"""Runtime dependency bootstrap for lightweight frozen builds."""

from __future__ import annotations

import ctypes
import importlib.util
import os
import subprocess
import sys
import time
import shutil
import urllib.request
import zipfile
from pathlib import Path


DEPENDENCIES = [
    "PyYAML>=6.0",
    "requests>=2.28",
    "numpy>=1.24",
    "edge-tts>=6.1",
    "sounddevice>=0.4",
    "tencentcloud-sdk-python-asr>=3.1",
    "PyQt6>=6.11,<6.12",
    "PyQt6-WebEngine>=6.11,<6.12",
]

REQUIRED_MODULES = [
    ("yaml", "PyYAML"),
    ("requests", "requests"),
    ("numpy", "numpy"),
    ("edge_tts", "edge-tts"),
    ("sounddevice", "sounddevice"),
    ("tencentcloud", "tencentcloud-sdk-python-asr"),
    ("PyQt6.QtCore", "PyQt6"),
    ("PyQt6.QtWebEngineWidgets", "PyQt6-WebEngine"),
]

INSTALL_SENTINEL = "coco-deps-v1"
PYTHON_EMBED_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"


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


def app_dir() -> Path:
    if is_frozen():
        return Path(sys.executable).resolve().parent
    return Path(__file__).resolve().parent.parent


def core_dir() -> Path:
    configured = os.environ.get("coco_core_dir")
    if configured:
        return Path(configured).resolve()
    if is_frozen():
        return app_dir() / "core"
    return app_dir()


def deps_dir() -> Path:
    return Path(os.environ.get("coco_deps_dir") or core_dir() / "runtime").resolve()


def site_packages_dir() -> Path:
    return deps_dir() / "site-packages"


def log_path() -> Path:
    return app_dir() / "bootstrap.log"


def add_dependency_paths() -> None:
    site_packages = site_packages_dir()
    paths = [
        site_packages,
        site_packages / "PyQt6" / "Qt6" / "bin",
        site_packages / "numpy.libs",
        site_packages / "_sounddevice_data" / "portaudio-binaries",
    ]
    for path in paths:
        if not path.exists():
            continue
        path_text = str(path)
        if path_text not in sys.path:
            sys.path.insert(0, path_text)
        if os.name == "nt":
            try:
                os.add_dll_directory(path_text)
            except (FileNotFoundError, OSError):
                pass
        os.environ["PATH"] = f"{path_text}{os.pathsep}{os.environ.get('PATH', '')}"


def missing_dependencies() -> list[str]:
    add_dependency_paths()
    missing = []
    for module_name, package_name in REQUIRED_MODULES:
        try:
            if importlib.util.find_spec(module_name) is None:
                raise ModuleNotFoundError(module_name)
            __import__(module_name)
        except Exception as exc:
            _log(f"Missing dependency check failed for {module_name}: {type(exc).__name__}: {exc}")
            missing.append(package_name)
    return sorted(set(missing))


def ensure_dependencies() -> None:
    if not is_frozen() or os.environ.get("coco_skip_deps_install") == "1":
        add_dependency_paths()
        return

    sentinel = deps_dir() / INSTALL_SENTINEL
    missing = runtime_missing_dependencies() if _portable_python_exe().exists() else _all_dependency_packages()
    if not missing and sentinel.exists():
        return

    deps_dir().mkdir(parents=True, exist_ok=True)
    site_packages_dir().mkdir(parents=True, exist_ok=True)
    _log("Missing runtime dependencies: " + (", ".join(missing) or "sentinel only"))

    setup_path = app_dir() / "setup.cmd"
    if os.environ.get("coco_setup_mode") != "1" and setup_path.exists():
        if _run_setup():
            add_dependency_paths()
            still_missing = missing_dependencies()
            if not still_missing:
                sentinel.write_text(str(int(time.time())), encoding="utf-8")
                _log("Runtime dependencies are ready after setup.cmd.")
                return
            _log("setup.cmd finished but dependencies are still missing: " + ", ".join(still_missing))
        else:
            _log("setup.cmd failed; stopping dependency bootstrap.")
            raise SystemExit(1)

    _notify(
        "coco 首次运行需要自动下载安装公共运行依赖。\n"
        "这一步只执行一次，可能需要几分钟；完成后会自动启动。"
    )
    try:
        _install_with_pip()
    except Exception as exc:
        _log(f"Dependency install failed: {exc!r}")
        _notify(
            "coco 自动安装运行依赖失败。\n"
            f"日志: {log_path()}\n\n{exc}",
            error=True,
        )
        raise SystemExit(1) from exc

    still_missing = runtime_missing_dependencies()
    if still_missing:
        message = "运行依赖安装后仍缺失: " + ", ".join(still_missing)
        _log(message)
        _notify(f"{message}\n日志: {log_path()}", error=True)
        raise SystemExit(1)

    sentinel.write_text(str(int(time.time())), encoding="utf-8")
    _log("Runtime dependencies are ready.")


