"""Standalone, rollback-capable updater for MusicGridStudio."""

from __future__ import annotations

import argparse
import ctypes
from datetime import datetime
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import sys
import tempfile
import time

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from musicgridstudio.update_support import (
    ARCHITECTURE,
    PRODUCT_ID,
    UpdatePackageError,
    extract_verified_payload,
    inspect_update_package,
)


MESSAGE_TITLE = "MusicGridStudio Update"


def _message(text: str, *, error: bool = False, silent: bool = False) -> None:
    if silent:
        stream = sys.stderr if error else sys.stdout
        print(text, file=stream)
        return
    flags = 0x10 if error else 0x40
    ctypes.windll.user32.MessageBoxW(None, text, MESSAGE_TITLE, flags)


def _wait_for_process(process_id: int, timeout_seconds: int = 60) -> None:
    if process_id <= 0:
        return
    deadline = time.monotonic() + timeout_seconds
    process_query_limited_information = 0x1000
    while time.monotonic() < deadline:
        handle = ctypes.windll.kernel32.OpenProcess(
            process_query_limited_information,
            False,
            process_id,
        )
        if not handle:
            return
        ctypes.windll.kernel32.CloseHandle(handle)
        time.sleep(0.25)
    raise UpdatePackageError(
        "MusicGridStudio did not close in time. Close it and try again."
    )


def _safe_target(root: Path, relative_path: str) -> Path:
    target = root.joinpath(*PurePosixPath(relative_path).parts).resolve()
    root_prefix = str(root.resolve()).rstrip("\\/") + os.sep
    if not str(target).startswith(root_prefix):
        raise UpdatePackageError(f"Unsafe update path: {relative_path}")
    return target


def _is_protected_install(directory: Path) -> bool:
    target = str(directory.resolve()).casefold()
    for environment_name in ("ProgramFiles", "ProgramFiles(x86)"):
        root = os.environ.get(environment_name)
        if not root:
            continue
        prefix = str(Path(root).resolve()).rstrip("\\/").casefold() + os.sep
        if target == prefix.rstrip(os.sep) or target.startswith(prefix):
            return True
    return False


def _is_administrator() -> bool:
    try:
        return bool(ctypes.windll.shell32.IsUserAnAdmin())
    except OSError:
        return False


def _quote_windows_argument(value: str) -> str:
    import subprocess

    return subprocess.list2cmdline([value])


def _relaunch_elevated(arguments: list[str]) -> None:
    executable = str(Path(sys.executable).resolve())
    parameters = " ".join(
        _quote_windows_argument(argument)
        for argument in (*arguments, "--elevated")
    )
    result = ctypes.windll.shell32.ShellExecuteW(
        None,
        "runas",
        executable,
        parameters,
        None,
        0,
    )
    if result <= 32:
        raise UpdatePackageError(
            "Administrator permission was not granted. The update was cancelled."
        )


def _backup_root(default_override: str) -> Path:
    if default_override:
        return Path(default_override).resolve()
    local_app_data = os.environ.get(
        "LOCALAPPDATA",
        str(Path.home() / "AppData" / "Local"),
    )
    return (
        Path(local_app_data)
        / "LeLien Consulting"
        / "MusicGridStudio"
        / "update-backups"
    )


def _replace_file(source: Path, target: Path) -> None:
    target.parent.mkdir(parents=True, exist_ok=True)
    pending = target.with_name(target.name + ".mgs-update-new")
    pending.unlink(missing_ok=True)
    shutil.copy2(source, pending)
    os.replace(pending, target)


