"""Helpers for keeping the bundled WebUI build in sync with source checkouts."""

from __future__ import annotations

import os
import shutil
import subprocess
from collections.abc import Callable, Mapping
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal

BuildMode = Literal["auto", "prompt", "warn", "skip"]

_SOURCE_TOP_LEVEL_FILES = (
    "index.html",
    "package.json",
    "bun.lock",
    "package-lock.json",
    "pnpm-lock.yaml",
    "yarn.lock",
    "vite.config.ts",
    "vite.config.js",
    "tailwind.config.ts",
    "tailwind.config.js",
    "postcss.config.ts",
    "postcss.config.js",
    "tsconfig.json",
    "tsconfig.build.json",
    "components.json",
)
_SOURCE_DIRS = ("src", "public")


class WebUIBuildError(RuntimeError):
    """Raised when the local WebUI bundle cannot be built."""


@dataclass(frozen=True)
class WebUIBundleStatus:
    """Freshness status for a source checkout's bundled WebUI assets."""

    source_dir: Path
    dist_dir: Path
    index_html: Path
    source_available: bool
    dist_available: bool
    stale: bool
    reason: str
    newest_source: Path | None = None
    newest_source_mtime_ns: int | None = None
    dist_mtime_ns: int | None = None

    @property
    def needs_build(self) -> bool:
        return self.source_available and self.stale


def default_project_root() -> Path:
    """Return the repository root when running from a source checkout."""
    return Path(__file__).resolve().parents[2]


def default_webui_source_dir(project_root: Path | None = None) -> Path:
    """Return the conventional frontend source directory for a checkout."""
    root = project_root or default_project_root()
    return root / "webui"


def default_webui_dist_dir(project_root: Path | None = None) -> Path:
    """Return the bundled WebUI dist directory for the installed package."""
    try:
        import nanobot.web as web_pkg  # type: ignore[import-not-found]
    except ImportError:
        root = project_root or default_project_root()
        return root / "nanobot" / "web" / "dist"
    return Path(web_pkg.__file__).resolve().parent / "dist"


def iter_webui_source_files(source_dir: Path) -> list[Path]:
    """Return WebUI source files that should make the production bundle stale."""
    files: list[Path] = []
    for name in _SOURCE_TOP_LEVEL_FILES:
        candidate = source_dir / name
        if candidate.is_file():
            files.append(candidate)
    for dirname in _SOURCE_DIRS:
        root = source_dir / dirname
        if not root.is_dir():
            continue
        files.extend(path for path in root.rglob("*") if path.is_file())
    channel_root = source_dir.parent / "nanobot" / "channels"
    if channel_root.is_dir():
        for channel_webui in channel_root.glob("*/webui"):
            files.extend(path for path in channel_webui.rglob("*") if path.is_file())
    return files


def inspect_webui_bundle(
    *,
    source_dir: Path | None = None,
    dist_dir: Path | None = None,
) -> WebUIBundleStatus:
    """Inspect whether a checkout's WebUI source is newer than the bundled dist."""
    resolved_source = source_dir or default_webui_source_dir()
    resolved_dist = dist_dir or default_webui_dist_dir()
    index_html = resolved_dist / "index.html"

    if not (resolved_source / "package.json").is_file():
        return WebUIBundleStatus(
            source_dir=resolved_source,
            dist_dir=resolved_dist,
            index_html=index_html,
            source_available=False,
            dist_available=index_html.is_file(),
            stale=False,
            reason="no_source",
        )

    if not index_html.is_file():
        return WebUIBundleStatus(
            source_dir=resolved_source,
            dist_dir=resolved_dist,
            index_html=index_html,
            source_available=True,
            dist_available=False,
            stale=True,
            reason="missing_dist",
        )

    dist_mtime_ns = index_html.stat().st_mtime_ns
    newest_source: Path | None = None
    newest_source_mtime_ns: int | None = None
    for candidate in iter_webui_source_files(resolved_source):
        try:
            mtime_ns = candidate.stat().st_mtime_ns
        except OSError:
            continue
        if newest_source_mtime_ns is None or mtime_ns > newest_source_mtime_ns:
            newest_source = candidate
            newest_source_mtime_ns = mtime_ns

    if newest_source_mtime_ns is not None and newest_source_mtime_ns > dist_mtime_ns:
        return WebUIBundleStatus(
            source_dir=resolved_source,
            dist_dir=resolved_dist,
            index_html=index_html,
            source_available=True,
            dist_available=True,
            stale=True,
            reason="source_newer",
            newest_source=newest_source,
            newest_source_mtime_ns=newest_source_mtime_ns,
            dist_mtime_ns=dist_mtime_ns,
        )

    return WebUIBundleStatus(
        source_dir=resolved_source,
        dist_dir=resolved_dist,
        index_html=index_html,
        source_available=True,
        dist_available=True,
        stale=False,
        reason="fresh",
        newest_source=newest_source,
        newest_source_mtime_ns=newest_source_mtime_ns,
        dist_mtime_ns=dist_mtime_ns,
    )


