from __future__ import annotations

import hashlib
import json
import os
import runpy
import shutil
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from urllib import error, 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"
RUN_APP = ROOT / "run_app.py"
STATE_DIR = ROOT / ".launcher"
DOWNLOAD_DIR = STATE_DIR / "downloads"
STAGING_DIR = STATE_DIR / "staging"
BACKUP_DIR = STATE_DIR / "backups"
LOG_FILE = STATE_DIR / "launcher.log"
DIAGNOSTICS_FILE = STATE_DIR / "diagnostics.json"
MINIMUM_HTML_VERSION = "1.0.68"
DOWNLOAD_TIMEOUT_SECONDS = 180
DOWNLOAD_RETRIES = 3
DOWNLOAD_PARALLEL_WORKERS = 12
DOWNLOAD_RANGE_PART_SIZE = 512 * 1024
DOWNLOAD_PARALLEL_MIN_SIZE = 4 * 1024 * 1024
DOWNLOAD_RANGE_RETRIES = 3
DOWNLOAD_RANGE_TIMEOUT_SECONDS = 45
_SINGLE_INSTANCE_MUTEX = None
MANAGED_ITEMS = [
    "hydrology_app",
    "资料",
    "validation",
    "run_app.py",
    "version.json",
    "README_PYTHON.md",
    "launcher.py",
    "hydrology_launcher.exe",
    "START_WINDOWS.bat",
    "TEST_INSTRUCTIONS.txt",
]
OBSOLETE_ITEMS = [
    "DIAGNOSE_START.bat",
    "launcher_debug.log",
]


class LauncherError(Exception):
    pass


class UpdateProgress:
    """Small native progress window shown while the launcher updates the app."""

    def __init__(self) -> None:
        self.root = None
        self.status_var = None
        self.progress = None

    def show(self, message: str) -> None:
        if os.name != "nt" or self.root is not None:
            return
        try:
            import tkinter as tk
            from tkinter import ttk

            root = tk.Tk()
            from hydrology_app.font_policy import FONT_FAMILY, configure_tk_fonts, mixed_tk_label
            configure_tk_fonts(root)
            root.title("水文计算软件正在更新")
            root.geometry("430x160")
            root.resizable(False, False)
            root.configure(bg="#f5f7fb")
            root.protocol("WM_DELETE_WINDOW", lambda: None)
            try:
                root.attributes("-topmost", True)
            except tk.TclError:
                pass

            frame = tk.Frame(root, bg="#f5f7fb", padx=26, pady=22)
            frame.pack(fill=tk.BOTH, expand=True)
            tk.Label(
                frame,
                text="正在更新水文计算软件",
                bg="#f5f7fb",
                fg="#102a43",
                font=(FONT_FAMILY, 15, "bold"),
            ).pack(anchor="w")
            status_var = tk.StringVar(value=message)
            mixed_tk_label(frame, status_var, background="#f5f7fb", foreground="#486581"
                           ).pack(fill=tk.X, pady=(8, 8))
            progress = ttk.Progressbar(frame, mode="indeterminate")
            progress.pack(fill=tk.X)
            progress.start(10)
            self.root = root
            self.status_var = status_var
            self.progress = progress
            self.pump()
        except Exception as exc:  # noqa: BLE001
            log(f"Update progress window skipped: {exc}")
            self.root = None

    def set(self, message: str) -> None:
        if self.root is None or self.status_var is None:
            return
        try:
            self.status_var.set(message)
            self.pump()
        except Exception as exc:  # noqa: BLE001
            log(f"Update progress update skipped: {exc}")

    def pump(self) -> None:
        if self.root is None:
            return
        try:
            self.root.update_idletasks()
            self.root.update()
        except Exception as exc:  # noqa: BLE001
            log(f"Update progress pump skipped: {exc}")
            self.root = None

    def close(self) -> None:
        if self.root is None:
            return
        try:
            if self.progress is not None:
                self.progress.stop()
            self.root.destroy()
        except Exception as exc:  # noqa: BLE001
            log(f"Update progress close skipped: {exc}")
        finally:
            self.root = None
            self.status_var = None
            self.progress = None


_UPDATE_PROGRESS = UpdateProgress()


def show_update_progress(message: str) -> None:
    _UPDATE_PROGRESS.show(message)


def set_update_progress(message: str) -> None:
    _UPDATE_PROGRESS.set(message)


def close_update_progress() -> None:
    _UPDATE_PROGRESS.close()


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


