#!/usr/bin/env python3
from __future__ import annotations

import json
import os
import queue
import re
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from getpass import getpass
from pathlib import Path
from typing import Callable

try:
    import tkinter as tk
    from tkinter import messagebox, ttk
except ImportError:
    tk = None
    messagebox = None
    ttk = None


REPO_ROOT = Path(__file__).resolve().parents[1]
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
DEFAULT_JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
DEFAULT_API_BASE_URL = "https://iwanttoheal.phenomrom.com"
DEFAULT_GITEA_URL = "https://git.whoagland.com"
DEFAULT_GITEA_OWNER = "phenom"
DEFAULT_GITEA_REPO = "i-want-to-heal"
DEFAULT_BRANCH = "main"
DEFAULT_TRUENAS_PATH = "/mnt/usbssds/apps/iwanttoheal/app"
DEFAULT_DB_PATH = "/mnt/usbssds/apps/iwanttoheal/data/game.db"


class ReleaseError(RuntimeError):
    pass


@dataclass(frozen=True)
class ReleaseConfig:
    version: str
    token: str
    api_base_url: str
    java_home: str
    gitea_url: str
    gitea_owner: str
    gitea_repo: str
    branch: str
    truenas_path: str
    truenas_db_path: str
    restart_command: str
    run_build: bool
    run_commit_push: bool
    run_release: bool
    run_truenas_pull: bool
    run_truenas_backup: bool
    run_restart: bool


def read_current_version() -> str:
    text = GRADLE_FILE.read_text()
    match = re.search(r'versionName\s+"([^"]+)"', text)
    return match.group(1) if match else ""


def run_command(
    args: list[str],
    *,
    cwd: Path,
    env: dict[str, str] | None,
    log: Callable[[str], None],
) -> None:
    log(f"$ {' '.join(args)}")
    proc = subprocess.Popen(
        args,
        cwd=cwd,
        env=env,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        bufsize=1,
    )
    assert proc.stdout is not None
    for line in proc.stdout:
        log(line.rstrip("\n"))
    code = proc.wait()
    if code != 0:
        raise ReleaseError(f"Command failed ({code}): {' '.join(args)}")


def update_gradle_version(version: str, log: Callable[[str], None]) -> int:
    text = GRADLE_FILE.read_text()
    code_match = re.search(r"versionCode\s+(\d+)", text)
    if not code_match:
        raise ReleaseError(f"Could not find versionCode in {GRADLE_FILE}")

    next_code = int(code_match.group(1)) + 1
    text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
    text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
    GRADLE_FILE.write_text(text)
    log(f"Android version set: versionName {version}, versionCode {next_code}")
    return next_code


def build_apk(config: ReleaseConfig, log: Callable[[str], None]) -> Path:
    update_gradle_version(config.version, log)
    env = os.environ.copy()
    env["JAVA_HOME"] = config.java_home
    env["PATH"] = f"{config.java_home}/bin:{env.get('PATH', '')}"
    env["VITE_API_BASE_URL"] = config.api_base_url

    run_command(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env, log=log)
    run_command(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env, log=log)

    apk_source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
    if not apk_source.exists():
        raise ReleaseError(f"APK not found: {apk_source}")
    apk_target = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
    apk_target.write_bytes(apk_source.read_bytes())
    log(f"APK copied: {apk_target.name} ({apk_target.stat().st_size:,} bytes)")
    return apk_target


def commit_and_push(config: ReleaseConfig, log: Callable[[str], None]) -> None:
    message = f"Android build v{config.version}"
    run_command(["git", "add", "."], cwd=REPO_ROOT, env=None, log=log)
    run_command(["git", "commit", "-m", message], cwd=REPO_ROOT, env=None, log=log)
    run_command(["git", "push", "origin", config.branch], cwd=REPO_ROOT, env=None, log=log)


def gitea_request(
    config: ReleaseConfig,
    method: str,
    path: str,
    *,
    body: bytes | None = None,
    content_type: str | None = None,
) -> tuple[int, bytes]:
    url = config.gitea_url.rstrip("/") + path
    headers = {"Authorization": f"token {config.token}"}
    if content_type:
        headers["Content-Type"] = content_type
    req = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req) as resp:
            return resp.status, resp.read()
    except urllib.error.HTTPError as exc:
        data = exc.read()
        return exc.code, data


