from __future__ import annotations

import base64
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
from urllib import parse, request
import zipfile


ROOT = Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else Path(__file__).resolve().parent
VERSION_FILE = ROOT / "version.json"
SELF_UPDATE_LOG = ROOT / ".launcher" / "launcher_self_update.log"
APP_NAME = "水文计算软件"
DATA_DIR_NAME = "资料"
VALIDATION_DIR_NAME = "validation"
VALIDATION_REPORT_NAME = "水文计算软件全公式新人例题验收报告.md"
DATA_UPDATES_DIR_NAME = "data_updates"
DOWNLOAD_TIMEOUT_SECONDS = 180
DOWNLOAD_RETRIES = 3
OBSOLETE_STARTUP_FILES = (
    "DIAGNOSE_START.bat",
    "launcher_debug.log",
)


def _log_self_update(message: str) -> None:
    try:
        SELF_UPDATE_LOG.parent.mkdir(parents=True, exist_ok=True)
        timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
        with SELF_UPDATE_LOG.open("a", encoding="utf-8") as file:
            file.write(f"[{timestamp}] {message}\n")
    except OSError:
        pass


def _notify_user(title: str, message: str) -> None:
    _log_self_update(f"{title}: {message}")
    if os.name == "nt":
        try:
            import ctypes

            ctypes.windll.user32.MessageBoxW(None, message, title, 0x00000010)
            return
        except Exception:
            pass
    print(f"{title}\n{message}")


def cleanup_obsolete_startup_files() -> bool:
    """Remove obsolete diagnostic/repair entry points so users only start the managed launcher."""
    removed = False
    for name in OBSOLETE_STARTUP_FILES:
        path = ROOT / name
        try:
            if path.is_dir():
                shutil.rmtree(path)
                removed = True
            elif path.exists():
                path.unlink()
                removed = True
        except OSError as exc:
            _log_self_update(f"Obsolete startup file cleanup skipped for {name}: {exc}")
    return removed


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


def _load_version_config() -> dict[str, object]:
    if not VERSION_FILE.exists():
        return {}
    try:
        data = json.loads(VERSION_FILE.read_text(encoding="utf-8-sig"))
    except (OSError, json.JSONDecodeError):
        return {}
    return data if isinstance(data, dict) else {}


def _resolve_url(config: dict[str, object], raw_url: str) -> str:
    if raw_url.startswith(("http://", "https://")):
        return raw_url
    auth = config.get("auth") if isinstance(config.get("auth"), dict) else {}
    server_url = str(auth.get("server_url") or "").rstrip("/") + "/"
    return parse.urljoin(server_url, raw_url.lstrip("/"))