def runtime_missing_dependencies() -> list[str]:
    python_exe = _portable_python_exe()
    if not python_exe.exists():
        return _all_dependency_packages()

    script = (
        "import importlib\n"
        f"modules = {REQUIRED_MODULES!r}\n"
        "missing = []\n"
        "for module_name, package_name in modules:\n"
        "    try:\n"
        "        importlib.import_module(module_name)\n"
        "    except Exception as exc:\n"
        "        print(f'{package_name}: {type(exc).__name__}: {exc}')\n"
        "        missing.append(package_name)\n"
        "raise SystemExit(1 if missing else 0)\n"
    )
    code = _run_logged([str(python_exe), "-c", script], timeout=120, check=False, env=_runtime_env())
    if code == 0:
        return []

    # The detailed import failures are already written to bootstrap.log.
    return _all_dependency_packages()


def runtime_env() -> dict[str, str]:
    return _runtime_env()


def portable_python_exe(prefer_windowed: bool = False) -> Path:
    python_dir = _portable_python_dir()
    if prefer_windowed:
        pythonw = python_dir / "pythonw.exe"
        if pythonw.exists():
            return pythonw
    return _portable_python_exe()


def _all_dependency_packages() -> list[str]:
    return sorted({package for _, package in REQUIRED_MODULES})


def _run_setup() -> bool:
    setup_path = app_dir() / "setup.cmd"
    if not setup_path.exists():
        return False
    env = os.environ.copy()
    env["coco_setup_mode"] = "1"
    env["coco_core_dir"] = str(core_dir())
    command = [os.environ.get("ComSpec", "cmd.exe"), "/d", "/c", str(setup_path)]
    creationflags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) if os.name == "nt" else 0
    _log(f"Running setup.cmd: {setup_path}")
    result = subprocess.run(
        command,
        cwd=str(app_dir()),
        env=env,
        stdin=subprocess.DEVNULL,
        creationflags=creationflags,
        timeout=1800,
    )
    if result.returncode != 0:
        _log(f"setup.cmd failed with exit code {result.returncode}")
        return False
    return True


def _install_with_pip() -> None:
    python_exe = _ensure_portable_python()
    index_url = os.environ.get("coco_pip_index_url", "").strip()
    extra_args = []
    if index_url:
        extra_args += ["--index-url", index_url]

    cmd = [
        str(python_exe),
        "-m",
        "pip",
        "install",
        "--target",
        str(site_packages_dir()),
        "--upgrade",
        "--only-binary=:all:",
        "--disable-pip-version-check",
        "--no-warn-script-location",
        *extra_args,
        *DEPENDENCIES,
    ]
    _run_logged(cmd, timeout=1800)


def _portable_python_dir() -> Path:
    return deps_dir() / "python"


def _portable_python_exe() -> Path:
    return _portable_python_dir() / "python.exe"


def _ensure_portable_python() -> Path:
    python_exe = _portable_python_exe()
    if python_exe.exists() and _run_logged(
        [str(python_exe), "-c", "import sys; print(sys.version)"],
        timeout=30,
        check=False,
    ) == 0:
        _ensure_portable_pip(python_exe)
        return python_exe

    python_dir = _portable_python_dir()
    if python_dir.exists():
        shutil.rmtree(python_dir)
    python_dir.mkdir(parents=True, exist_ok=True)

    archive = deps_dir() / f"python-{PYTHON_EMBED_VERSION}-embed-amd64.zip"
    url = os.environ.get("coco_python_embed_url", "").strip() or (
        f"https://www.python.org/ftp/python/{PYTHON_EMBED_VERSION}/"
        f"python-{PYTHON_EMBED_VERSION}-embed-amd64.zip"
    )
    _download(url, archive)
    _log(f"Extracting portable Python: {archive}")
    with zipfile.ZipFile(archive) as zf:
        zf.extractall(python_dir)
    _configure_embedded_python(python_dir)
    _ensure_portable_pip(python_exe)
    return python_exe