def parse_json_response(status: int, data: bytes, action: str) -> dict:
    try:
        parsed = json.loads(data.decode("utf-8") or "{}")
    except json.JSONDecodeError as exc:
        raise ReleaseError(f"{action} failed ({status}): {data.decode('utf-8', 'replace')}") from exc
    if status >= 400:
        raise ReleaseError(f"{action} failed ({status}): {parsed}")
    if not isinstance(parsed, dict):
        raise ReleaseError(f"{action} returned unexpected JSON: {parsed}")
    return parsed


def find_or_create_release(config: ReleaseConfig, log: Callable[[str], None]) -> int:
    repo_path = f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
    tag = f"v{config.version}"
    tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
    status, data = gitea_request(config, "GET", tag_path)
    if status == 200:
        release = parse_json_response(status, data, "Find release")
        release_id = release.get("id")
        if not release_id:
            raise ReleaseError(f"Existing release missing id: {release}")
        log(f"Gitea release exists: {tag} (id {release_id})")
        return int(release_id)
    if status != 404:
        parse_json_response(status, data, "Find release")

    payload = {
        "tag_name": tag,
        "target_commitish": config.branch,
        "name": tag,
        "body": f"I Want to Heal Android build v{config.version}",
        "draft": False,
        "prerelease": False,
    }
    status, data = gitea_request(
        config,
        "POST",
        repo_path + "/releases",
        body=json.dumps(payload).encode("utf-8"),
        content_type="application/json",
    )
    release = parse_json_response(status, data, "Create release")
    release_id = release.get("id")
    if not release_id:
        raise ReleaseError(f"Created release missing id: {release}")
    log(f"Gitea release created: {tag} (id {release_id})")
    return int(release_id)


def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> None:
    apk_path = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
    if not apk_path.exists():
        raise ReleaseError(f"APK not found for release upload: {apk_path}")
    release_id = find_or_create_release(config, log)

    boundary = f"----iwanttoheal{int(time.time() * 1000)}"
    header = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
        "Content-Type: application/vnd.android.package-archive\r\n\r\n"
    ).encode("utf-8")
    footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
    body = header + apk_path.read_bytes() + footer
    asset_name = urllib.parse.quote(apk_path.name)
    path = (
        f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
        f"/releases/{release_id}/assets?name={asset_name}"
    )
    status, data = gitea_request(
        config,
        "POST",
        path,
        body=body,
        content_type=f"multipart/form-data; boundary={boundary}",
    )
    if status == 409:
        raise ReleaseError(f"Release asset already exists: {apk_path.name}")
    parse_json_response(status, data, "Upload release asset")
    log(f"Gitea asset uploaded: {apk_path.name}")


def update_truenas(config: ReleaseConfig, log: Callable[[str], None]) -> None:
    app_path = Path(config.truenas_path)
    if not app_path.exists():
        raise ReleaseError(f"TrueNAS path not found: {app_path}")
    run_command(["git", "pull"], cwd=app_path, env=None, log=log)

    if config.run_truenas_backup:
        db_path = Path(config.truenas_db_path)
        if not db_path.exists():
            raise ReleaseError(f"TrueNAS database not found: {db_path}")
        stamp = time.strftime("%Y%m%d-%H%M%S")
        backup_path = db_path.with_name(f"game-before-update-{stamp}.db")
        backup_path.write_bytes(db_path.read_bytes())
        log(f"TrueNAS database backup copied: {backup_path}")

    if config.run_restart:
        if not config.restart_command.strip():
            raise ReleaseError("Restart command is empty")
        run_command(["sh", "-lc", config.restart_command], cwd=app_path, env=None, log=log)


def validate_config(config: ReleaseConfig) -> None:
    if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", config.version):
        raise ReleaseError("Version must look like 1.1.2")
    if config.run_release and not config.token:
        raise ReleaseError("Gitea token required for release step")
    if config.run_restart and not config.run_truenas_pull:
        raise ReleaseError("Restart needs TrueNAS update step enabled")


def prompt_text(label: str, default: str = "", *, secret: bool = False) -> str:
    prompt = f"{label}"
    if default:
        prompt += f" [{default}]"
    prompt += ": "
    try:
        value = getpass(prompt) if secret else input(prompt)
    except EOFError:
        value = ""
    return value.strip() or default


def prompt_bool(label: str, default: bool) -> bool:
    suffix = "Y/n" if default else "y/N"
    try:
        value = input(f"{label} [{suffix}]: ").strip().lower()
    except EOFError:
        value = ""
    if not value:
        return default
    return value in {"y", "yes", "true", "1"}


