"""Small standard-library launcher and updater for Boqsc Chat on Windows."""

from __future__ import annotations

import hashlib
import json
import os
import re
import subprocess
import sys
import threading
import tkinter as tk
import urllib.error
import urllib.parse
import urllib.request
import uuid
from dataclasses import dataclass
from pathlib import Path
from tkinter import ttk
from typing import Any, Callable, Dict, Optional


APP_NAME = "Boqsc Chat"
LAUNCHER_VERSION = "1.0.0"
BASE_URL = "https://chat.boqsc.eu"
MANIFEST_URL = f"{BASE_URL}/download/latest.json"
CLIENT_FILE_NAME = "BoqscChat.exe"
STATE_FILE_NAME = "launcher-state.json"
MAX_MANIFEST_BYTES = 64 * 1024
MAX_CLIENT_BYTES = 100 * 1024 * 1024
REQUEST_TIMEOUT_SECONDS = 15
CLIENT_MUTEX_NAME = "Local\\BoqscChat-8AEF76D1-4C91-4E5E-9D5A-718A5E46B641"
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+){1,3}(?:[-+][a-zA-Z0-9.-]+)?$")


class LauncherError(RuntimeError):
    pass


class DownloadCancelled(LauncherError):
    pass


@dataclass(frozen=True)
class Release:
    version: str
    file_name: str
    sha256: str
    byte_size: int


def app_data_dir() -> Path:
    local = os.environ.get("LOCALAPPDATA")
    if not local:
        raise LauncherError("Windows Local AppData could not be located.")
    return Path(local) / "BoqscChat"


def validate_manifest(payload: Any) -> Release:
    if not isinstance(payload, dict):
        raise LauncherError("The release manifest is invalid.")
    version = str(payload.get("version") or "").strip()
    file_name = str(payload.get("file") or "").strip()
    digest = str(payload.get("sha256") or "").strip().lower()
    try:
        byte_size = int(payload.get("bytes"))
    except (TypeError, ValueError) as exc:
        raise LauncherError("The release size is invalid.") from exc
    if not VERSION_RE.fullmatch(version):
        raise LauncherError("The release version is invalid.")
    if file_name != CLIENT_FILE_NAME:
        raise LauncherError("The release filename is not allowed.")
    if not SHA256_RE.fullmatch(digest):
        raise LauncherError("The release checksum is invalid.")
    if byte_size < 1 or byte_size > MAX_CLIENT_BYTES:
        raise LauncherError("The release size is outside the allowed range.")
    return Release(version=version, file_name=file_name, sha256=digest, byte_size=byte_size)


def _same_https_origin(url: str) -> bool:
    expected = urllib.parse.urlparse(BASE_URL)
    actual = urllib.parse.urlparse(url)
    return (
        actual.scheme == "https"
        and actual.hostname == expected.hostname
        and (actual.port or 443) == (expected.port or 443)
    )


def _request(url: str) -> urllib.request.Request:
    return urllib.request.Request(
        url,
        headers={
            "Accept": "application/json, application/octet-stream;q=0.9",
            "User-Agent": f"BoqscChatLauncher/{LAUNCHER_VERSION} Windows",
        },
    )


def fetch_release() -> Release:
    if not _same_https_origin(MANIFEST_URL):
        raise LauncherError("The configured manifest address is not trusted.")
    try:
        with urllib.request.urlopen(_request(MANIFEST_URL), timeout=REQUEST_TIMEOUT_SECONDS) as response:
            if not _same_https_origin(response.geturl()):
                raise LauncherError("The release manifest redirected to an untrusted address.")
            raw = response.read(MAX_MANIFEST_BYTES + 1)
    except (urllib.error.URLError, TimeoutError, OSError) as exc:
        raise LauncherError(f"Could not check for updates: {exc}") from exc
    if len(raw) > MAX_MANIFEST_BYTES:
        raise LauncherError("The release manifest is unexpectedly large.")
    try:
        payload = json.loads(raw.decode("utf-8-sig"))
    except (UnicodeDecodeError, ValueError) as exc:
        raise LauncherError("The release manifest could not be read.") from exc
    return validate_manifest(payload)


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        while True:
            chunk = handle.read(1024 * 1024)
            if not chunk:
                break
            digest.update(chunk)
    return digest.hexdigest()


