"""Source launch/update composition over the shared JavaScript builders."""

import os
from pathlib import Path
import shutil
import subprocess
import sys


def source_product_current(project_root: Path, product: str, out: Path) -> bool:
    """Read the compiler's receipt without acquiring tools or dependencies."""
    from pm import env_for, installed_package

    installed = installed_package("node")
    if installed is None or installed.binary is None:
        return False  # Only PM's Node may run the receipt reader; never the user's PATH copy.
    node, env = str(installed.binary), env_for("node")
    try:
        result = subprocess.run(
            [node, str(project_root / "scripts/build/freshness.mjs"),
             "--source", str(project_root), "--product", product, "--out", str(out)],
            cwd=project_root, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=True,
        )
        return result.stdout.strip() == "true"
    except (OSError, subprocess.SubprocessError):
        return False


def source_build_env(base_env: dict | None = None, *, explicit: bool = False, verify: bool = True) -> dict[str, str]:
    from pm import ensure
    from pm.environments import project_python, running_from_selected_environment
    from pm.paths import repo_root
    from hermes_constants import get_hermes_home

    # The historical update runs on store Python with the selected environment
    # activated in-process. Icon generation starts an isolated child, which needs
    # the selected venv executable rather than the store interpreter.
    root = repo_root()
    python = str(project_python(root)) if running_from_selected_environment(root) else sys.executable
    env = {**os.environ, **(base_env or {}), "CI": "1", "HERMES_PYTHON": python,
           "PYTHON": python}
    # Electron's installer unpacks its binary with a native module that imports
    # VCRUNTIME140.dll, which a fresh Windows lacks. Our Python ships one, and
    # scripts/build/electron-runtime.mjs copies it beside that module.
    vc_runtime = Path(sys.base_prefix) / "vcruntime140.dll"
    if vc_runtime.is_file():
        env["HERMES_VC_RUNTIME"] = str(vc_runtime)
    env.pop("ESBUILD_BINARY_PATH", None)
    npmrc = get_hermes_home() / "npmrc"
    if npmrc.is_file():
        env.setdefault("NPM_CONFIG_USERCONFIG", str(npmrc))
    return ensure("npm", base_env=env, explicit=explicit, verify=verify).env


def run_in_custody(project_root: Path, command: list, label: str, **kwargs):
    """``pm.progress.run_contained`` for a build command that writes the checkout (node, npm):
    it and everything it starts stay in the update's custody (POSIX: the checkout lock fd, the
    caller's process group, and every descendant killed when it exits; Windows: the owner's
    kill-on-close job), so the checkout is never handed to a contender while one of them still
    writes. Outside an update it is ``run_contained`` as is."""
    from pm.progress import run_contained
    from hermes_cli.update_custody import contained_command

    with contained_command(command, root=project_root) as (argv, custody):
        return run_contained(argv, label, **kwargs, **custody)


def run_source_script(project_root: Path, script: str, *args: str, env: dict, label: str) -> None:
    command = [shutil.which("node", path=env["PATH"]), str(project_root / script), *args]
    # npm's deprecation warnings are the loudest lines and never actionable
    # here; they still land in the failure tail.
    run_in_custody(project_root, command, label, hide=lambda line: line.lower().startswith("npm warn"),
                   indent="  ", cwd=project_root, env=env)


def prepare_source_dependencies(project_root: Path, workspaces: tuple[str, ...], *, env: dict,
                                explicit: bool = False) -> None:
    from pm import lazy_installs_allowed

    run_source_script(
        project_root, "scripts/build/node-deps.mjs", "--source", str(project_root), "--reuse",
        *(() if explicit or lazy_installs_allowed() else ("--no-install",)),
        *(arg for workspace in workspaces for arg in ("--workspace", workspace)), env=env,
        label="Preparing Node dependencies",
    )


def prepare_launch_dependencies(project_root: Path, *, env: dict) -> None:
    """A launch rebuild must not prune another installed source frontend."""
    from hermes_cli.main_desktop import _desktop_dist_exists, _desktop_packaged_executable

    desktop_dir = project_root / "apps/desktop"
    desktop = _desktop_dist_exists(desktop_dir) or _desktop_packaged_executable(desktop_dir) is not None
    workspaces = ("ui-tui", "web") + (("apps/desktop",) if desktop else ())
    prepare_source_dependencies(project_root, workspaces, env=env)