def run_release_steps(config: ReleaseConfig, log: Callable[[str], None]) -> None:
    if config.run_build:
        build_apk(config, log)
    if config.run_commit_push:
        commit_and_push(config, log)
    if config.run_release:
        upload_release_asset(config, log)
    if config.run_truenas_pull:
        update_truenas(config, log)


def cli_main() -> int:
    print("Tkinter not available. CLI mode.")
    current_version = read_current_version()
    config = ReleaseConfig(
        version=prompt_text("Version", current_version),
        token=prompt_text("Gitea token", secret=True),
        api_base_url=prompt_text("API base URL", DEFAULT_API_BASE_URL),
        java_home=prompt_text("JAVA_HOME", DEFAULT_JAVA_HOME),
        gitea_url=prompt_text("Gitea URL", DEFAULT_GITEA_URL),
        gitea_owner=prompt_text("Gitea owner", DEFAULT_GITEA_OWNER),
        gitea_repo=prompt_text("Gitea repo", DEFAULT_GITEA_REPO),
        branch=prompt_text("Git branch", DEFAULT_BRANCH),
        truenas_path=prompt_text("TrueNAS app path", DEFAULT_TRUENAS_PATH),
        truenas_db_path=prompt_text("TrueNAS DB path", DEFAULT_DB_PATH),
        restart_command=prompt_text("Restart command"),
        run_build=prompt_bool("Build APK", True),
        run_commit_push=prompt_bool("Commit + push", True),
        run_release=prompt_bool("Create Gitea release", True),
        run_truenas_pull=prompt_bool("TrueNAS pull", True),
        run_truenas_backup=prompt_bool("Backup DB", True),
        run_restart=prompt_bool("Restart", False),
    )
    validate_config(config)
    run_release_steps(config, print)
    print("Release steps complete")
    return 0