def load_state(directory: Path) -> Optional[Dict[str, Any]]:
    try:
        payload = json.loads((directory / STATE_FILE_NAME).read_text(encoding="utf-8"))
    except (OSError, ValueError, TypeError):
        return None
    return payload if isinstance(payload, dict) else None


def save_state(directory: Path, release: Release) -> None:
    state_path = directory / STATE_FILE_NAME
    temporary = state_path.with_suffix(".tmp")
    payload = {
        "version": release.version,
        "file": release.file_name,
        "sha256": release.sha256,
        "bytes": release.byte_size,
        "launcher_version": LAUNCHER_VERSION,
    }
    try:
        temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8")
        os.replace(temporary, state_path)
    finally:
        try:
            temporary.unlink()
        except OSError:
            pass


def remember_state(directory: Path, release: Release) -> None:
    """Persist offline verification metadata without blocking a valid launch."""
    try:
        save_state(directory, release)
    except OSError:
        pass


def installed_matches(path: Path, release: Release) -> bool:
    try:
        return path.is_file() and path.stat().st_size == release.byte_size and sha256_file(path) == release.sha256
    except OSError:
        return False


def verified_offline_release(directory: Path) -> Optional[Release]:
    state = load_state(directory)
    if state is None:
        return None
    try:
        release = validate_manifest(state)
    except LauncherError:
        return None
    return release if installed_matches(directory / CLIENT_FILE_NAME, release) else None


def remove_stale_downloads(directory: Path) -> None:
    for candidate in directory.glob(f"{CLIENT_FILE_NAME}.part-*"):
        try:
            candidate.unlink()
        except OSError:
            pass


def download_release(
    directory: Path,
    release: Release,
    progress: Callable[[int, int], None],
    cancelled: threading.Event,
) -> Path:
    directory.mkdir(parents=True, exist_ok=True)
    remove_stale_downloads(directory)
    target = directory / CLIENT_FILE_NAME
    temporary = directory / f"{CLIENT_FILE_NAME}.part-{uuid.uuid4().hex}"
    download_url = f"{BASE_URL}/download/{urllib.parse.quote(release.file_name)}"
    digest = hashlib.sha256()
    received = 0
    try:
        with urllib.request.urlopen(_request(download_url), timeout=REQUEST_TIMEOUT_SECONDS) as response:
            if not _same_https_origin(response.geturl()):
                raise LauncherError("The client download redirected to an untrusted address.")
            supplied_length = response.headers.get("Content-Length")
            if supplied_length:
                try:
                    if int(supplied_length) != release.byte_size:
                        raise LauncherError("The server reported an unexpected download size.")
                except ValueError as exc:
                    raise LauncherError("The server reported an invalid download size.") from exc
            with temporary.open("xb") as output:
                while True:
                    if cancelled.is_set():
                        raise DownloadCancelled("Download cancelled.")
                    chunk = response.read(128 * 1024)
                    if not chunk:
                        break
                    received += len(chunk)
                    if received > release.byte_size or received > MAX_CLIENT_BYTES:
                        raise LauncherError("The client download is larger than expected.")
                    output.write(chunk)
                    digest.update(chunk)
                    progress(received, release.byte_size)
        if received != release.byte_size:
            raise LauncherError("The client download is incomplete.")
        if digest.hexdigest() != release.sha256:
            raise LauncherError("Security check failed: the downloaded client checksum does not match.")
        try:
            os.replace(temporary, target)
        except OSError as exc:
            raise LauncherError(
                "The installed client could not be replaced. Close Boqsc Chat and try again."
            ) from exc
    except DownloadCancelled:
        raise
    except LauncherError:
        raise
    except (urllib.error.URLError, TimeoutError, OSError) as exc:
        raise LauncherError(f"The client could not be downloaded: {exc}") from exc
    finally:
        try:
            temporary.unlink()
        except OSError:
            pass
    remember_state(directory, release)
    return target