def build_source_tui(project_root: Path, *, env: dict) -> None:
    run_source_script(project_root, "scripts/build/tui.mjs", env=env, label="Building the TUI")


def build_source_web(project_root: Path, *, env: dict, icons: Path | None = None) -> None:
    # Bounded build (#63338): the Vite/Rolldown dashboard build saturates small
    # hosts; cap the V8 heap and the native bundler's rayon thread pool.
    from hermes_cli.web_build_limits import apply_web_build_limits

    apply_web_build_limits(env)
    # Default-brand icons are committed; installs never render them.
    icons = icons or project_root
    run_source_script(project_root, "scripts/build/web.mjs", "--source", str(project_root),
                      "--icons", str(icons), "--out", str(project_root / "hermes_cli/web_dist"), env=env,
                      label="Building the web UI")


def source_frontends(project_root: Path) -> tuple[str, ...]:
    """The frontend workspaces this checkout carries. A source slice without them
    (python-only installs, the installer's acceptance fixture) has no products
    to build; it still publishes commands and runs the maintenance tail."""
    return tuple(name for name in ("ui-tui", "web") if (project_root / name / "package.json").is_file())


class ProductBuildError(RuntimeError):
    """One or more products failed; every independent product was still attempted."""

    def __init__(self, failures: list[tuple[str, BaseException]]):
        self.failures = failures
        super().__init__("; ".join(f"{name}: {_failure_text(exc)}" for name, exc in failures))


def _failure_text(exc: BaseException) -> str:
    if isinstance(exc, subprocess.CalledProcessError):
        return f"{' '.join(map(str, exc.cmd)) if isinstance(exc.cmd, (list, tuple)) else exc.cmd} exited {exc.returncode}"
    return f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__


def _headless_linux() -> bool:
    return sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))