if tk is not None and ttk is not None and messagebox is not None:

    class ReleaseApp(ttk.Frame):
        def __init__(self, root: tk.Tk) -> None:
            super().__init__(root, padding=12)
            self.root = root
            self.log_queue: queue.Queue[tuple[str, str]] = queue.Queue()
            self.worker: threading.Thread | None = None
            self.vars: dict[str, tk.Variable] = {}
            self.grid(sticky="nsew")
            root.title("I Want to Heal Release Tool")
            root.geometry("920x720")
            root.columnconfigure(0, weight=1)
            root.rowconfigure(0, weight=1)
            self.columnconfigure(1, weight=1)
            self.rowconfigure(13, weight=1)
            self._build_form()
            self.after(100, self._drain_log_queue)

        def _var(self, name: str, value: object = "") -> tk.Variable:
            if isinstance(value, bool):
                var: tk.Variable = tk.BooleanVar(value=value)
            else:
                var = tk.StringVar(value=str(value))
            self.vars[name] = var
            return var

        def _build_form(self) -> None:
            current_version = read_current_version()
            fields = [
                ("Version", "version", current_version),
                ("Gitea token", "token", ""),
                ("API base URL", "api_base_url", DEFAULT_API_BASE_URL),
                ("JAVA_HOME", "java_home", DEFAULT_JAVA_HOME),
                ("Gitea URL", "gitea_url", DEFAULT_GITEA_URL),
                ("Gitea owner", "gitea_owner", DEFAULT_GITEA_OWNER),
                ("Gitea repo", "gitea_repo", DEFAULT_GITEA_REPO),
                ("Git branch", "branch", DEFAULT_BRANCH),
                ("TrueNAS app path", "truenas_path", DEFAULT_TRUENAS_PATH),
                ("TrueNAS DB path", "truenas_db_path", DEFAULT_DB_PATH),
                ("Restart command", "restart_command", ""),
            ]
            for row, (label, name, value) in enumerate(fields):
                ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=3)
                entry = ttk.Entry(self, textvariable=self._var(name, value), show="*" if name == "token" else "")
                entry.grid(row=row, column=1, sticky="ew", pady=3)

            checks = ttk.Frame(self)
            checks.grid(row=11, column=0, columnspan=2, sticky="ew", pady=(10, 4))
            check_data = [
                ("Build APK", "run_build", True),
                ("Commit + push", "run_commit_push", True),
                ("Create Gitea release", "run_release", True),
                ("TrueNAS pull", "run_truenas_pull", True),
                ("Backup DB", "run_truenas_backup", True),
                ("Restart", "run_restart", False),
            ]
            for index, (text, name, value) in enumerate(check_data):
                ttk.Checkbutton(checks, text=text, variable=self._var(name, value)).grid(
                    row=0, column=index, padx=(0, 14), sticky="w"
                )

            buttons = ttk.Frame(self)
            buttons.grid(row=12, column=0, columnspan=2, sticky="ew", pady=(6, 8))
            self.run_button = ttk.Button(buttons, text="Run Selected Steps", command=self.run_release)
            self.run_button.pack(side="left")
            ttk.Button(buttons, text="Start Admin Server", command=self.start_admin).pack(side="left", padx=8)

            self.log_text = tk.Text(self, height=18, wrap="word")
            self.log_text.grid(row=13, column=0, columnspan=2, sticky="nsew")
            scroll = ttk.Scrollbar(self, command=self.log_text.yview)
            scroll.grid(row=13, column=2, sticky="ns")
            self.log_text.configure(yscrollcommand=scroll.set)

        def config_from_form(self) -> ReleaseConfig:
            get = lambda name: str(self.vars[name].get()).strip()
            get_bool = lambda name: bool(self.vars[name].get())
            return ReleaseConfig(
                version=get("version"),
                token=get("token"),
                api_base_url=get("api_base_url"),
                java_home=get("java_home"),
                gitea_url=get("gitea_url"),
                gitea_owner=get("gitea_owner"),
                gitea_repo=get("gitea_repo"),
                branch=get("branch"),
                truenas_path=get("truenas_path"),
                truenas_db_path=get("truenas_db_path"),
                restart_command=get("restart_command"),
                run_build=get_bool("run_build"),
                run_commit_push=get_bool("run_commit_push"),
                run_release=get_bool("run_release"),
                run_truenas_pull=get_bool("run_truenas_pull"),
                run_truenas_backup=get_bool("run_truenas_backup"),
                run_restart=get_bool("run_restart"),
            )

        def log(self, message: str) -> None:
            self.log_queue.put(("log", message))

        def _drain_log_queue(self) -> None:
            try:
                while True:
                    kind, message = self.log_queue.get_nowait()
                    if kind == "done":
                        self.run_button.configure(state="normal")
                        if message:
                            messagebox.showinfo("Release tool", message)
                    elif kind == "error":
                        self.run_button.configure(state="normal")
                        messagebox.showerror("Release failed", message)
                    else:
                        self.log_text.insert("end", message + "\n")
                        self.log_text.see("end")
            except queue.Empty:
                pass
            self.after(100, self._drain_log_queue)

        def run_release(self) -> None:
            if self.worker and self.worker.is_alive():
                return
            try:
                config = self.config_from_form()
                validate_config(config)
            except ReleaseError as exc:
                messagebox.showerror("Invalid settings", str(exc))
                return
            self.run_button.configure(state="disabled")
            self.log_text.delete("1.0", "end")
            self.worker = threading.Thread(target=self._run_release_worker, args=(config,), daemon=True)
            self.worker.start()

        def _run_release_worker(self, config: ReleaseConfig) -> None:
            try:
                run_release_steps(config, self.log)
                self.log_queue.put(("done", "Release steps complete"))
            except Exception as exc:
                self.log_queue.put(("error", str(exc)))

        def start_admin(self) -> None:
            if self.worker and self.worker.is_alive():
                messagebox.showerror("Busy", "Release steps are still running")
                return
            self.log_text.delete("1.0", "end")
            self.run_button.configure(state="disabled")
            self.worker = threading.Thread(target=self._start_admin_worker, daemon=True)
            self.worker.start()

        def _start_admin_worker(self) -> None:
            try:
                self.log("Admin URL: http://127.0.0.1:4174")
                run_command(["npm", "run", "admin:start"], cwd=REPO_ROOT, env=None, log=self.log)
                self.log_queue.put(("done", "Admin server stopped"))
            except Exception as exc:
                self.log_queue.put(("error", str(exc)))


def main() -> int:
    if not GRADLE_FILE.exists():
        print(f"Missing Gradle file: {GRADLE_FILE}", file=sys.stderr)
        return 1
    if tk is None:
        return cli_main()
    root = tk.Tk()
    ReleaseApp(root)
    root.mainloop()
    return 0


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