"""Health check and self-update endpoints."""

from __future__ import annotations

import asyncio
import logging
import os
import re
import subprocess
import sys
import time
from pathlib import Path

from fastapi import APIRouter, Request

router = APIRouter(tags=["health"])
log = logging.getLogger(__name__)

# Cached PyPI version check (refreshed at most every 6 hours)
_pypi_cache: dict[str, object] = {"version": None, "checked_at": 0.0}
_PYPI_CHECK_INTERVAL = 6 * 3600  # seconds


def _is_editable_install() -> bool:
    """Detect pip editable install (pip install -e .)."""
    try:
        # In editable installs, the package source is a local directory
        # Check if our __file__ is inside a repo (has pyproject.toml nearby)
        repo_marker = Path(__file__).resolve().parents[4] / "pyproject.toml"
        if repo_marker.is_file():
            return True
    except Exception:
        pass
    return False


def _get_version() -> str:
    # Editable install: source of truth is pyproject.toml, NOT importlib.metadata.
    # `git pull` bumps pyproject.toml but does not rewrite the dist-info that pip
    # wrote at `pip install -e .` time, so importlib.metadata would return a stale
    # version forever and self-update polling would never see the new value.
    if _is_editable_install():
        try:
            pyproject = Path(__file__).resolve().parents[4] / "pyproject.toml"
            text = pyproject.read_text(encoding="utf-8")
            m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
            if m:
                return m.group(1)
        except Exception:
            pass

    # Installed package metadata (works from pip install)
    try:
        from importlib.metadata import version
        return version("agntspace")
    except Exception:
        pass
    # Final fallback (works from source checkout without editable marker)
    try:
        pyproject = Path(__file__).resolve().parents[4] / "pyproject.toml"
        text = pyproject.read_text(encoding="utf-8")
        m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
        if m:
            return m.group(1)
    except Exception:
        pass
    return "dev"


def _parse_version(v: str) -> tuple[int, ...]:
    """Parse '1.12.0' into (1, 12, 0) for comparison."""
    try:
        return tuple(int(x) for x in v.split("."))
    except (ValueError, AttributeError):
        return (0,)


async def _check_pypi_latest() -> str | None:
    """Check PyPI for the latest agntspace version (cached)."""
    now = time.time()
    if (
        _pypi_cache["version"] is not None
        and now - float(_pypi_cache["checked_at"]) < _PYPI_CHECK_INTERVAL
    ):
        return _pypi_cache["version"]  # type: ignore[return-value]

    try:
        import httpx
        async with httpx.AsyncClient(timeout=5.0) as client:
            resp = await client.get("https://pypi.org/pypi/agntspace/json")
            if resp.status_code == 200:
                data = resp.json()
                latest = data.get("info", {}).get("version")
                if latest:
                    _pypi_cache["version"] = latest
                    _pypi_cache["checked_at"] = now
                    return latest
    except Exception:
        pass
    return _pypi_cache.get("version")  # type: ignore[return-value]


@router.get("/health")
async def health(request: Request) -> dict:
    """Liveness + readiness probe.

    Always returns 200 ``status: "ok"`` as long as the HTTP server can
    respond — that's liveness. Readiness is reported alongside so ops
    tools (systemd, scheduled-task supervisors, healthcheck dashboards)
    can distinguish "still booting" from "dead":

    - ``ready: false`` + ``boot_phase: "building_graph"`` → knowledge
      graph still scanning the vault; chat/journal/settings work, graph
      queries return empty results until the build completes.
    - ``ready: true`` + ``boot_phase: "ready"`` → all subsystems up.
    - ``ready: false`` + ``boot_phase: "graph_build_failed"`` → the
      background build raised; server continues to serve everything
      else, but graph queries are permanently empty until the next boot.
    """
    setup_mode = getattr(request.app.state, "setup_mode", False)
    ready = bool(getattr(request.app.state, "ready", False))
    boot_phase = str(getattr(request.app.state, "boot_phase", "unknown"))
    graph_progress = getattr(request.app.state, "graph_progress", None)
    from agntspace.infra.timezone import get_tz_name
    return {
        "status": "ok",
        "version": _get_version(),
        "setup_mode": setup_mode,
        "timezone": get_tz_name(),
        "ready": ready,
        "boot_phase": boot_phase,
        "graph_progress": graph_progress,
    }


@router.get("/stats")
async def stats() -> dict:
    """PyPI downloads (total, summed over all tracked days) + GitHub stars for the landing page."""
    downloads = None
    stars = None
    try:
        import httpx
        async with httpx.AsyncClient(timeout=5.0) as client:
            pypi_resp, gh_resp = await asyncio.gather(
                client.get("https://pypistats.org/api/packages/agntspace/overall?mirrors=false"),
                client.get(
                    "https://api.github.com/repos/agntspace/agntspace",
                    headers={"Accept": "application/vnd.github.v3+json"},
                ),
                return_exceptions=True,
            )
            if not isinstance(pypi_resp, Exception) and pypi_resp.status_code == 200:
                rows = pypi_resp.json().get("data", [])
                total = sum(
                    int(row.get("downloads", 0))
                    for row in rows
                    if row.get("category") == "without_mirrors"
                )
                downloads = total if total > 0 else None
            if not isinstance(gh_resp, Exception) and gh_resp.status_code == 200:
                stars = gh_resp.json().get("stargazers_count")
    except Exception:
        pass
    return {"downloads": downloads, "stars": stars}


