"""Verified update-package inspection and updater process launching."""

from __future__ import annotations

from dataclasses import dataclass
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import subprocess
import sys
import tempfile
import uuid
import zipfile


UPDATE_SCHEMA = 1
PRODUCT_ID = "MusicGridStudio"
ARCHITECTURE = "windows-x64"
MANIFEST_NAME = "update_manifest.json"
PAYLOAD_PREFIX = "payload/"
MAX_UPDATE_FILES = 20_000
MAX_UPDATE_BYTES = 2 * 1024 * 1024 * 1024


class UpdatePackageError(ValueError):
    """Raised when an update package is incompatible or fails validation."""


@dataclass(frozen=True, slots=True)
class UpdateFile:
    path: str
    sha256: str
    size: int


@dataclass(frozen=True, slots=True)
class UpdatePackageInfo:
    path: Path
    from_version: str
    to_version: str
    files: tuple[UpdateFile, ...]
    remove: tuple[str, ...]

    @property
    def payload_size(self) -> int:
        return sum(file.size for file in self.files)


def parse_version(value: object) -> tuple[int, ...]:
    text = str(value or "").strip()
    parts = text.split(".")
    if not 2 <= len(parts) <= 4 or any(
        not part.isdecimal() for part in parts
    ):
        raise UpdatePackageError(f"Invalid version number: {text or '(empty)'}")
    return tuple(int(part) for part in parts)


def normalize_relative_path(value: object) -> str:
    text = str(value or "").replace("\\", "/").strip("/")
    path = PurePosixPath(text)
    if (
        not text
        or path.is_absolute()
        or any(part in {"", ".", ".."} for part in path.parts)
        or ":" in text
    ):
        raise UpdatePackageError(f"Unsafe update path: {value!s}")
    return path.as_posix()