def describe_webui_bundle_status(status: WebUIBundleStatus) -> str:
    """Return a short user-facing freshness message."""
    if status.reason == "missing_dist":
        return "Bundled WebUI build is missing."
    if status.reason == "source_newer":
        changed = _display_source_path(status)
        return f"WebUI source is newer than the bundled build ({changed})."
    if status.reason == "fresh":
        return "Bundled WebUI build is up to date."
    return "WebUI source tree was not found; using the bundled build."


def build_webui_bundle(
    *,
    source_dir: Path | None = None,
    dist_dir: Path | None = None,
    runner: str | None = None,
    subprocess_run: Callable[..., subprocess.CompletedProcess[Any]] = subprocess.run,
    output: Callable[[str], None] | None = None,
) -> WebUIBundleStatus:
    """Install frontend dependencies and build the WebUI bundle."""
    resolved_source = source_dir or default_webui_source_dir()
    command_runner = runner or pick_webui_build_runner()
    if command_runner is None:
        raise WebUIBuildError(
            "neither `bun` nor `npm` is available on PATH; install one or run "
            "`cd webui && bun run build` manually"
        )

    _emit(output, f"Building bundled WebUI with `{command_runner}`...")
    _run_frontend_command(
        [command_runner, "install"],
        cwd=resolved_source,
        subprocess_run=subprocess_run,
    )
    _run_frontend_command(
        [command_runner, "run", "build"],
        cwd=resolved_source,
        subprocess_run=subprocess_run,
    )
    return inspect_webui_bundle(source_dir=resolved_source, dist_dir=dist_dir)


def ensure_webui_bundle(
    *,
    mode: BuildMode,
    source_dir: Path | None = None,
    dist_dir: Path | None = None,
    confirm: Callable[[str], bool] | None = None,
    output: Callable[[str], None] | None = None,
    runner: str | None = None,
    environ: Mapping[str, str] | None = None,
    subprocess_run: Callable[..., subprocess.CompletedProcess[Any]] = subprocess.run,
) -> WebUIBundleStatus:
    """Ensure or warn about a stale WebUI bundle according to the selected mode."""
    env = environ or os.environ
    status = inspect_webui_bundle(source_dir=source_dir, dist_dir=dist_dir)
    if not status.needs_build:
        return status

    detail = describe_webui_bundle_status(status)
    if env.get("NANOBOT_SKIP_WEBUI_BUILD") == "1" or mode == "skip":
        _emit(output, f"Warning: {detail} Skipping WebUI build.")
        return status

    if mode == "warn":
        _emit(
            output,
            f"Warning: {detail} Run `cd {status.source_dir} && bun run build` "
            "to refresh it.",
        )
        return status

    if mode == "prompt":
        if confirm is None:
            _emit(output, f"Warning: {detail} No interactive confirmation is available.")
            return status
        message = "Build WebUI now? This runs `cd webui && bun run build`."
        if not confirm(message):
            _emit(output, "Continuing with the existing bundled WebUI build.")
            return status

    try:
        return build_webui_bundle(
            source_dir=status.source_dir,
            dist_dir=status.dist_dir,
            runner=runner,
            subprocess_run=subprocess_run,
            output=output,
        )
    except WebUIBuildError as exc:
        raise WebUIBuildError(f"{detail} {exc}") from exc


def pick_webui_build_runner() -> str | None:
    """Pick the frontend package manager used to build the WebUI."""
    for candidate in ("bun", "npm"):
        if executable := shutil.which(candidate):
            return executable
    return None


def _run_frontend_command(
    command: list[str],
    *,
    cwd: Path,
    subprocess_run: Callable[..., subprocess.CompletedProcess[Any]],
) -> None:
    try:
        subprocess_run(command, cwd=cwd, check=True)
    except subprocess.CalledProcessError as exc:
        raise WebUIBuildError(
            f"command failed ({exc.returncode}): {' '.join(command)}"
        ) from exc
    except OSError as exc:
        raise WebUIBuildError(f"command failed: {' '.join(command)} ({exc})") from exc


def _display_source_path(status: WebUIBundleStatus) -> str:
    if status.newest_source is None:
        return "source files changed"
    with suppress(ValueError):
        return str(status.newest_source.relative_to(status.source_dir))
    return str(status.newest_source)


def _emit(output: Callable[[str], None] | None, message: str) -> None:
    if output is not None:
        output(message)