def _configure_embedded_python(python_dir: Path) -> None:
    for path in python_dir.glob("python*._pth"):
        lines = []
        seen_site = False
        seen_lib_site = False
        seen_runtime_site = False
        for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
            line = raw.strip()
            if line == "#import site":
                line = "import site"
            if line == "import site":
                seen_site = True
            if line.replace("/", "\\").lower() == r"lib\site-packages":
                seen_lib_site = True
            if line.replace("/", "\\").lower() == r"..\site-packages":
                seen_runtime_site = True
            lines.append(line)
        if not seen_lib_site:
            lines.insert(max(0, len(lines) - 1), r"Lib\site-packages")
        if not seen_runtime_site:
            lines.insert(max(0, len(lines) - 1), r"..\site-packages")
        if not seen_site:
            lines.append("import site")
        path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def _ensure_portable_pip(python_exe: Path) -> None:
    if _run_logged([str(python_exe), "-m", "pip", "--version"], timeout=30, check=False) == 0:
        return
    get_pip = deps_dir() / "get-pip.py"
    url = os.environ.get("coco_get_pip_url", "").strip() or "https://bootstrap.pypa.io/get-pip.py"
    _download(url, get_pip)
    _run_logged(
        [
            str(python_exe),
            str(get_pip),
            "--disable-pip-version-check",
            "--no-warn-script-location",
        ],
        timeout=900,
    )


def _download(url: str, target: Path) -> None:
    target.parent.mkdir(parents=True, exist_ok=True)
    _log(f"Downloading: {url} -> {target}")
    request = urllib.request.Request(url, headers={"User-Agent": "coco Setup"})
    with urllib.request.urlopen(request, timeout=120) as response:
        with target.open("wb") as f:
            shutil.copyfileobj(response, f)


def _runtime_env() -> dict[str, str]:
    env = os.environ.copy()
    env["coco_core_dir"] = str(core_dir())
    env["PYTHONNOUSERSITE"] = "1"
    runtime_paths = [
        site_packages_dir(),
        site_packages_dir() / "PyQt6" / "Qt6" / "bin",
        site_packages_dir() / "numpy.libs",
        site_packages_dir() / "_sounddevice_data" / "portaudio-binaries",
        _portable_python_dir(),
    ]
    existing_pythonpath = env.get("PYTHONPATH", "")
    env["PYTHONPATH"] = os.pathsep.join([
        str(path) for path in runtime_paths if path.exists()
    ] + ([existing_pythonpath] if existing_pythonpath else []))
    env["PATH"] = os.pathsep.join([
        str(path) for path in runtime_paths if path.exists()
    ] + [env.get("PATH", "")])
    return env


def _run_logged(cmd: list[str], timeout: int, check: bool = True, env: dict[str, str] | None = None) -> int:
    _log("Running: " + _masked_command(cmd))
    run_env = env or os.environ.copy()
    run_env["PYTHONNOUSERSITE"] = "1"
    with log_path().open("ab") as log_file:
        result = subprocess.run(
            cmd,
            cwd=str(app_dir()),
            env=run_env,
            stdin=subprocess.DEVNULL,
            stdout=log_file,
            stderr=subprocess.STDOUT,
            timeout=timeout,
        )
    if check and result.returncode != 0:
        raise RuntimeError(f"command failed with exit code {result.returncode}: {_masked_command(cmd)}")
    return int(result.returncode)


def _log(message: str) -> None:
    try:
        with log_path().open("a", encoding="utf-8") as f:
            f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}\n")
    except OSError:
        pass


def _notify(message: str, error: bool = False) -> None:
    if os.environ.get("coco_bootstrap_quiet") == "1":
        return
    if os.name != "nt":
        return
    flags = 0x10 if error else 0x40
    try:
        ctypes.windll.user32.MessageBoxW(None, message, "coco", flags)
    except Exception:
        pass


def _masked_command(args: list[str]) -> str:
    masked = []
    skip_next = False
    for item in args:
        if skip_next:
            masked.append("***")
            skip_next = False
            continue
        masked.append(item)
        if item in {"--index-url", "--extra-index-url"}:
            skip_next = True
    return " ".join(masked)