def file_sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as source:
        for chunk in iter(lambda: source.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest().upper()


def _member_sha256(archive: zipfile.ZipFile, member_name: str) -> str:
    digest = hashlib.sha256()
    with archive.open(member_name, "r") as source:
        for chunk in iter(lambda: source.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest().upper()


def _load_manifest(archive: zipfile.ZipFile) -> dict:
    names = archive.namelist()
    if len(names) != len(set(names)):
        raise UpdatePackageError("The update ZIP contains duplicate entries.")
    try:
        raw = archive.read(MANIFEST_NAME)
    except KeyError as exc:
        raise UpdatePackageError(
            "update_manifest.json was not found in the update package."
        ) from exc
    try:
        manifest = json.loads(raw.decode("utf-8-sig"))
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise UpdatePackageError(
            "The update manifest could not be read."
        ) from exc
    if not isinstance(manifest, dict):
        raise UpdatePackageError("The update manifest has an invalid format.")
    return manifest


def inspect_update_package(
    package_path: str | Path,
    current_version: object,
) -> UpdatePackageInfo:
    package = Path(package_path)
    if not package.is_file():
        raise UpdatePackageError("The selected update package does not exist.")
    try:
        archive = zipfile.ZipFile(package)
    except (OSError, zipfile.BadZipFile) as exc:
        raise UpdatePackageError(
            "The selected file is not a valid update ZIP."
        ) from exc

    with archive:
        manifest = _load_manifest(archive)
        if manifest.get("schema") != UPDATE_SCHEMA:
            raise UpdatePackageError("This update format is not supported.")
        if manifest.get("product") != PRODUCT_ID:
            raise UpdatePackageError(
                "This update package is for a different product."
            )
        if manifest.get("architecture") != ARCHITECTURE:
            raise UpdatePackageError(
                "This update package is not for Windows x64."
            )

        from_version = str(manifest.get("from_version", "")).strip()
        to_version = str(manifest.get("to_version", "")).strip()
        current_text = str(current_version or "").strip()
        current_tuple = parse_version(current_text)
        from_tuple = parse_version(from_version)
        to_tuple = parse_version(to_version)
        if from_tuple != current_tuple:
            raise UpdatePackageError(
                f"This package updates version {from_version}, "
                f"but the installed version is {current_text}."
            )
        if to_tuple <= current_tuple:
            raise UpdatePackageError(
                "The selected package is not newer than the installed version."
            )

        raw_files = manifest.get("files")
        if not isinstance(raw_files, list) or not raw_files:
            raise UpdatePackageError(
                "The update package does not contain any replacement files."
            )
        if len(raw_files) > MAX_UPDATE_FILES:
            raise UpdatePackageError("The update package contains too many files.")

        files: list[UpdateFile] = []
        file_paths: set[str] = set()
        total_size = 0
        archive_names = set(archive.namelist())
        for raw_file in raw_files:
            if not isinstance(raw_file, dict):
                raise UpdatePackageError(
                    "The update file list has an invalid format."
                )
            relative_path = normalize_relative_path(raw_file.get("path"))
            if relative_path in file_paths:
                raise UpdatePackageError(
                    f"Duplicate update path: {relative_path}"
                )
            file_paths.add(relative_path)
            expected_hash = str(raw_file.get("sha256", "")).upper()
            if (
                len(expected_hash) != 64
                or any(character not in "0123456789ABCDEF" for character in expected_hash)
            ):
                raise UpdatePackageError(
                    f"Invalid SHA-256 value for {relative_path}."
                )
            try:
                expected_size = int(raw_file.get("size"))
            except (TypeError, ValueError) as exc:
                raise UpdatePackageError(
                    f"Invalid file size for {relative_path}."
                ) from exc
            if expected_size < 0:
                raise UpdatePackageError(
                    f"Invalid file size for {relative_path}."
                )
            total_size += expected_size
            if total_size > MAX_UPDATE_BYTES:
                raise UpdatePackageError("The update package is too large.")

            member_name = PAYLOAD_PREFIX + relative_path
            if member_name not in archive_names:
                raise UpdatePackageError(
                    f"Payload file is missing: {relative_path}"
                )
            member = archive.getinfo(member_name)
            if member.is_dir() or member.file_size != expected_size:
                raise UpdatePackageError(
                    f"Payload size does not match: {relative_path}"
                )
            actual_hash = _member_sha256(archive, member_name)
            if actual_hash != expected_hash:
                raise UpdatePackageError(
                    f"Payload verification failed: {relative_path}"
                )
            files.append(
                UpdateFile(relative_path, expected_hash, expected_size)
            )

        raw_remove = manifest.get("remove", [])
        if not isinstance(raw_remove, list):
            raise UpdatePackageError(
                "The obsolete-file list has an invalid format."
            )
        remove: list[str] = []
        remove_paths: set[str] = set()
        for raw_path in raw_remove:
            relative_path = normalize_relative_path(raw_path)
            if relative_path in file_paths or relative_path in remove_paths:
                raise UpdatePackageError(
                    f"Conflicting update path: {relative_path}"
                )
            remove_paths.add(relative_path)
            remove.append(relative_path)

    return UpdatePackageInfo(
        package.resolve(),
        from_version,
        to_version,
        tuple(files),
        tuple(remove),
    )


def extract_verified_payload(
    package: UpdatePackageInfo,
    destination: Path,
) -> None:
    destination.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(package.path) as archive:
        for update_file in package.files:
            target = destination.joinpath(*PurePosixPath(update_file.path).parts)
            target.parent.mkdir(parents=True, exist_ok=True)
            member_name = PAYLOAD_PREFIX + update_file.path
            digest = hashlib.sha256()
            size = 0
            with archive.open(member_name, "r") as source, target.open(
                "wb"
            ) as output:
                for chunk in iter(lambda: source.read(1024 * 1024), b""):
                    output.write(chunk)
                    digest.update(chunk)
                    size += len(chunk)
            if (
                size != update_file.size
                or digest.hexdigest().upper() != update_file.sha256
            ):
                raise UpdatePackageError(
                    f"Payload verification failed: {update_file.path}"
                )


def is_frozen_application() -> bool:
    return bool(getattr(sys, "frozen", False))


def installed_updater_path() -> Path:
    return Path(sys.executable).resolve().with_name(
        "MusicGridStudioUpdater.exe"
    )


def updater_is_available() -> bool:
    return is_frozen_application() and installed_updater_path().is_file()


def launch_update_installer(
    package: UpdatePackageInfo,
    current_version: str,
) -> None:
    updater = installed_updater_path()
    if not updater_is_available():
        raise UpdatePackageError(
            "The updater is available only in the packaged Windows edition."
        )
    temporary_directory = (
        Path(tempfile.gettempdir()) / "MusicGridStudioUpdater"
    )
    temporary_directory.mkdir(parents=True, exist_ok=True)
    temporary_updater = temporary_directory / (
        f"MusicGridStudioUpdater-{uuid.uuid4().hex}.exe"
    )
    shutil.copy2(updater, temporary_updater)
    arguments = [
        str(temporary_updater),
        "--patch",
        str(package.path),
        "--install-dir",
        str(Path(sys.executable).resolve().parent),
        "--wait-pid",
        str(os.getpid()),
        "--current-version",
        str(current_version),
    ]
    creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
    try:
        subprocess.Popen(
            arguments,
            close_fds=True,
            creationflags=creation_flags,
        )
    except OSError as exc:
        temporary_updater.unlink(missing_ok=True)
        raise UpdatePackageError(
            f"The updater could not be started.\n\n{exc}"
        ) from exc