def _download_file(url: str, target: Path) -> None:
    target.parent.mkdir(parents=True, exist_ok=True)
    temp_target = target.with_suffix(target.suffix + ".part")
    last_error: Exception | None = None
    for attempt in range(1, DOWNLOAD_RETRIES + 1):
        try:
            if temp_target.exists():
                temp_target.unlink()
            opener = request.build_opener(request.ProxyHandler({}))
            with opener.open(url, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response, temp_target.open("wb") as file:
                while True:
                    chunk = response.read(1024 * 1024)
                    if not chunk:
                        break
                    file.write(chunk)
            temp_target.replace(target)
            return
        except Exception as exc:  # noqa: BLE001
            last_error = exc
            _log_self_update(f"Download attempt {attempt}/{DOWNLOAD_RETRIES} failed: {exc}")
            try:
                temp_target.unlink()
            except OSError:
                pass
            time.sleep(min(2 * attempt, 6))
    if last_error:
        raise last_error


def _extract_launcher_from_downloaded_package(config: dict[str, object], target: Path) -> bool:
    version = str(config.get("version") or "").strip()
    candidates = []
    if version:
        candidates.append(ROOT / ".launcher" / "downloads" / f"hydrology_client_{version}.zip")
    downloads_dir = ROOT / ".launcher" / "downloads"
    if downloads_dir.exists():
        candidates.extend(sorted(downloads_dir.glob("*.zip"), key=lambda path: path.stat().st_mtime, reverse=True))
    seen: set[Path] = set()
    for zip_path in candidates:
        if zip_path in seen or not zip_path.exists():
            continue
        seen.add(zip_path)
        try:
            with zipfile.ZipFile(zip_path) as archive:
                member_name = next(
                    (name for name in archive.namelist() if name.replace("\\", "/").endswith("hydrology_launcher.exe")),
                    "",
                )
                if not member_name:
                    continue
                target.parent.mkdir(parents=True, exist_ok=True)
                with archive.open(member_name) as source, target.open("wb") as file:
                    while True:
                        chunk = source.read(1024 * 1024)
                        if not chunk:
                            break
                        file.write(chunk)
                _log_self_update(f"Extracted launcher from {zip_path}")
                return True
        except (OSError, zipfile.BadZipFile):
            continue
    return False


def _data_resources_ready() -> bool:
    data_dir = ROOT / DATA_DIR_NAME
    required_files = [
        data_dir / "皮尔逊Ⅲ型曲线模比系数计算.xls",
        data_dir / "瞬时单位线法" / "提取自甘肃省暴雨洪水图集.pdf",
    ]
    return all(path.exists() for path in required_files)


def _downloaded_package_candidates(config: dict[str, object]) -> list[Path]:
    downloads_dir = ROOT / ".launcher" / "downloads"
    candidates: list[Path] = []
    version = str(config.get("version") or "").strip()
    if version:
        candidates.append(downloads_dir / f"hydrology_client_{version}.zip")
    if downloads_dir.exists():
        candidates.extend(sorted(downloads_dir.glob("*.zip"), key=lambda path: path.stat().st_mtime, reverse=True))
    seen: set[Path] = set()
    unique: list[Path] = []
    for path in candidates:
        if path not in seen:
            seen.add(path)
            unique.append(path)
    return unique


def _extract_package_prefix(zip_path: Path, directory_name: str) -> bool:
    prefix = directory_name.rstrip("/") + "/"
    target_root = ROOT.resolve()
    with zipfile.ZipFile(zip_path) as archive:
        members = [
            member
            for member in archive.infolist()
            if member.filename.replace("\\", "/").startswith(prefix) and not member.is_dir()
        ]
        if not members:
            return False
        for member in members:
            relative_name = member.filename.replace("\\", "/")
            destination = (ROOT / relative_name).resolve()
            if destination != target_root and target_root not in destination.parents:
                raise RuntimeError("更新包包含不安全路径")
        for member in members:
            relative_name = member.filename.replace("\\", "/")
            destination = ROOT / relative_name
            destination.parent.mkdir(parents=True, exist_ok=True)
            with archive.open(member) as source, destination.open("wb") as target:
                while True:
                    chunk = source.read(1024 * 1024)
                    if not chunk:
                        break
                    target.write(chunk)
    _log_self_update(f"Restored {directory_name} from {zip_path}")
    return True


def maybe_restore_data_resources() -> bool:
    # The default online install is intentionally core-only. Reference material
    # is an optional package and must never block the calculation workspace.
    need_data = not _data_resources_ready()
    need_validation = not (ROOT / VALIDATION_DIR_NAME / VALIDATION_REPORT_NAME).is_file()
    if not need_data and not need_validation:
        return False
    config = _load_version_config()
    restored = False
    for zip_path in _downloaded_package_candidates(config):
        if not zip_path.exists():
            continue
        try:
            if need_data and _extract_package_prefix(zip_path, DATA_DIR_NAME):
                restored = True
                need_data = False
            if need_validation and _extract_package_prefix(zip_path, VALIDATION_DIR_NAME):
                restored = True
                need_validation = False
            if not need_data and not need_validation:
                return restored
        except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
            _log_self_update(f"Data resource restore skipped: {exc}")
    return restored


def apply_bundled_data_updates() -> bool:
    """Apply small corrected data files without replacing the full user data folder."""
    updates_root = ROOT / "hydrology_app" / DATA_UPDATES_DIR_NAME
    if not updates_root.is_dir():
        return False
    root_resolved = ROOT.resolve()
    updated = False
    for source in updates_root.rglob("*"):
        if not source.is_file():
            continue
        target = (ROOT / source.relative_to(updates_root)).resolve()
        if target != root_resolved and root_resolved not in target.parents:
            raise RuntimeError("内置资料更新包含不安全路径")
        if target.is_file() and _sha256_file(target) == _sha256_file(source):
            continue
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, target)
        updated = True
        _log_self_update(f"Applied bundled data update: {target.relative_to(ROOT)}")
    return updated