def apply_update(
    *,
    patch_path: Path,
    install_directory: Path,
    current_version: str,
    wait_process_id: int,
    backup_root: Path,
) -> str:
    package = inspect_update_package(patch_path, current_version)
    _wait_for_process(wait_process_id)
    if not (install_directory / "MusicGridStudio.exe").is_file():
        raise UpdatePackageError(
            "MusicGridStudio.exe was not found in the selected installation."
        )

    temporary_root = Path(
        tempfile.mkdtemp(prefix="MusicGridStudioUpdate-")
    )
    payload_root = temporary_root / "payload"
    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    backup_directory = (
        backup_root / f"{current_version}-before-{package.to_version}-{timestamp}"
    )
    backed_up: list[str] = []
    created: list[str] = []
    try:
        extract_verified_payload(package, payload_root)
        backup_directory.mkdir(parents=True, exist_ok=False)

        affected_paths = [
            update_file.path for update_file in package.files
        ] + list(package.remove)
        for relative_path in affected_paths:
            target = _safe_target(install_directory, relative_path)
            if target.is_file():
                backup = _safe_target(backup_directory, relative_path)
                backup.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(target, backup)
                backed_up.append(relative_path)
            elif relative_path not in package.remove:
                created.append(relative_path)

        for update_file in package.files:
            source = _safe_target(payload_root, update_file.path)
            target = _safe_target(install_directory, update_file.path)
            _replace_file(source, target)

        for relative_path in package.remove:
            target = _safe_target(install_directory, relative_path)
            if target.is_file():
                target.unlink()

        result = {
            "schema": 1,
            "product": PRODUCT_ID,
            "architecture": ARCHITECTURE,
            "from_version": package.from_version,
            "to_version": package.to_version,
            "installed_at": datetime.now().astimezone().isoformat(),
            "backup_directory": str(backup_directory),
        }
        state_directory = backup_root.parent
        state_directory.mkdir(parents=True, exist_ok=True)
        (state_directory / "last_update.json").write_text(
            json.dumps(result, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
        return package.to_version
    except Exception:
        for relative_path in created:
            target = _safe_target(install_directory, relative_path)
            target.unlink(missing_ok=True)
        for relative_path in backed_up:
            backup = _safe_target(backup_directory, relative_path)
            target = _safe_target(install_directory, relative_path)
            if backup.is_file():
                _replace_file(backup, target)
        raise
    finally:
        shutil.rmtree(temporary_root, ignore_errors=True)


def _arguments() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--patch", required=True)
    parser.add_argument("--install-dir", required=True)
    parser.add_argument("--wait-pid", type=int, default=0)
    parser.add_argument("--current-version", required=True)
    parser.add_argument("--backup-root", default="")
    parser.add_argument("--silent", action="store_true")
    parser.add_argument("--elevated", action="store_true")
    return parser.parse_args()


def main() -> int:
    arguments = _arguments()
    install_directory = Path(arguments.install_dir).resolve()
    if (
        _is_protected_install(install_directory)
        and not _is_administrator()
        and not arguments.elevated
    ):
        try:
            forwarded = [
                "--patch",
                arguments.patch,
                "--install-dir",
                arguments.install_dir,
                "--wait-pid",
                str(arguments.wait_pid),
                "--current-version",
                arguments.current_version,
            ]
            if arguments.backup_root:
                forwarded.extend(["--backup-root", arguments.backup_root])
            if arguments.silent:
                forwarded.append("--silent")
            _relaunch_elevated(forwarded)
            return 0
        except Exception as exc:
            _message(str(exc), error=True, silent=arguments.silent)
            return 1

    try:
        installed_version = apply_update(
            patch_path=Path(arguments.patch).resolve(),
            install_directory=install_directory,
            current_version=arguments.current_version,
            wait_process_id=arguments.wait_pid,
            backup_root=_backup_root(arguments.backup_root),
        )
    except Exception as exc:
        _message(
            "The update could not be applied. Existing files were restored.\n\n"
            f"{exc}",
            error=True,
            silent=arguments.silent,
        )
        return 1
    _message(
        f"MusicGridStudio was updated to version {installed_version}.\n\n"
        "You can now start MusicGridStudio again.",
        silent=arguments.silent,
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
