"""
Utility library for checking and applying SecureDrop Workstation VM updates.

This library is meant to be called by the SecureDrop launcher, which
is opened by the user when clicking on the desktop, opening
/usr/bin/sdw-updater.
"""

import json
import os
import subprocess
import threading
import time
from datetime import datetime, timedelta
from enum import Enum

from sdw_util import Util

DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
DEFAULT_HOME = ".securedrop_updater"
FLAG_FILE_STATUS_SD_APP = "/home/user/.securedrop_client/sdw-update-status"
FLAG_FILE_LAST_UPDATED_SD_APP = "/home/user/.securedrop_client/sdw-last-updated"
FLAG_FILE_STATUS_DOM0 = os.path.join(DEFAULT_HOME, "sdw-update-status")
FLAG_FILE_LAST_UPDATED_DOM0 = os.path.join(DEFAULT_HOME, "sdw-last-updated")
LOCK_FILE = "sdw-updater.lock"
LOG_FILE = "updater.log"
DETAIL_LOG_FILE = "updater-detail.log"
DETAIL_LOGGER_PREFIX = "detail"  # For detailed logs such as Salt states

# We use a hardcoded temporary directory path in dom0. As dom0 is not
# a multi-user environment, we can safely assume that only the Updater is
# managing that filepath. Later on, we should consider porting the check-migration
# logic to leverage the Qubes Python API.
MIGRATION_DIR = "/tmp/sdw-migrations"

DEBIAN_VERSION = "bookworm"

sdlog = Util.get_logger(module=__name__)
detail_log = Util.get_logger(prefix=DETAIL_LOGGER_PREFIX, module=__name__)

# The are the TemplateVMs that require full patch level at boot in order to start the client,
# as well as their associated TemplateVMs.
# In the future, we could use qvm-prefs to extract this information.
current_vms = {
    "fedora": "fedora-40-xfce",
    "sd-viewer": f"sd-large-{DEBIAN_VERSION}-template",
    "sd-app": f"sd-small-{DEBIAN_VERSION}-template",
    "sd-log": f"sd-small-{DEBIAN_VERSION}-template",
    "sd-devices": f"sd-large-{DEBIAN_VERSION}-template",
    "sd-proxy": f"sd-small-{DEBIAN_VERSION}-template",
    "sd-whonix": "whonix-gateway-17",
    "sd-gpg": f"sd-small-{DEBIAN_VERSION}-template",
}

current_templates = set([val for key, val in current_vms.items() if key != "dom0"])


def get_dom0_path(folder):
    return os.path.join(os.path.expanduser("~"), folder)


def run_full_install():
    """
    Re-apply the entire Salt config via sdw-admin. Required to enforce
    VM state during major migrations, such as template consolidation.
    """
    sdlog.info("Running 'sdw-admin --apply' to apply full system state")
    apply_cmd = ["sdw-admin", "--apply"]
    apply_cmd_for_log = (" ").join(apply_cmd)
    try:
        output = subprocess.check_output(apply_cmd)
    except subprocess.CalledProcessError as e:
        sdlog.error(f"Failed to apply full system state. Please review {DETAIL_LOG_FILE}.")
        sdlog.error(str(e))
        clean_output = Util.strip_ansi_colors(e.output.decode("utf-8").strip())
        detail_log.error(f"Output from failed command: {apply_cmd_for_log}\n{clean_output}")
        return UpdateStatus.UPDATES_FAILED

    clean_output = Util.strip_ansi_colors(output.decode("utf-8").strip())
    detail_log.info(f"Output from command: {apply_cmd_for_log}\n{clean_output}")

    # Clean up flag requesting migration. Shell out since root created it.
    rm_flag_cmd = ["sudo", "rm", "-rf", MIGRATION_DIR]
    try:
        subprocess.check_call(rm_flag_cmd)
    except subprocess.CalledProcessError as e:
        sdlog.error("Failed to remove migration flag.")
        sdlog.error(str(e))
        return UpdateStatus.UPDATES_FAILED

    sdlog.info("Full system state successfully applied and migration flag cleared.")
    return UpdateStatus.UPDATES_OK


def migration_is_required():
    """
    Check whether a full run of the Salt config via sdw-admin is required.
    """
    result = False
    if os.path.exists(MIGRATION_DIR) and len(os.listdir(MIGRATION_DIR)) > 0:
        sdlog.info("Migration is required, will enforce full config during update")
        result = True
    return result


def apply_updates_dom0():
    """
    Apply updates to dom0
    """
    sdlog.info("Applying all updates to dom0")

    dom0_status = _check_updates_dom0()
    if dom0_status == UpdateStatus.UPDATES_REQUIRED:
        upgrade_results = _apply_updates_dom0()
    else:
        upgrade_results = UpdateStatus.UPDATES_OK
    return upgrade_results


