#!/usr/bin/env python3
"""
Build a cross-surface API manifest for ferro-ta.

The generated manifest summarizes:
- Python indicator/method exposure (from ferro_ta.tools.api_info)
- Core Rust crate public functions (ferro_ta_core)
- WASM/Node exported functions (from wasm pkg d.ts)

Output is written to `docs/api_manifest.json`.
"""

from __future__ import annotations

import argparse
import ast
import datetime as _dt
import importlib.util
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any


def _repo_root() -> Path:
    return Path(__file__).resolve().parents[1]


def _load_api_info_module(root: Path, module_path: Path):
    python_root = str(root / "python")
    if python_root not in sys.path:
        sys.path.insert(0, python_root)
    spec = importlib.util.spec_from_file_location(
        "ferro_ta_tools_api_info", module_path
    )
    if spec is None or spec.loader is None:
        raise RuntimeError(f"Could not load module spec from {module_path}")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)  # type: ignore[assignment]
    return module


def _module_file(root: Path, module_name: str) -> Path | None:
    module_rel = module_name.replace(".", "/")
    file_path = root / "python" / f"{module_rel}.py"
    if file_path.exists():
        return file_path
    init_path = root / "python" / module_rel / "__init__.py"
    if init_path.exists():
        return init_path
    return None


def _extract_dunder_all(file_path: Path) -> list[str]:
    try:
        source = file_path.read_text(encoding="utf-8")
        tree = ast.parse(source, filename=str(file_path))
    except Exception:
        return []

    exports: list[str] = []
    for node in tree.body:
        value_node = None
        if isinstance(node, ast.Assign):
            for target in node.targets:
                if isinstance(target, ast.Name) and target.id == "__all__":
                    value_node = node.value
                    break
        elif isinstance(node, ast.AnnAssign):
            target = node.target
            if isinstance(target, ast.Name) and target.id == "__all__":
                value_node = node.value
        if value_node is None:
            continue
        try:
            value = ast.literal_eval(value_node)
        except Exception:
            continue
        if isinstance(value, str):
            exports = [value]
        elif isinstance(value, (list, tuple)):
            exports = [item for item in value if isinstance(item, str)]
    return exports


def _module_exports(root: Path, module_name: str) -> list[str]:
    file_path = _module_file(root, module_name)
    if file_path is None:
        return []
    return _extract_dunder_all(file_path)


def _extract_python_api(root: Path) -> dict[str, Any]:
    module_path = root / "python" / "ferro_ta" / "tools" / "api_info.py"
    api_info_module = _load_api_info_module(root, module_path)

    category_modules = dict(getattr(api_info_module, "_CATEGORY_MODULES", {}))
    method_modules = dict(getattr(api_info_module, "_METHOD_MODULES", {}))

    indicators: list[dict[str, Any]] = []
    seen_indicators: set[str] = set()
    for category, module_name in category_modules.items():
        for name in _module_exports(root, module_name):
            if name in seen_indicators:
                continue
            seen_indicators.add(name)
            indicators.append(
                {
                    "name": name,
                    "category": category,
                    "module": module_name,
                    "doc": "",
                    "params": [],
                }
            )

    methods: list[dict[str, Any]] = []
    seen_methods: set[tuple[str, str]] = set()
    for category, module_name in method_modules.items():
        for name in _module_exports(root, module_name):
            key = (module_name, name)
            if key in seen_methods:
                continue
            seen_methods.add(key)
            methods.append(
                {
                    "name": name,
                    "category": category,
                    "module": module_name,
                    "doc": "",
                    "params": [],
                }
            )

    indicators.sort(key=lambda entry: entry["name"])
    methods.sort(key=lambda entry: (entry["category"], entry["name"]))

    categories = sorted({entry["category"] for entry in indicators})

    if not indicators:
        raise RuntimeError(
            "No Python indicators discovered from source exports. "
            "Check `python/ferro_ta/tools/api_info.py` mappings and module __all__ declarations."
        )

    return {
        "indicator_count": len(indicators),
        "method_count": len(methods),
        "categories": categories,
        "indicators": indicators,
        "methods": methods,
    }