def build_update_products(project_root: Path, *, desktop: bool) -> None:
    """Prepare the selected union once, attempting every independent product.

    A failed product never skips the next one (a broken web build must not leave the TUI or the
    desktop app stale); the failures are raised together at the end as ``ProductBuildError``.
    """
    # Both current updates and historical takeover reach this in a fresh target
    # interpreter, never in the updater's pre-sync import graph.
    from hermes_cli.main_install_repair import _install_configured_features_missing_deps
    from hermes_cli.update_stage import publish_stage

    failures: list[tuple[str, BaseException]] = []

    def attempt(name: str, step) -> bool:
        try:
            step()
            return True
        except Exception as exc:  # health: allow BLE001 -- collected and raised after the independent steps
            print(f"  ⚠ {name} failed: {_failure_text(exc)}")
            failures.append((name, exc))
            return False

    attempt("feature dependencies", lambda: _install_configured_features_missing_deps(project_root))
    frontends = source_frontends(project_root)
    if frontends:
        desktop_built = False
        env: dict = {}
        workspaces = frontends + (("apps/desktop",) if desktop else ())
        publish_stage("Updating Node dependencies")

        def node_dependencies(selected: tuple[str, ...]) -> None:
            # Acquiring npm is part of this step: its failure must be reported like the install's.
            # A recorded install is trusted as at startup (re-hashing node+npm costs ~2 s per
            # tail); a missing one is still installed explicitly.
            env.update(source_build_env(explicit=True, verify=False))
            prepare_source_dependencies(project_root, selected, env=env, explicit=True)

        # Every product compiles from these node_modules: without them there is nothing to build.
        prepared = attempt("Node dependencies", lambda: node_dependencies(workspaces))
        desktop_skipped = False
        if not prepared and desktop and env:
            # Only apps/desktop brings native addons (node-pty, electron) into the union: a host
            # whose compiler cannot build them must still get the TUI and web UI it runs.
            print("  → Retrying Node dependencies without the desktop app")
            failed = failures.pop()
            prepared = desktop_skipped = attempt("Node dependencies", lambda: node_dependencies(frontends))
            if desktop_skipped:
                failures.append(("desktop dependencies", failed[1]))
        desktop_reason = failures[-1][0] if failures else ""
        if desktop and (desktop_skipped or _headless_linux()):
            print("  ⓘ If this host never runs the desktop app, `hermes uninstall --gui` removes it "
                  "and future updates stop building it.")
        if prepared:
            # An update that changed no TUI/web input reuses the receipted output, as the
            # launch path already does; recompiling it produces the same bytes. Desktop
            # additionally needs the packaged app to name HEAD (its baked stamp carries the
            # commit), so it is reused only when HEAD did not move: "Already up to date",
            # a retried tail, a takeover re-entry.
            if "ui-tui" in frontends:
                if source_product_current(project_root, "tui", project_root / "ui-tui/dist"):
                    print("  ✓ TUI is up to date")
                else:
                    publish_stage("Building the TUI")
                    attempt("TUI build", lambda: build_source_tui(project_root, env=env))
            if "web" in frontends:
                if source_product_current(project_root, "web", project_root / "hermes_cli/web_dist"):
                    print("  ✓ Web UI is up to date")
                else:
                    publish_stage("Building the web UI")
                    attempt("web UI build", lambda: build_source_web(project_root, env=env))
            if desktop and not desktop_skipped:
                desktop_built = attempt("desktop app build", lambda: _build_desktop_product(project_root, env, publish_stage))
                desktop_reason = failures[-1][0] if not desktop_built else ""
        if desktop and not desktop_built:
            # One whole line the Desktop hand-off scripts match: the follow-up text is truncated
            # and names whichever products failed first, so it cannot say if THIS app was rebuilt.
            print(f"  Desktop app build owed: {desktop_reason} failed")
    # A configured memory provider that no longer ships in core is installed from the
    # catalog for every profile home sharing this venv (config, data and tool names
    # unchanged). The update must finish even if the migration blows up.
    try:
        from hermes_cli.memory_provider_migration import migrate_all_homes

        migrate_all_homes()
    except Exception as exc:  # health: allow BLE001 -- post-commit boundary: printed as ⚠, never fails the committed update; every later update/tail re-runs it
        print(f"  ⚠ Memory provider migration skipped: {exc}")
    # Same for a gateway platform / toolset that left core (Home Assistant): every home that used
    # it gets its catalog plugin (hermes_cli/left_core_migration.py).
    try:
        from hermes_cli.left_core_migration import migrate_all_homes as migrate_left_core

        migrate_left_core()
    except Exception as exc:  # health: allow BLE001 -- post-commit boundary: printed as ⚠, never fails the committed update; every later update/tail re-runs it
        print(f"  ⚠ Plugin migration skipped: {exc}")
    if failures:
        raise ProductBuildError(failures)


def _build_desktop_product(project_root: Path, env: dict, publish_stage) -> None:
    from hermes_cli.main_desktop import (
        _packaged_desktop_current_for_head, _refresh_installed_desktop_apps, build_prepared_desktop)

    desktop_dir = project_root / "apps/desktop"
    if _packaged_desktop_current_for_head(desktop_dir, project_root):
        print("  ✓ Desktop app is up to date")
    else:
        publish_stage("Building the desktop app")
        # The desktop build mutates checkout-scoped node_modules and
        # apps/desktop/release; serialize it against a concurrent manual
        # `hermes desktop` (#93940). The update path waits rather than exits:
        # the in-flight build it queues behind produces the same fresh tree
        # this update needs.
        from hermes_cli.desktop_build_lock import DesktopBuildLock

        build_lock = DesktopBuildLock(project_root)
        build_lock.acquire(wait=True)
        try:
            build_prepared_desktop(
                desktop_dir, source_mode=False,
                npm=shutil.which("npm", path=env["PATH"]), env=env, icons=project_root,
            )
        finally:
            build_lock.release()
    # A current release/ can still sit beside a stale installed copy (an earlier
    # update rebuilt but never installed); healing must not wait for the next build.
    _refresh_installed_desktop_apps(desktop_dir)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Build source-install frontends")
    parser.add_argument("--source", type=Path, required=True)
    parser.add_argument("--desktop", action="store_true")
    args = parser.parse_args()
    build_update_products(args.source.resolve(), desktop=args.desktop)