def apply_updates_templates(templates=current_templates, progress_callback=None):
    """
    Apply updates to all TemplateVMs.
    """
    sdlog.info(f"Applying all updates to VMs: {', '.join(templates)}")
    try:
        proc = _start_qubes_updater_proc(templates)
        result_update_status = {}
        stderr_thread = threading.Thread(
            target=_qubes_updater_parse_progress,
            args=(proc.stderr, result_update_status, templates, progress_callback),
        )
        stdout_thread = threading.Thread(target=_qubes_updater_parse_stdout, args=(proc.stdout,))
        stderr_thread.start()
        stdout_thread.start()

        while proc.poll() is None or stderr_thread.is_alive() or stdout_thread.is_alive():
            time.sleep(1)

        stderr_thread.join()
        stdout_thread.join()
        proc.stderr.close()
        proc.stdout.close()
        update_status = overall_update_status(result_update_status)
        if update_status == UpdateStatus.UPDATES_OK:
            sdlog.info("Template updates successful")
        else:
            sdlog.info("Template updates failed")
        return update_status

    except subprocess.CalledProcessError as e:
        sdlog.error(
            "An error has occurred updating templates. Please contact your administrator."
            f" See {DETAIL_LOG_FILE} for details."
        )
        sdlog.error(str(e))
        return UpdateStatus.UPDATES_FAILED


def _start_qubes_updater_proc(templates):
    update_cmd = [
        "qubes-vm-update",
        "--apply-to-all",  # Enforce app qube restarts
        "--force-update",  # Bypass Qubes' staleness-dection and update all
        "--show-output",  # Update transaction details (goes to stdout)
        "--just-print-progress",  # Progress reporting (goes to stderr)
        "--targets",
        ",".join(templates),
    ]
    detail_log.info("Starting Qubes Updater with command: {}".format(" ".join(update_cmd)))
    return subprocess.Popen(
        update_cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )


def _qubes_updater_parse_stdout(stream):
    while True:
        untrusted_line = stream.readline()
        if len(untrusted_line) == 0:
            break

        line = Util.strip_ansi_colors(untrusted_line.decode("utf-8"))
        line = line.rstrip()
        detail_log.info(f"[Qubes updater] {line}")