def notify_user(title: str, message: str) -> None:
    log(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 acquire_single_instance_lock() -> bool:
    """Allow only one launcher process to update or run the app at a time."""
    # @hook launcher-single-instance 20260814
    # Prevents repeated double-clicks from starting concurrent update/download/install chains.
    global _SINGLE_INSTANCE_MUTEX
    if _SINGLE_INSTANCE_MUTEX is not None:
        return True
    if os.name != "nt":
        return True
    try:
        import ctypes

        kernel32 = ctypes.windll.kernel32
        mutex = kernel32.CreateMutexW(None, False, "Local\\HTZD_HydrologyCalc_Launcher")
        if not mutex:
            return True
        if kernel32.GetLastError() == 183:  # ERROR_ALREADY_EXISTS
            notify_user(
                "水文计算软件正在启动",
                "检测到软件已经在启动或更新中，请等待当前窗口完成。\n\n"
                "如果长时间没有反应，请先在任务管理器结束 hydrology_launcher.exe 后再重新打开。",
            )
            return False
        _SINGLE_INSTANCE_MUTEX = mutex
        return True
    except Exception as exc:  # noqa: BLE001
        log(f"Single instance lock skipped: {exc}")
        return True


def expected_install_root() -> Path | None:
    if os.name != "nt":
        return None
    local_appdata = os.environ.get("LOCALAPPDATA")
    if not local_appdata:
        return None
    return Path(local_appdata) / "HydrologyCalc"


def is_expected_install_root() -> bool:
    expected = expected_install_root()
    if expected is None:
        return True
    try:
        return ROOT.resolve() == expected.resolve()
    except OSError:
        return False


def write_diagnostics(**data: object) -> None:
    try:
        local: dict[str, object] = {}
        if VERSION_FILE.exists():
            local = json.loads(VERSION_FILE.read_text(encoding="utf-8-sig"))
            if not isinstance(local, dict):
                local = {}
        payload = {
            "timestamp": datetime.now().isoformat(timespec="seconds"),
            "root": str(ROOT),
            "executable": str(Path(sys.executable).resolve()),
            "frozen": bool(getattr(sys, "frozen", False)),
            "expected_install_root": str(expected_install_root() or ""),
            "is_expected_install_root": is_expected_install_root(),
            "local_version": str(local.get("version") or ""),
            "ui_engine": str(local.get("ui_engine") or ""),
        }
        payload.update(data)
        DIAGNOSTICS_FILE.parent.mkdir(parents=True, exist_ok=True)
        DIAGNOSTICS_FILE.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    except Exception as exc:
        log(f"Diagnostics write skipped: {exc}")


def load_local_version() -> dict[str, object]:
    if not VERSION_FILE.exists():
        raise LauncherError("version.json not found")
    data = json.loads(VERSION_FILE.read_text(encoding="utf-8-sig"))
    if not isinstance(data, dict):
        raise LauncherError("version.json is invalid")
    return data


def parse_version(value: str) -> tuple[int, ...]:
    parts = []
    for part in str(value).split("."):
        digits = "".join(ch for ch in part if ch.isdigit())
        parts.append(int(digits or "0"))
    return tuple(parts or [0])


def compare_versions(left: str, right: str) -> int:
    left_parts = list(parse_version(left))
    right_parts = list(parse_version(right))
    size = max(len(left_parts), len(right_parts))
    left_parts.extend([0] * (size - len(left_parts)))
    right_parts.extend([0] * (size - len(right_parts)))
    if left_parts < right_parts:
        return -1
    if left_parts > right_parts:
        return 1
    return 0


def no_proxy_opener() -> request.OpenerDirector:
    return request.build_opener(request.ProxyHandler({}))


def fetch_latest_version(local: dict[str, object]) -> dict[str, object]:
    auth = local.get("auth") if isinstance(local.get("auth"), dict) else {}
    server_url = str(auth.get("server_url") or "").rstrip("/")
    if not server_url:
        raise LauncherError("auth.server_url is empty")
    current_version = str(local.get("version") or "0.0.0")
    channel = str(local.get("channel") or "stable")
    query = parse.urlencode({"current_version": current_version, "channel": channel})
    url = f"{server_url}/api/app/version?{query}"
    with no_proxy_opener().open(url, timeout=12) as response:
        data = json.loads(response.read().decode("utf-8"))
    if not isinstance(data, dict):
        raise LauncherError("version response is invalid")
    return data


def should_update(local: dict[str, object], remote: dict[str, object]) -> bool:
    update = local.get("update") if isinstance(local.get("update"), dict) else {}
    if update.get("enabled", True) is False:
        return False
    local_version = str(local.get("version") or "0.0.0")
    remote_version = str(remote.get("version") or "0.0.0")
    min_supported = str(
        remote.get("minimum_supported_version")
        or remote.get("min_supported_version")
        or remote.get("min_supported")
        or ""
    )
    has_download = bool(str(remote.get("download_url") or "").strip())
    if not has_download:
        return False
    # @hook launcher-force-update-same-version 20260814
    # Force updates only make stale clients update; latest clients must not redownload the same package at startup.
    if min_supported and compare_versions(local_version, min_supported) < 0:
        return True
    return compare_versions(local_version, remote_version) < 0


def resolve_download_url(local: dict[str, object], remote: dict[str, object]) -> str:
    raw_url = str(remote.get("download_url") or "").strip()
    if not raw_url:
        raise LauncherError("download_url is empty")
    if raw_url.startswith(("http://", "https://")):
        return raw_url
    auth = local.get("auth") if isinstance(local.get("auth"), dict) else {}
    server_url = str(auth.get("server_url") or "").rstrip("/") + "/"
    return parse.urljoin(server_url, raw_url.lstrip("/"))


def resolve_download_urls(local: dict[str, object], remote: dict[str, object]) -> list[str]:
    urls = [resolve_download_url(local, remote)]
    raw_mirrors = remote.get("download_mirrors")
    mirrors = raw_mirrors if isinstance(raw_mirrors, list) else []
    for mirror in mirrors:
        mirror_url = str(mirror or "").strip()
        if mirror_url and mirror_url not in urls:
            urls.append(mirror_url)
    return urls


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 _request_for(url: str, *, byte_range: tuple[int, int] | None = None, method: str = "GET") -> request.Request:
    req = request.Request(url, method=method)
    req.add_header("User-Agent", "HydrologyCalcLauncher/1.0")
    if byte_range is not None:
        req.add_header("Range", f"bytes={byte_range[0]}-{byte_range[1]}")
    return req


def _parse_content_range(value: str) -> int:
    if "/" not in value:
        return 0
    total = value.rsplit("/", 1)[-1].strip()
    return int(total) if total.isdigit() else 0


def _probe_parallel_download(url: str) -> tuple[int, bool]:
    try:
        with no_proxy_opener().open(_request_for(url, method="HEAD"), timeout=15) as response:
            length = str(response.headers.get("Content-Length") or "")
            accepts_ranges = str(response.headers.get("Accept-Ranges") or "").lower()
            total = int(length) if length.isdigit() else 0
            if total and "bytes" in accepts_ranges:
                return total, True
    except Exception as exc:  # noqa: BLE001
        log(f"Download HEAD probe skipped: {exc}")
    try:
        with no_proxy_opener().open(_request_for(url, byte_range=(0, 0)), timeout=15) as response:
            status = int(getattr(response, "status", 0) or 0)
            total = _parse_content_range(str(response.headers.get("Content-Range") or ""))
            return total, status == 206 and total > 0
    except Exception as exc:  # noqa: BLE001
        log(f"Download range probe skipped: {exc}")
    return 0, False


def _download_range_part(url: str, part_path: Path, start: int, end: int) -> int:
    expected = end - start + 1
    curl = shutil.which("curl.exe") or shutil.which("curl")
    if curl:
        last_error = ""
        for _attempt in range(1, DOWNLOAD_RANGE_RETRIES + 1):
            remove_path(part_path)
            result = subprocess.run(
                [
                    curl,
                    "--fail",
                    "--location",
                    "--silent",
                    "--show-error",
                    "--connect-timeout",
                    "20",
                    "--max-time",
                    str(DOWNLOAD_RANGE_TIMEOUT_SECONDS),
                    "--range",
                    f"{start}-{end}",
                    "--output",
                    str(part_path),
                    url,
                ],
                check=False,
                capture_output=True,
                text=True,
            )
            downloaded = part_path.stat().st_size if part_path.exists() else 0
            if result.returncode == 0 and downloaded == expected:
                return downloaded
            last_error = (result.stderr or result.stdout or str(result.returncode)).strip()
        raise LauncherError(f"curl range download failed: {last_error}")

    downloaded = 0
    with no_proxy_opener().open(_request_for(url, byte_range=(start, end)), timeout=DOWNLOAD_TIMEOUT_SECONDS) as response:
        status = int(getattr(response, "status", 0) or 0)
        if status != 206:
            raise LauncherError(f"range request returned HTTP {status}")
        with part_path.open("wb") as file:
            while True:
                chunk = response.read(256 * 1024)
                if not chunk:
                    break
                file.write(chunk)
                downloaded += len(chunk)
    if downloaded != expected:
        raise LauncherError(f"range part size mismatch: expected {expected}, got {downloaded}")
    return downloaded


def _cleanup_download_parts(temp_target: Path) -> None:
    for path in temp_target.parent.glob(f"{temp_target.name}.part-*"):
        remove_path(path)


def _parallel_download_update(url: str, temp_target: Path) -> bool:
    total, supports_ranges = _probe_parallel_download(url)
    if not supports_ranges or total < DOWNLOAD_PARALLEL_MIN_SIZE:
        return False

    parts: list[tuple[int, int, Path]] = []
    for index, start in enumerate(range(0, total, DOWNLOAD_RANGE_PART_SIZE)):
        end = min(start + DOWNLOAD_RANGE_PART_SIZE - 1, total - 1)
        parts.append((start, end, temp_target.with_name(f"{temp_target.name}.part-{index:05d}")))

    _cleanup_download_parts(temp_target)
    downloaded = 0
    try:
        workers = min(DOWNLOAD_PARALLEL_WORKERS, len(parts))
        set_update_progress(f"正在并发下载安装包：0.0/{total / 1048576:.1f} MB。")
        with ThreadPoolExecutor(max_workers=workers) as executor:
            futures = {
                executor.submit(_download_range_part, url, part_path, start, end): part_path
                for start, end, part_path in parts
            }
            for future in as_completed(futures):
                downloaded += future.result()
                set_update_progress(
                    f"正在并发下载安装包：{downloaded / 1048576:.1f}/{total / 1048576:.1f} MB。"
                )
        with temp_target.open("wb") as output:
            for _start, _end, part_path in parts:
                with part_path.open("rb") as part_file:
                    shutil.copyfileobj(part_file, output, length=1024 * 1024)
        if temp_target.stat().st_size != total:
            raise LauncherError("parallel download size mismatch")
        return True
    finally:
        _cleanup_download_parts(temp_target)


def _single_download_update(current_url: str, temp_target: Path) -> None:
    with no_proxy_opener().open(_request_for(current_url), timeout=DOWNLOAD_TIMEOUT_SECONDS) as response, temp_target.open("wb") as file:
        total_header = response.headers.get("Content-Length") or ""
        total = int(total_header) if total_header.isdigit() else 0
        downloaded = 0
        last_ui_update = 0.0
        while True:
            chunk = response.read(1024 * 1024)
            if not chunk:
                break
            file.write(chunk)
            downloaded += len(chunk)
            now = time.monotonic()
            if now - last_ui_update >= 0.5:
                if total:
                    set_update_progress(
                        f"正在下载安装包：{downloaded / 1048576:.1f}/{total / 1048576:.1f} MB，"
                        "慢网环境会多等一会。"
                    )
                else:
                    set_update_progress(f"正在下载安装包：{downloaded / 1048576:.1f} MB，慢网环境会多等一会。")
                last_ui_update = now


def download_update(url: str | list[str], target: Path) -> None:
    target.parent.mkdir(parents=True, exist_ok=True)
    temp_target = target.with_name(f"{target.name}.part")
    last_error: Exception | None = None
    urls = [url] if isinstance(url, str) else list(url)
    urls = [item for item in urls if str(item).strip()]
    if not urls:
        raise LauncherError("download_url is empty")
    total_attempts = max(1, DOWNLOAD_RETRIES * len(urls))
    attempt_index = 0
    for current_url in urls:
        for attempt in range(1, DOWNLOAD_RETRIES + 1):
            attempt_index += 1
            try:
                remove_path(temp_target)
                set_update_progress(f"正在下载安装包（第 {attempt_index}/{total_attempts} 次），请不要关闭软件。")
                log(f"Download attempt {attempt_index}/{total_attempts}: {current_url}")
                if not _parallel_download_update(str(current_url), temp_target):
                    _single_download_update(str(current_url), temp_target)
                temp_target.replace(target)
                return
            except Exception as exc:  # noqa: BLE001
                last_error = exc
                log(f"Download attempt {attempt_index} failed: {exc}")
                remove_path(temp_target)
                if attempt < DOWNLOAD_RETRIES:
                    time.sleep(min(2 * attempt, 6))
    raise LauncherError(f"download failed after {total_attempts} attempts: {last_error}")


def safe_extract(zip_path: Path, target_dir: Path) -> None:
    target_dir.mkdir(parents=True, exist_ok=True)
    target_root = target_dir.resolve()
    with zipfile.ZipFile(zip_path) as archive:
        for member in archive.infolist():
            destination = (target_dir / member.filename).resolve()
            if destination != target_root and target_root not in destination.parents:
                raise LauncherError("update package contains unsafe paths")
        archive.extractall(target_dir)


def find_package_root(extracted_dir: Path) -> Path:
    if (extracted_dir / "version.json").exists() and (extracted_dir / "run_app.py").exists():
        return extracted_dir
    children = [path for path in extracted_dir.iterdir() if path.is_dir()]
    if len(children) == 1 and (children[0] / "version.json").exists() and (children[0] / "run_app.py").exists():
        return children[0]
    raise LauncherError("update package does not look like a client package")


def validate_package(package_root: Path, expected_version: str) -> None:
    required = [package_root / "hydrology_app", package_root / "run_app.py", package_root / "version.json"]
    missing = [str(path.name) for path in required if not path.exists()]
    if missing:
        raise LauncherError("update package is missing: " + ", ".join(missing))
    version = json.loads((package_root / "version.json").read_text(encoding="utf-8-sig")).get("version")
    if expected_version and str(version) != str(expected_version):
        raise LauncherError(f"update package version {version} does not match {expected_version}")


def remove_path(path: Path) -> None:
    if path.is_dir():
        shutil.rmtree(path)
    elif path.exists():
        path.unlink()


def cleanup_obsolete_items() -> None:
    for name in OBSOLETE_ITEMS:
        target = ROOT / name
        if target.exists():
            remove_path(target)
            log(f"Removed obsolete startup item: {name}")


def copy_path(source: Path, target: Path) -> None:
    if source.is_dir():
        shutil.copytree(source, target)
    else:
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, target)


