# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 sol pbc

"""Build generated command references for router skills."""

from __future__ import annotations

import importlib
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path

import frontmatter
import typer
from typer.main import get_command

from solstone.think.command_polarity import classify_verb

ROOT = Path(__file__).resolve().parents[2]

# Test hook: leave as None in production so new app fragments are discovered at
# build/check time instead of being registered in a second source list.
FRAGMENT_SOURCES: tuple[Path, ...] | None = None
HEALTH_JOURNAL_NAMESPACES = ("journal health", "journal talent")
JOURNAL_NAMESPACE_CONTRIBUTIONS = {"health": HEALTH_JOURNAL_NAMESPACES}

SOL_COMMANDS_PATH = Path("solstone/talent/sol/references/commands.md")
JOURNAL_COMMANDS_PATH = Path("solstone/talent/journal/references/commands.md")

_BANNER = (
    "<!-- GENERATED by sol skills build. "
    "Do not hand-edit; regenerate with make skills. -->"
)


@dataclass(frozen=True)
class Fragment:
    app: str
    path: Path
    repo_path: str
    description: str
    triggers: tuple[str, ...]


def _absolute(path: Path) -> Path:
    return path if path.is_absolute() else ROOT / path


def _repo_relative(path: Path) -> str:
    try:
        return path.relative_to(ROOT).as_posix()
    except ValueError:
        return path.as_posix()


def discover_fragment_sources(root: Path | None = None) -> tuple[Path, ...]:
    """Return app-owned skill fragments in deterministic repo-relative order."""
    base = root or ROOT
    apps_dir = base / "solstone" / "apps"
    return tuple(
        sorted(
            path.relative_to(base)
            for path in apps_dir.glob("*/talent/*/SKILL.md")
            if path.is_file()
        )
    )


def _fragment_sources() -> tuple[Path, ...]:
    if FRAGMENT_SOURCES is not None:
        return tuple(FRAGMENT_SOURCES)
    return discover_fragment_sources(ROOT)


def _parse_triggers(description: str) -> tuple[str, ...]:
    marker = "TRIGGER:"
    if marker not in description:
        return ()
    triggers: list[str] = []
    for raw_token in description.split(marker, 1)[1].split(","):
        token = raw_token.strip()
        if token.lower().startswith("sol call"):
            continue
        token = token.removesuffix(".").strip()
        if token:
            triggers.append(token)
    return tuple(triggers)


def _load_fragment(rel_path: Path) -> Fragment:
    path = _absolute(rel_path)
    try:
        post = frontmatter.load(path)
    except Exception as exc:
        raise ValueError(f"{path}: malformed frontmatter: {exc}") from exc

    name = post.metadata.get("name")
    if not name:
        raise ValueError(f"{path}: missing frontmatter name")
    if name != path.parent.name:
        raise ValueError(
            f"{path}: frontmatter name {name!r} does not match directory {path.parent.name!r}"
        )
    description = post.metadata.get("description")
    if not description:
        raise ValueError(f"{path}: missing frontmatter description")
    if not isinstance(description, str):
        raise ValueError(f"{path}: frontmatter description must be a string")

    return Fragment(
        app=name,
        path=path,
        repo_path=_repo_relative(path),
        description=description,
        triggers=_parse_triggers(description),
    )


def _load_fragments() -> dict[str, Fragment]:
    fragments: dict[str, Fragment] = {}
    seen_names: dict[str, Path] = {}
    source_paths = _fragment_sources()
    if not source_paths:
        raise ValueError(f"{ROOT / 'solstone' / 'apps'}: no app skill fragments found")
    for rel_path in source_paths:
        fragment = _load_fragment(rel_path)
        previous = seen_names.get(fragment.app)
        if previous is not None:
            raise ValueError(
                f"{fragment.path}: duplicate app key {fragment.app!r}: "
                f"{previous} and {fragment.path}"
            )
        seen_names[fragment.app] = fragment.path
        fragments[fragment.app] = fragment
    return fragments


def _resolve_typer_app(app_name: str, source: Path) -> typer.Typer:
    module_name = (
        "solstone.think.tools.health"
        if app_name == "health"
        else f"solstone.apps.{app_name}.call"
    )
    try:
        module = importlib.import_module(module_name)
    except Exception as exc:
        raise ValueError(f"{source}: cannot import {module_name}: {exc}") from exc

    app = getattr(module, "app", None)
    if not isinstance(app, typer.Typer):
        raise ValueError(f"{source}: {module_name} does not export a Typer app")
    return app