def _qubes_updater_parse_progress(stream, result, templates, progress_callback=None):
    update_progress = {}

    for template in templates:
        result[template] = UpdateStatus.UPDATES_IN_PROGRESS

    while True:
        untrusted_line = stream.readline()
        if len(untrusted_line) == 0:
            break

        line = Util.strip_ansi_colors(untrusted_line.decode("utf-8").rstrip())
        try:
            vm, status, info = line.split()
        except ValueError:
            sdlog.warn("Line in Qubes updater's output could not be parsed")
            continue

        if status == "updating":
            if update_progress.get(vm) is None:
                sdlog.info(f"Starting update on template: '{vm}'")
                update_progress[vm] = 0
            else:
                vm_progress = int(float(info))
                update_progress[vm] = vm_progress
                if progress_callback:
                    progress_callback(sum(update_progress.values()) // len(templates))

        # First time complete (status "done") may be repeated various times
        if status == "done" and result[vm] == UpdateStatus.UPDATES_IN_PROGRESS:
            result[vm] = UpdateStatus.from_qubes_updater_name(info)
            if result[vm] == UpdateStatus.UPDATES_OK:
                sdlog.info(f"Update successful for template: '{vm}'")
                update_progress[vm] = 100
            else:
                sdlog.error(f"Update failed for template: '{vm}'")


def _check_updates_dom0():
    """
    We need to reboot the system after every dom0 update. The update
    script does not tell us through its exit code whether updates were applied,
    and parsing command output can be brittle.

    For this reason, we check for available updates first. The result of this
    check is cached, so it does not incur a significant performance penalty.
    """
    try:
        subprocess.check_call(["sudo", "qubes-dom0-update", "--check-only"])
    except subprocess.CalledProcessError as e:
        sdlog.error("dom0 updates required, or cannot check for updates")
        sdlog.error(str(e))
        return UpdateStatus.UPDATES_REQUIRED

    sdlog.info("No updates available for dom0")
    return UpdateStatus.UPDATES_OK


def _apply_updates_dom0():
    """
    Apply updates to dom0. Any update to dom0 will require a reboot after
    the upgrade.
    """
    sdlog.info("Updating dom0")
    try:
        subprocess.check_call(["sudo", "qubes-dom0-update", "-y"])
    except subprocess.CalledProcessError as e:
        sdlog.error("An error has occurred updating dom0. Please contact your administrator.")
        sdlog.error(str(e))
        return UpdateStatus.UPDATES_FAILED

    sdlog.info("dom0 updates have been applied and a reboot is required.")
    return UpdateStatus.REBOOT_REQUIRED


def _write_last_updated_flags_to_disk():
    """
    Writes the time of last successful upgrade to dom0 and sd-app
    """
    current_date = str(datetime.now().strftime(DATE_FORMAT))

    flag_file_sd_app_last_updated = FLAG_FILE_LAST_UPDATED_SD_APP
    flag_file_dom0_last_updated = get_dom0_path(FLAG_FILE_LAST_UPDATED_DOM0)

    try:
        sdlog.info(f"Setting last updated to {current_date} in sd-app")
        subprocess.check_call(
            [
                "qvm-run",
                "sd-app",
                f"echo '{current_date}' > {flag_file_sd_app_last_updated}",
            ]
        )
    except subprocess.CalledProcessError as e:
        sdlog.error("Error writing last updated flag to sd-app")
        sdlog.error(str(e))

    try:
        sdlog.info(f"Setting last updated to {current_date} in dom0")
        if not os.path.exists(os.path.dirname(flag_file_dom0_last_updated)):
            os.makedirs(os.path.dirname(flag_file_dom0_last_updated))
        with open(flag_file_dom0_last_updated, "w") as f:
            f.write(current_date)
    except Exception as e:
        sdlog.error("Error writing last updated flag to dom0")
        sdlog.error(str(e))


def _write_updates_status_flag_to_disk(status):
    """
    Writes the latest SecureDrop Workstation update status to disk, on both
    dom0 and sd-app for futher processing in the future.
    """
    flag_file_path_sd_app = FLAG_FILE_STATUS_SD_APP
    flag_file_path_dom0 = get_dom0_path(FLAG_FILE_STATUS_DOM0)

    try:
        sdlog.info(f"Setting update flag to {status.value} in sd-app")
        subprocess.check_call(
            ["qvm-run", "sd-app", f"echo '{status.value}' > {flag_file_path_sd_app}"]
        )
    except subprocess.CalledProcessError as e:
        sdlog.error("Error writing update status flag to sd-app")
        sdlog.error(str(e))

    try:
        sdlog.info(f"Setting update flag to {status.value} in dom0")
        if not os.path.exists(os.path.dirname(flag_file_path_dom0)):
            os.makedirs(os.path.dirname(flag_file_path_dom0))

        current_date = str(datetime.now().strftime(DATE_FORMAT))

        with open(flag_file_path_dom0, "w") as f:
            flag_contents = {"last_status_update": current_date, "status": status.value}
            json.dump(flag_contents, f)
    except Exception as e:
        sdlog.error("Error writing update status flag to dom0")
        sdlog.error(str(e))


def last_required_reboot_performed():
    """
    Checks if the dom0 flag file indicates that a reboot is required, and
    if so, will check current uptime with the data at which the reboot
    was requested. This will be used by the _write_updates_status_flag_to_disk
    function to preserve the status UPDATES_REQUIRED instead of updating.
    """
    flag_contents = read_dom0_update_flag_from_disk(with_timestamp=True)

    # No flag exists on disk (yet)
    if flag_contents is None:
        return True

    if int(flag_contents["status"]) == int(UpdateStatus.REBOOT_REQUIRED.value):
        reboot_time = datetime.strptime(flag_contents["last_status_update"], DATE_FORMAT)
        boot_time = datetime.now() - _get_uptime()

        # The session was started *before* the reboot was requested by
        # the updater, system was not rebooted after previous run
        if boot_time < reboot_time:
            return False

        # system was rebooted after flag was written to disk
        return True

    # previous run did not require reboot
    return True


def _get_uptime():
    """
    Returns timedelta containing system (dom0) uptime.
    """
    uptime = None
    with open("/proc/uptime") as f:
        uptime = f.read().split(" ")[0].strip()
    uptime = int(float(uptime))
    uptime_hours = uptime // 3600
    uptime_minutes = (uptime % 3600) // 60
    uptime_seconds = uptime % 60

    return timedelta(hours=uptime_hours, minutes=uptime_minutes, seconds=uptime_seconds)


def read_dom0_update_flag_from_disk(with_timestamp=False):
    """
    Read the last updated SecureDrop Workstation update status from disk
    in dom0, and returns the corresponding UpdateStatus. If ivoked the
    parameter `with_timestamp=True`, this function will return the full json.
    """
    flag_file_path_dom0 = get_dom0_path(FLAG_FILE_STATUS_DOM0)

    try:
        with open(flag_file_path_dom0) as f:
            contents = json.load(f)
            for status in UpdateStatus:
                if int(contents["status"]) == int(status.value):
                    if with_timestamp:
                        return contents

                    return status
    except Exception:
        sdlog.info("Cannot read dom0 status flag, assuming first run")
        return None


def overall_update_status(results):
    """
    Helper method that returns the worst-case status
    For now, simple logic for reboot required: If dom0 or fedora updates, a
    reboot will be required.
    """
    updates_failed = False
    updates_required = False
    reboot_required = False

    # Ensure the user has rebooted after the previous installer run required a reboot
    if not last_required_reboot_performed():
        return UpdateStatus.REBOOT_REQUIRED

    for result in results.values():
        if result == UpdateStatus.UPDATES_FAILED:
            updates_failed = True
        if result == UpdateStatus.UPDATES_IN_PROGRESS:
            updates_failed = True
        elif result == UpdateStatus.REBOOT_REQUIRED:
            reboot_required = True
        elif result == UpdateStatus.UPDATES_REQUIRED:
            updates_required = True

    if updates_failed:
        return UpdateStatus.UPDATES_FAILED
    if reboot_required:
        return UpdateStatus.REBOOT_REQUIRED
    if updates_required:
        return UpdateStatus.UPDATES_REQUIRED

    return UpdateStatus.UPDATES_OK


def apply_dom0_state():
    """
    Applies the dom0 state to ensure dom0 and AppVMs are properly
    Configured. This will *not* enforce configuration inside the AppVMs.
    Here, we call qubectl directly (instead of through sdw-admin) to
    ensure it is environment-specific.
    """
    sdlog.info("Applying dom0 state")
    cmd = ["sudo", "qubesctl", "--show-output", "state.highstate"]
    cmd_for_log = " ".join(cmd)
    try:
        output = subprocess.check_output(cmd)
        sdlog.info("Dom0 state applied")
        clean_output = Util.strip_ansi_colors(output.decode("utf-8").strip())
        detail_log.info(f"Output from command: {cmd_for_log}\n{clean_output}")
        return UpdateStatus.UPDATES_OK
    except subprocess.CalledProcessError as e:
        sdlog.error(f"Failed to apply dom0 state. See {DETAIL_LOG_FILE} for details.")
        sdlog.error(str(e))
        clean_output = Util.strip_ansi_colors(e.output.decode("utf-8").strip())
        detail_log.error(f"Output from failed command: {cmd_for_log}\n{clean_output}")
        return UpdateStatus.UPDATES_FAILED


def should_launch_updater(interval):
    status = read_dom0_update_flag_from_disk(with_timestamp=True)

    if _valid_status(status):
        if _interval_expired(interval, status):
            sdlog.info("Update interval expired: launching updater.")
            return True
        if status["status"] == UpdateStatus.UPDATES_OK.value:
            sdlog.info("Updates OK and interval not expired, launching client.")
            return False
        if status["status"] == UpdateStatus.REBOOT_REQUIRED.value:
            if last_required_reboot_performed():
                sdlog.info("Required reboot performed, updating status and launching client.")
                _write_updates_status_flag_to_disk(UpdateStatus.UPDATES_OK)
                return False

            sdlog.info("Required reboot pending, launching updater")
            return True
        if status["status"] == UpdateStatus.UPDATES_REQUIRED.value:
            sdlog.info("Updates are required, launching updater.")
            return True
        if status["status"] == UpdateStatus.UPDATES_FAILED.value:
            sdlog.info("Preceding update failed, launching updater.")
            return True

        sdlog.info("Update status is unknown, launching updater.")
        return True

    sdlog.info("Update status not available, launching updater.")
    return True


def _valid_status(status):
    """
    status should contain 2 items, the update flag and a timestamp.
    """
    if isinstance(status, dict) and len(status) == 2:
        return True
    return False


def _interval_expired(interval, status):
    """
    Check if specified update interval has expired.
    """

    try:
        update_time = datetime.strptime(status["last_status_update"], DATE_FORMAT)
    except ValueError:
        # Broken timestamp? run the updater.
        return True
    if (datetime.now() - update_time) < timedelta(seconds=interval):
        return False
    return True


class UpdateStatus(Enum):
    """
    Standardizes return codes for update/upgrade methods
    """

    UPDATES_OK = "0"
    UPDATES_REQUIRED = "1"
    REBOOT_REQUIRED = "2"
    UPDATES_FAILED = "3"
    UPDATES_IN_PROGRESS = "4"

    @classmethod
    def from_qubes_updater_name(cls, name):
        """
        Maps qubes updater's terminology to SDW Updater one. Upstream code found in:
        https://github.com/QubesOS/qubes-desktop-linux-manager/blob/4afc35/qui/updater/utils.py#L199C25-L204C27
        """
        names = {
            "success": cls.UPDATES_OK,
            "error": cls.UPDATES_FAILED,
            "no_updates": cls.UPDATES_OK,
            "cancelled": cls.UPDATES_FAILED,
        }
        try:
            return names[name]
        except KeyError:
            sdlog.error("Qubes updater provided an invalid update status.")
            return cls.UPDATES_FAILED