def restore_backup(backup_root: Path) -> None:
    for name in MANAGED_ITEMS:
        backup = backup_root / name
        target = ROOT / name
        if backup.exists():
            remove_path(target)
            copy_path(backup, target)


def managed_items_for_install() -> list[str]:
    if getattr(sys, "frozen", False):
        return [name for name in MANAGED_ITEMS if name != Path(sys.executable).name]
    return MANAGED_ITEMS


def install_package(package_root: Path, version: str) -> None:
    backup_root = BACKUP_DIR / f"{int(time.time())}_{version}"
    backup_root.mkdir(parents=True, exist_ok=True)
    managed_items = managed_items_for_install()
    try:
        for name in managed_items:
            current = ROOT / name
            if current.exists():
                copy_path(current, backup_root / name)
        for name in managed_items:
            source = package_root / name
            if source.exists():
                target = ROOT / name
                remove_path(target)
                copy_path(source, target)
        cleanup_obsolete_items()
    except Exception:
        restore_backup(backup_root)
        raise


def cleanup_work_dirs() -> None:
    for path in [STAGING_DIR]:
        if path.exists():
            shutil.rmtree(path)


def apply_update(local: dict[str, object], remote: dict[str, object]) -> None:
    version = str(remote.get("version") or "")
    download_urls = resolve_download_urls(local, remote)
    sha256 = str(remote.get("sha256") or "").strip().lower()
    if not sha256:
        raise LauncherError("server did not provide sha256")
    zip_path = DOWNLOAD_DIR / f"hydrology_client_{version}.zip"
    show_update_progress(f"检测到新版 {version}，正在准备自动更新。")
    try:
        cleanup_work_dirs()
        log(f"Downloading {download_urls[0]}")
        download_update(download_urls, zip_path)
        set_update_progress("下载完成，正在校验完整性。")
        actual_sha256 = sha256_file(zip_path).lower()
        if actual_sha256 != sha256:
            raise LauncherError("sha256 mismatch")
        set_update_progress("校验通过，正在解压更新文件。")
        safe_extract(zip_path, STAGING_DIR)
        package_root = find_package_root(STAGING_DIR)
        validate_package(package_root, version)
        set_update_progress("正在安装更新文件，完成后会自动打开软件。")
        install_package(package_root, version)
        cleanup_work_dirs()
        log(f"Updated to {version}")
    finally:
        close_update_progress()