def client_is_running() -> bool:
    if sys.platform != "win32":
        return False
    synchronize = 0x00100000
    kernel32 = __import__("ctypes").windll.kernel32
    kernel32.OpenMutexW.restype = __import__("ctypes").c_void_p
    handle = kernel32.OpenMutexW(synchronize, False, CLIENT_MUTEX_NAME)
    if not handle:
        return False
    kernel32.CloseHandle(handle)
    return True


def launch_client(path: Path) -> None:
    if not path.is_file():
        raise LauncherError("The installed client is missing.")
    try:
        subprocess.Popen(
            [str(path)],
            cwd=str(path.parent),
            close_fds=True,
            creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
        )
    except OSError as exc:
        raise LauncherError(f"Boqsc Chat could not be started: {exc}") from exc


class LauncherWindow:
    def __init__(self) -> None:
        self.cancelled = threading.Event()
        self.root = tk.Tk(className="BoqscChatLauncher")
        self.root.title(f"{APP_NAME} Launcher")
        self.root.geometry("480x270")
        self.root.resizable(False, False)
        self.root.configure(bg="#0b0e16")
        self.root.protocol("WM_DELETE_WINDOW", self.close)
        self._center()
        self._build()
        self.worker = threading.Thread(target=self.run_launcher, name="boqsc-launcher", daemon=True)
        self.root.after(180, self.worker.start)

    def _center(self) -> None:
        self.root.update_idletasks()
        width, height = 480, 270
        x = max(0, (self.root.winfo_screenwidth() - width) // 2)
        y = max(0, (self.root.winfo_screenheight() - height) // 2)
        self.root.geometry(f"{width}x{height}+{x}+{y}")

    def _build(self) -> None:
        header = tk.Frame(self.root, bg="#0b0e16")
        header.pack(fill="x", padx=28, pady=(25, 0))
        logo = tk.Canvas(header, width=44, height=44, bg="#0b0e16", highlightthickness=0)
        logo.pack(side="left", padx=(0, 13))
        logo.create_rectangle(2, 2, 42, 42, fill="#7b6bed", outline="#a99dff", width=1)
        logo.create_oval(12, 14, 18, 20, fill="white", outline="white")
        logo.create_oval(26, 14, 32, 20, fill="white", outline="white")
        logo.create_arc(13, 14, 31, 32, start=200, extent=140, style="arc", outline="white", width=2)
        title_box = tk.Frame(header, bg="#0b0e16")
        title_box.pack(side="left", fill="x", expand=True)
        tk.Label(title_box, text=APP_NAME, bg="#0b0e16", fg="#f4f6fb", font=("Segoe UI Semibold", 17)).pack(anchor="w")
        tk.Label(title_box, text=f"LAUNCHER {LAUNCHER_VERSION}", bg="#0b0e16", fg="#777f92", font=("Segoe UI", 8)).pack(anchor="w")

        self.status = tk.Label(self.root, text="Preparing…", bg="#0b0e16", fg="#d9deea", font=("Segoe UI", 11), anchor="w")
        self.status.pack(fill="x", padx=30, pady=(26, 6))
        self.detail = tk.Label(self.root, text="Checking the installed client", bg="#0b0e16", fg="#7f8799", font=("Segoe UI", 9), anchor="w")
        self.detail.pack(fill="x", padx=30)

        style = ttk.Style(self.root)
        style.theme_use("clam")
        style.configure("Chat.Horizontal.TProgressbar", troughcolor="#171b28", background="#8474f4", bordercolor="#171b28", lightcolor="#8474f4", darkcolor="#8474f4", thickness=9)
        self.progress = ttk.Progressbar(self.root, style="Chat.Horizontal.TProgressbar", mode="indeterminate", maximum=100)
        self.progress.pack(fill="x", padx=30, pady=(20, 0))
        self.progress.start(12)

        self.button = tk.Button(
            self.root,
            text="Cancel",
            command=self.close,
            bg="#171b28",
            fg="#aeb6c7",
            activebackground="#23283a",
            activeforeground="white",
            relief="flat",
            borderwidth=0,
            padx=16,
            pady=7,
            cursor="hand2",
        )
        self.button.pack(side="right", padx=30, pady=20)

    def ui(self, callback: Callable[[], None]) -> None:
        try:
            self.root.after(0, callback)
        except tk.TclError:
            pass

    def set_status(self, status: str, detail: str) -> None:
        self.ui(lambda: (self.status.configure(text=status), self.detail.configure(text=detail)))

    def set_progress(self, received: int, total: int) -> None:
        percent = max(0, min(100, int(received * 100 / total)))

        def update() -> None:
            self.progress.stop()
            self.progress.configure(mode="determinate", value=percent)
            self.detail.configure(text=f"{received / 1024 / 1024:.1f} of {total / 1024 / 1024:.1f} MB — {percent}%")

        self.ui(update)

    def fail(self, message: str) -> None:
        def update() -> None:
            self.progress.stop()
            self.progress.configure(mode="determinate", value=0)
            self.status.configure(text="Could not start Boqsc Chat", fg="#ff899b")
            self.detail.configure(text=message, wraplength=410, justify="left")
            self.button.configure(text="Close", command=self.root.destroy)

        self.ui(update)

    def finish(self) -> None:
        self.ui(lambda: self.root.after(350, self.root.destroy))

    def run_launcher(self) -> None:
        directory = app_data_dir()
        directory.mkdir(parents=True, exist_ok=True)
        target = directory / CLIENT_FILE_NAME
        remove_stale_downloads(directory)
        try:
            if client_is_running() and target.is_file():
                self.set_status("Boqsc Chat is already running", "Bringing the existing client forward")
                launch_client(target)
                self.finish()
                return
            self.set_status("Checking for updates…", "Connecting securely to chat.boqsc.eu")
            try:
                release = fetch_release()
            except LauncherError:
                offline = verified_offline_release(directory)
                if offline is None:
                    raise
                self.set_status("Starting offline…", f"Using previously verified version {offline.version}")
                launch_client(target)
                self.finish()
                return
            if installed_matches(target, release):
                remember_state(directory, release)
                self.set_status("Boqsc Chat is ready", f"Version {release.version} is already installed")
            else:
                self.set_status("Updating Boqsc Chat…", f"Downloading verified version {release.version}")
                target = download_release(directory, release, self.set_progress, self.cancelled)
                self.set_status("Update complete", f"Verified version {release.version}")
            if self.cancelled.is_set():
                raise DownloadCancelled("Launch cancelled.")
            launch_client(target)
            self.finish()
        except DownloadCancelled:
            self.finish()
        except LauncherError as exc:
            offline = verified_offline_release(directory)
            if offline is not None and not self.cancelled.is_set():
                try:
                    self.set_status("Update unavailable", f"Starting verified version {offline.version} instead")
                    launch_client(target)
                    self.finish()
                    return
                except LauncherError:
                    pass
            self.fail(str(exc))
        except Exception as exc:
            self.fail(f"Unexpected launcher error: {exc}")

    def close(self) -> None:
        self.cancelled.set()
        self.root.destroy()

    def run(self) -> None:
        self.root.mainloop()


def main() -> int:
    if sys.platform != "win32":
        try:
            root = tk.Tk()
            root.withdraw()
            from tkinter import messagebox

            messagebox.showerror(APP_NAME, "The Boqsc Chat launcher requires Windows.")
            root.destroy()
        except tk.TclError:
            pass
        return 1
    LauncherWindow().run()
    return 0


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