def _ps_quote(value: str) -> str:
    return "'" + value.replace("'", "''") + "'"


def repair_windows_shortcut_icons() -> bool:
    if os.name != "nt":
        return False
    launcher = ROOT / "hydrology_launcher.exe"
    icon = ROOT / "hydrology_app" / "assets" / "app_icon.ico"
    if not launcher.exists() or not icon.exists():
        return False
    desktop = Path.home() / "Desktop" / f"{APP_NAME}.lnk"
    public_root = Path(os.environ.get("PUBLIC") or r"C:\Users\Public")
    public_desktop = public_root / "Desktop" / f"{APP_NAME}.lnk"
    appdata = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
    start_menu = appdata / "Microsoft" / "Windows" / "Start Menu" / "Programs" / APP_NAME / f"{APP_NAME}.lnk"
    script = f"""
$ErrorActionPreference = 'SilentlyContinue'
$target = {_ps_quote(str(launcher))}
$work = {_ps_quote(str(ROOT))}
$icon = {_ps_quote(str(icon) + ",0")}
$paths = @(
    {_ps_quote(str(desktop))},
    {_ps_quote(str(public_desktop))},
    {_ps_quote(str(start_menu))}
)
$shell = New-Object -ComObject WScript.Shell
foreach ($path in $paths) {{
    if (Test-Path -LiteralPath $path) {{
        $shortcut = $shell.CreateShortcut($path)
        $shortcut.TargetPath = $target
        $shortcut.WorkingDirectory = $work
        $shortcut.IconLocation = $icon
        $shortcut.Save()
    }}
}}
ie4uinit.exe -show
""".strip()
    try:
        encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii")
        creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
        subprocess.run(
            ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded],
            cwd=str(ROOT),
            check=False,
            capture_output=True,
            creationflags=creationflags,
        )
        return True
    except OSError as exc:
        _log_self_update(f"Shortcut icon repair skipped: {exc}")
        return False


def _schedule_launcher_replacement(current_exe: Path, new_exe: Path) -> None:
    script = f"""
$ErrorActionPreference = 'SilentlyContinue'
$exe = {_ps_quote(str(current_exe))}
$new = {_ps_quote(str(new_exe))}
$work = Split-Path -LiteralPath $exe
for ($i = 0; $i -lt 60; $i++) {{
    try {{
        Copy-Item -LiteralPath $new -Destination $exe -Force
        if ($env:HYDROLOGY_NO_RESTART_AFTER_LAUNCHER_UPDATE -ne '1') {{
            Start-Process -FilePath $exe -WorkingDirectory $work
        }}
        break
    }} catch {{
        Start-Sleep -Seconds 1
    }}
}}
""".strip()
    encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii")
    subprocess.Popen(
        ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded],
        cwd=str(ROOT),
        close_fds=True,
        creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
    )


def maybe_update_frozen_launcher() -> bool:
    if not getattr(sys, "frozen", False):
        return False
    current_exe = Path(sys.executable).resolve()
    if current_exe.name.lower() != "hydrology_launcher.exe":
        return False
    config = _load_version_config()
    update = config.get("update") if isinstance(config.get("update"), dict) else {}
    launcher_url = str(update.get("launcher_url") or "").strip()
    expected_sha256 = str(update.get("launcher_sha256") or "").strip().lower()
    if not launcher_url or not expected_sha256:
        return False
    try:
        if _sha256_file(current_exe).lower() == expected_sha256:
            return False
        url = _resolve_url(config, launcher_url)
        pending_exe = ROOT / ".launcher" / "pending" / "hydrology_launcher.exe"
        if not _extract_launcher_from_downloaded_package(config, pending_exe):
            _log_self_update(f"Downloading launcher from {url}")
            _download_file(url, pending_exe)
        actual_sha256 = _sha256_file(pending_exe).lower()
        if actual_sha256 != expected_sha256:
            _log_self_update(f"Launcher sha256 mismatch: {actual_sha256} != {expected_sha256}")
            return False
        print("正在更新启动器，更新完成后会自动重新打开软件。")
        _log_self_update("Scheduling launcher replacement")
        _schedule_launcher_replacement(current_exe, pending_exe)
        return True
    except Exception as exc:
        _log_self_update(f"Launcher self update skipped: {exc}")
        return False