def check_and_update() -> bool:
    local = load_local_version()
    remote = fetch_latest_version(local)
    write_diagnostics(
        server_version=str(remote.get("version") or ""),
        server_download_url=str(remote.get("download_url") or ""),
        server_sha256=str(remote.get("sha256") or ""),
        update_required=should_update(local, remote),
        update_stage="checked",
    )
    if not should_update(local, remote):
        return False
    apply_update(local, remote)
    write_diagnostics(
        server_version=str(remote.get("version") or ""),
        server_download_url=str(remote.get("download_url") or ""),
        server_sha256=str(remote.get("sha256") or ""),
        update_required=True,
        update_stage="updated",
        updated_to=str(remote.get("version") or ""),
    )
    return True


def try_check_and_update() -> bool:
    try:
        return check_and_update()
    except (LauncherError, OSError, error.URLError, json.JSONDecodeError, zipfile.BadZipFile) as exc:
        log(f"Update skipped: {exc}")
        write_diagnostics(update_stage="failed", last_error=str(exc))
        return False


def preflight_update_before_run() -> bool:
    """Update before opening the app so stale clients cannot show the old UI first."""
    # @hook launcher-preflight 20260806
    # Startup now checks the server version before UI launch and blocks known-stale HTML clients if update fails.
    try:
        local = load_local_version()
    except (LauncherError, OSError, json.JSONDecodeError) as exc:
        write_diagnostics(update_stage="local_version_failed", last_error=str(exc))
        notify_user("水文计算软件启动失败", "本机安装不完整，缺少 version.json 或版本文件损坏。请从官网重新下载安装最新版。")
        return False

    if not is_expected_install_root():
        log(f"Warning: running outside expected install root: {ROOT}")

    try:
        remote = fetch_latest_version(local)
        update_required = should_update(local, remote)
        write_diagnostics(
            server_version=str(remote.get("version") or ""),
            server_download_url=str(remote.get("download_url") or ""),
            server_sha256=str(remote.get("sha256") or ""),
            update_required=update_required,
            update_stage="preflight_checked",
        )
        if update_required:
            apply_update(local, remote)
            write_diagnostics(
                server_version=str(remote.get("version") or ""),
                server_download_url=str(remote.get("download_url") or ""),
                server_sha256=str(remote.get("sha256") or ""),
                update_required=True,
                update_stage="preflight_updated",
                updated_to=str(remote.get("version") or ""),
            )
        return True
    except (LauncherError, OSError, error.URLError, json.JSONDecodeError, zipfile.BadZipFile) as exc:
        local_version = str(local.get("version") or "0.0.0")
        ui_engine = str(local.get("ui_engine") or "").lower()
        write_diagnostics(update_stage="preflight_failed", last_error=str(exc))
        log(f"Preflight update failed: {exc}")
        if ui_engine != "html" or compare_versions(local_version, MINIMUM_HTML_VERSION) < 0:
            notify_user(
                "需要更新后再启动",
                "本机版本过旧，继续打开可能出现旧界面或重新登录异常。\n\n"
                "请检查网络后重新打开软件；如仍失败，请把 .launcher\\diagnostics.json 和 .launcher\\launcher.log 发给管理员。",
            )
            return False
        return True


def run_app() -> int:
    if not RUN_APP.exists():
        print("run_app.py not found.")
        return 1
    if getattr(sys, "frozen", False):
        os.chdir(ROOT)
        sys.path.insert(0, str(ROOT))
        try:
            runpy.run_path(str(RUN_APP), run_name="__main__")
        except SystemExit as exc:
            return int(exc.code or 0) if isinstance(exc.code, int) else 1
        return 0
    return subprocess.call([sys.executable, str(RUN_APP)], cwd=str(ROOT))


def main() -> int:
    if not acquire_single_instance_lock():
        return 3
    if os.environ.get("HYDROLOGY_LAUNCHER_CHECK_ONLY") == "1":
        try_check_and_update()
        return 0
    if os.environ.get("HYDROLOGY_LAUNCHER_DISABLE_PREFLIGHT_UPDATE") != "1":
        if not preflight_update_before_run():
            return 2
    elif os.environ.get("HYDROLOGY_LAUNCHER_UPDATE_BEFORE_RUN") == "1":
        try_check_and_update()
    exit_code = run_app()
    if os.environ.get("HYDROLOGY_LAUNCHER_SKIP_POST_RUN_UPDATE") != "1":
        try_check_and_update()
    return exit_code


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