@router.get("/version")
async def version_check() -> dict:
    """Check if a newer version is available on PyPI."""
    current = _get_version()
    latest = await _check_pypi_latest()

    update_available = False
    if latest and current != "dev":
        update_available = _parse_version(latest) > _parse_version(current)

    return {
        "current": current,
        "latest": latest,
        "update_available": update_available,
        "editable": _is_editable_install(),
    }


def _resolve_start_command() -> list[str]:
    """Return the command to launch a replacement AgntSpace server.

    Prefers the original invocation (preserves --host/--port/etc.) but falls back
    to `python -m agntspace start` if the entry-point shim was renamed by
    `pip install --upgrade` (a common Windows pattern — pip can't overwrite a
    running .exe so it renames it to .exe.old).
    """
    orig = getattr(sys, "orig_argv", None) or []
    if orig:
        exe = orig[0]
        # Only reuse orig_argv if its launcher still exists on disk. A missing
        # launcher is what makes os.execv silently fail post-upgrade.
        if exe and Path(exe).is_file():
            return list(orig)

    # Robust fallback: sys.executable is never renamed by pip. Preserve any
    # runtime args (--port, --host, etc.) from sys.argv beyond the program name.
    args = list(sys.argv[1:]) if len(sys.argv) > 1 else []
    if "start" not in args:
        args = ["start", *args]
    return [sys.executable, "-m", "agntspace", *args]


def _spawn_detached_restart() -> None:
    """Spawn a fully-detached replacement server.

    The child survives our exit and does its own port reclaim via the
    built-in `_try_reclaim_port()` in `cli.start()`. This is what fixes
    the "Server did not restart in time" failure mode: `os.execv` on
    Windows races with port 8000's TIME_WAIT and often leaves the new
    process unable to bind. A detached child with its own port-reclaim
    step sidesteps both issues.
    """
    cmd = _resolve_start_command()
    log.info("Self-update: spawning replacement server: %s", " ".join(str(c) for c in cmd))

    if sys.platform == "win32":
        # CREATE_NO_WINDOW: silent background (no console flash for UI-triggered update)
        # CREATE_NEW_PROCESS_GROUP: isolates child from our Ctrl-C / parent death signals
        CREATE_NO_WINDOW = 0x08000000
        CREATE_NEW_PROCESS_GROUP = 0x00000200
        subprocess.Popen(  # noqa: S603 — command is built from sys.executable / orig_argv
            cmd,
            creationflags=CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            stdin=subprocess.DEVNULL,
            close_fds=True,
        )
    else:
        # start_new_session=True: POSIX equivalent — child becomes its own session leader
        subprocess.Popen(  # noqa: S603
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            stdin=subprocess.DEVNULL,
            start_new_session=True,
            close_fds=True,
        )


def _restart_server() -> None:
    """Spawn a detached replacement server, then exit this process cleanly.

    os._exit(0) bypasses atexit handlers — uvicorn's graceful shutdown can
    hold port 8000 for 30+ seconds, which would starve the child's bind().
    We want an immediate release so the child's port reclaim either finds
    the port free or finds our already-dying shell and cleans it up.
    """
    log.info("Restarting server after self-update...")
    try:
        _spawn_detached_restart()
    except Exception:
        log.exception("Failed to spawn replacement server — aborting restart")
        return
    # Give the child a brief moment to start Python before we release the port
    time.sleep(0.5)
    log.info("Self-update: exiting current process to release port 8000")
    os._exit(0)


@router.post("/update")
async def self_update() -> dict:
    """Install the latest version from PyPI (or git pull for editable installs) and restart."""
    current = _get_version()
    editable = _is_editable_install()

    latest = await _check_pypi_latest()
    if not editable and (not latest or _parse_version(latest) <= _parse_version(current)):
        return {"status": "skipped", "reason": "Already up to date"}

    if editable:
        # Source checkout — use git pull instead of pip
        try:
            repo_root = Path(__file__).resolve().parents[4]
            result = subprocess.run(
                ["git", "pull", "--ff-only"],
                capture_output=True,
                text=True,
                timeout=60,
                cwd=str(repo_root),
            )
            if result.returncode != 0:
                log.error("git pull failed: %s", result.stderr[-500:] if result.stderr else "unknown")
                return {
                    "status": "failed",
                    "reason": result.stderr.strip().split("\n")[-1] if result.stderr else "git pull failed",
                }
            # Also rebuild UI if node_modules exists
            ui_dir = repo_root / "ui"
            if (ui_dir / "node_modules").is_dir():
                subprocess.run(
                    ["npm", "run", "build"],
                    capture_output=True,
                    text=True,
                    timeout=120,
                    cwd=str(ui_dir),
                    shell=True,  # npm .cmd wrapper on Windows
                )
        except subprocess.TimeoutExpired:
            return {"status": "failed", "reason": "git pull timed out"}
    else:
        # PyPI install
        try:
            result = subprocess.run(
                [sys.executable, "-m", "pip", "install", "--upgrade", "--no-cache-dir", "agntspace"],
                capture_output=True,
                text=True,
                timeout=120,
            )
            if result.returncode != 0:
                log.error("pip upgrade failed: %s", result.stderr[-500:] if result.stderr else "unknown")
                return {
                    "status": "failed",
                    "reason": result.stderr.strip().split("\n")[-1] if result.stderr else "pip failed",
                }
        except subprocess.TimeoutExpired:
            return {"status": "failed", "reason": "pip timed out after 120s"}

    # Schedule server restart (gives the HTTP response time to flush)
    asyncio.get_event_loop().call_later(1.0, _restart_server)

    return {
        "status": "restarting",
        "from_version": current,
        "to_version": latest or "latest",
        "method": "git" if editable else "pip",
    }