def _extract_core_exports(root: Path) -> list[dict[str, str]]:
    core_src = root / "crates" / "ferro_ta_core" / "src"
    entries: list[dict[str, str]] = []

    for rs_file in sorted(core_src.rglob("*.rs")):
        rel = rs_file.relative_to(core_src).as_posix()
        module = rel[:-3].replace("/", ".")
        text = rs_file.read_text(encoding="utf-8")
        for match in re.finditer(r"(?m)^\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(", text):
            entries.append(
                {
                    "module": module,
                    "function": match.group(1),
                    "file": rel,
                }
            )

    entries.sort(key=lambda item: (item["module"], item["function"]))
    return entries


def _extract_wasm_exports(root: Path) -> list[str]:
    exports: set[str] = set()

    # Source exports are the canonical declaration of the WASM/Node API and
    # avoid drift when a stale wasm/pkg folder is present locally.
    wasm_lib = root / "wasm" / "src" / "lib.rs"
    if wasm_lib.exists():
        text = wasm_lib.read_text(encoding="utf-8")
        for match in re.finditer(
            r"(?ms)#\s*\[wasm_bindgen(?:\([^\)]*\))?\]\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(",
            text,
        ):
            exports.add(match.group(1))
        if exports:
            return sorted(exports)

    # Fallback to generated declarations if source parsing did not find exports.
    dts_path = root / "wasm" / "node" / "ferro_ta_wasm.d.ts"
    if dts_path.exists():
        for line in dts_path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if line.startswith("export function "):
                name = line[len("export function ") :].split("(")[0].strip()
                if name:
                    exports.add(name)

    return sorted(exports)


def _safe_git_head(root: Path) -> str | None:
    try:
        completed = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=root,
            capture_output=True,
            text=True,
            check=True,
        )
    except (subprocess.CalledProcessError, FileNotFoundError):
        return None
    value = completed.stdout.strip()
    return value or None


def build_manifest(
    root: Path, include_runtime_metadata: bool = False
) -> dict[str, Any]:
    python_api = _extract_python_api(root)
    rust_core = _extract_core_exports(root)
    wasm_exports = _extract_wasm_exports(root)

    python_indicator_names = {entry["name"] for entry in python_api["indicators"]}
    python_indicator_names_lc = {name.lower() for name in python_indicator_names}
    wasm_set = set(wasm_exports)
    wasm_set_lc = {name.lower() for name in wasm_set}
    common_with_wasm = sorted(python_indicator_names_lc.intersection(wasm_set_lc))

    manifest: dict[str, Any] = {
        "surfaces": {
            "python": python_api,
            "rust_core": {
                "public_function_count": len(rust_core),
                "functions": rust_core,
            },
            "wasm_node": {
                "export_count": len(wasm_exports),
                "exports": wasm_exports,
            },
        },
        "parity_summary": {
            "python_indicator_count": len(python_indicator_names_lc),
            "wasm_export_count": len(wasm_set),
            "common_python_wasm_count": len(common_with_wasm),
            "common_python_wasm": common_with_wasm,
            "python_only_vs_wasm": sorted(python_indicator_names_lc - wasm_set_lc),
            "wasm_only_vs_python": sorted(wasm_set_lc - python_indicator_names_lc),
        },
    }

    if include_runtime_metadata:
        manifest["generated_at_utc"] = _dt.datetime.now(tz=_dt.UTC).isoformat()
        manifest["git_head"] = _safe_git_head(root)

    return manifest


def main() -> None:
    parser = argparse.ArgumentParser(description="Build cross-surface API manifest")
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("docs/api_manifest.json"),
        help="Output JSON path relative to repo root (default: docs/api_manifest.json)",
    )
    parser.add_argument(
        "--include-runtime-metadata",
        action="store_true",
        help=(
            "Include non-deterministic metadata fields (timestamp, git head). "
            "Disabled by default to keep manifest reproducible for CI checks."
        ),
    )
    args = parser.parse_args()

    root = _repo_root()
    output_path = (root / args.output).resolve()
    output_path.parent.mkdir(parents=True, exist_ok=True)

    manifest = build_manifest(
        root, include_runtime_metadata=args.include_runtime_metadata
    )
    output_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
    print(f"Wrote API manifest to {output_path}")


if __name__ == "__main__":
    main()