def _log_ui_engine(message: str) -> None:
    log_file = ROOT / ".launcher" / "ui_engine.log"
    try:
        log_file.parent.mkdir(parents=True, exist_ok=True)
        timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
        with log_file.open("a", encoding="utf-8") as handle:
            handle.write(f"[{timestamp}] {message}\n")
    except OSError:
        pass


def _preferred_ui_engine() -> str:
    if os.environ.get("HYDROLOGY_ALLOW_LEGACY_TKINTER") == "1":
        _log_ui_engine("legacy tkinter allowed by HYDROLOGY_ALLOW_LEGACY_TKINTER=1")
        return "tkinter"
    if (ROOT / "hydrology_app" / "webui" / "index.html").exists():
        return "html"
    config = _load_version_config()
    configured = str(config.get("ui_engine") or "").strip().lower()
    if configured == "html":
        return "html"
    _log_ui_engine("HTML webui resources are missing; legacy tkinter is disabled in production")
    return "missing_html"


def _run_html_or_fallback() -> None:
    ui_engine = _preferred_ui_engine()
    if ui_engine == "html":
        try:
            from hydrology_app.web_launcher import (
                FALLBACK_EXIT_CODE,
                MISSING_WEBVIEW2_EXIT_CODE,
                RELOGIN_EXIT_CODE,
                main as web_main,
            )

            for relogin_attempt in range(3):
                exit_code = int(web_main())
                if exit_code == 0:
                    return
                if exit_code == MISSING_WEBVIEW2_EXIT_CODE:
                    _log_ui_engine("HTML UI requires WebView2; not falling back to the legacy tkinter workspace")
                    return
                if exit_code == RELOGIN_EXIT_CODE:
                    _log_ui_engine(
                        f"HTML UI requested relogin; restarting HTML UI attempt {relogin_attempt + 1} "
                        "without opening the legacy tkinter login dialog"
                    )
                    continue
                if exit_code == FALLBACK_EXIT_CODE:
                    _notify_user(
                        "水文计算软件启动失败",
                        "新版界面启动失败。请确认已安装 Microsoft Edge WebView2 Runtime，"
                        "并重新打开软件触发自动更新；软件不会再退回旧界面。",
                    )
                    return
                _log_ui_engine(f"HTML UI returned {exit_code}; not falling back to the legacy tkinter workspace")
                print("HTML UI startup failed. Please update the software or install Microsoft Edge WebView2 Runtime.")
                return
            else:
                _log_ui_engine("HTML UI requested relogin repeatedly; exiting without opening legacy tkinter workspace")
                return
        except Exception as exc:  # noqa: BLE001
            _log_ui_engine(f"HTML UI exception: {exc}; not falling back to the legacy tkinter workspace")
            print("HTML UI startup failed. Please update the software or install Microsoft Edge WebView2 Runtime.")
            return
    if ui_engine == "tkinter":
        from hydrology_app.gui import main

        main()
        return
    _notify_user(
        "水文计算软件启动失败",
        "当前安装包缺少新版 HTML 界面文件，已禁止自动退回旧界面。"
        "请重新打开软件触发自动更新，或从官网下载安装最新版。",
    )


if __name__ == "__main__":
    maybe_restore_data_resources()
    apply_bundled_data_updates()
    cleanup_obsolete_startup_files()
    repair_windows_shortcut_icons()
    if maybe_update_frozen_launcher():
        raise SystemExit(0)
    _run_html_or_fallback()