def _command_names(app_name: str, source: Path) -> tuple[str, ...]:
    app = _resolve_typer_app(app_name, source)
    try:
        command = get_command(app)
        names = tuple(sorted(command.commands.keys()))
    except Exception as exc:
        raise ValueError(f"{source}: cannot introspect Typer commands: {exc}") from exc
    if not names:
        raise ValueError(f"{source}: Typer app has no commands")
    return names


def _format_list(values: tuple[str, ...]) -> str:
    return ", ".join(f"`{value}`" for value in values)


def _polarity_groups(verbs: tuple[str, ...]) -> dict[str, tuple[str, ...]]:
    grouped = {"read": [], "write": [], "other": []}
    for verb in verbs:
        grouped[classify_verb(verb)].append(verb)
    return {group: tuple(sorted(values)) for group, values in grouped.items() if values}


def _render_sol(fragments: dict[str, Fragment]) -> str:
    lines = [
        _BANNER,
        "",
        "# Sol Router Commands",
        "",
        "Generated inventory of app command guidance for the `sol` router.",
        "",
    ]
    for app_name in sorted(fragments):
        fragment = fragments[app_name]
        verbs = _command_names(app_name, fragment.path)
        groups = _polarity_groups(verbs)
        lines.extend(
            [
                f"## {app_name} — `sol call {app_name}`",
                "",
            ]
        )
        if fragment.triggers:
            lines.extend([f"Triggers: {_format_list(fragment.triggers)}", ""])
        for label, key in (("Read", "read"), ("Write", "write"), ("Other", "other")):
            values = groups.get(key)
            if values:
                lines.extend([f"{label}: {_format_list(values)}", ""])
        lines.extend([f"Guidance: `{fragment.repo_path}`", ""])
    return "\n".join(lines).rstrip() + "\n"


def _render_journal(fragments: dict[str, Fragment]) -> str:
    lines = [
        _BANNER,
        "",
        "# Journal Router Commands",
        "",
        "Generated app-contributed command guidance for the `journal` router.",
        "",
    ]
    for app_name in sorted(JOURNAL_NAMESPACE_CONTRIBUTIONS):
        fragment = fragments.get(app_name)
        if fragment is None:
            raise ValueError(
                f"{app_name}: journal namespace contribution has no fragment"
            )
        namespaces = JOURNAL_NAMESPACE_CONTRIBUTIONS[app_name]
        formatted_namespaces = ", ".join(f"`{namespace}`" for namespace in namespaces)
        lines.extend(
            [
                f"## {app_name} — {formatted_namespaces}",
                "",
            ]
        )
        if fragment.triggers:
            lines.extend([f"Triggers: {_format_list(fragment.triggers)}", ""])
        lines.extend([f"Guidance: `{fragment.repo_path}`", ""])
    return "\n".join(lines).rstrip() + "\n"


def render() -> dict[str, str]:
    """Render both generated reference files fully in memory."""
    fragments = _load_fragments()
    return {
        str(_absolute(SOL_COMMANDS_PATH)): _render_sol(fragments),
        str(_absolute(JOURNAL_COMMANDS_PATH)): _render_journal(fragments),
    }


def _atomic_write_text(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, temp_path = tempfile.mkstemp(dir=path.parent, prefix=".tmp_", suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as out_file:
            out_file.write(content)
        os.replace(temp_path, path)
    except Exception:
        try:
            os.unlink(temp_path)
        except Exception:
            pass
        raise


def build() -> list[Path]:
    """Write generated reference files atomically and return written paths."""
    outputs = render()
    written: list[Path] = []
    for raw_path, content in outputs.items():
        path = Path(raw_path)
        _atomic_write_text(path, content)
        written.append(path)
    return written


def check() -> list[Path]:
    """Return generated reference paths whose on-disk content is stale."""
    stale: list[Path] = []
    for raw_path, content in render().items():
        path = Path(raw_path)
        try:
            current = path.read_text(encoding="utf-8")
        except FileNotFoundError:
            stale.append(path)
            continue
        if current != content:
            stale.append(path)
    return stale
