"""Characterization of a legacy module (brownfield Stage Zero).

On request the system **reconstructs a specification** for a legacy module and pins
the module's **current behavior** with characterization tests that serve as its
oracle. This is the safe way to bring un-specified code under the judiciary before it
is changed: write the spec it implicitly already satisfies, lock today's behavior, and
only then modify it.

The module is parsed statically (``ast``) — never executed at generation time — so
characterizing untrusted legacy code is safe. We emit:

* a minimal **spec stub** under the run-artifact base folder (``specs-src/``) with one requirement
  per public symbol, and
* a runnable **characterization test** that references those requirement IDs (so the
  spec-conformance gate traces them) and pins the public surface, with a
  clear place to add behavioral golden-master assertions.

Only Python is reconstructed today; other languages get the spec stub plus a
test-scaffold note (the AST extractor is the language-specific part).
"""

from __future__ import annotations

import ast
import os
import re
from dataclasses import dataclass, field
from pathlib import Path


@dataclass
class CharacterizeResult:
    spec_id: str
    spec_path: Path
    test_path: Path
    symbols: list[str] = field(default_factory=list)
    requirement_ids: list[str] = field(default_factory=list)


def _spec_id_for(module_path: Path) -> str:
    """A stable, conformance-valid Spec ID from the module name (e.g. ``calc.py`` → ``CALC``)."""
    stem = re.sub(r"[^0-9A-Za-z]", "", module_path.stem).upper()
    return (stem or "LEGACY")[:16]


def public_symbols(source: str) -> list[str]:
    """Top-level public functions and classes, in source order (no leading underscore)."""
    tree = ast.parse(source)
    out: list[str] = []
    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            if not node.name.startswith("_"):
                out.append(node.name)
    return out


def _next_feature_number(specs_dir: Path) -> int:
    nums = []
    if specs_dir.is_dir():
        for d in specs_dir.iterdir():
            m = re.match(r"(\d+)-", d.name)
            if m:
                nums.append(int(m.group(1)))
    return (max(nums) + 1) if nums else 1


def _safe_ident(name: str) -> str:
    return re.sub(r"[^0-9A-Za-z]", "_", name)


def _render_spec(spec_id: str, module_rel: str, symbols: list[str]) -> tuple[str, list[str]]:
    req_ids: list[str] = []
    lines = [
        f"# Characterization Specification: {module_rel}",
        "",
        f"**Spec ID**: {spec_id}",
        "",
        "**Risk Tier**: Standard",
        "<!-- Reconstructed for a legacy module; raise the tier before hardening it. -->",
        "",
        "**Status**: Reconstructed",
        "",
        f"**Input**: Existing behavior of `{module_rel}`, reconstructed by `3pwr characterize`.",
        "",
        "---",
        "",
        "## Non-Goals *(mandatory)*",
        "",
        "- This spec does **not** define *desired* behavior; it freezes the module's "
        "**observed** behavior as an oracle so the code can be safely changed later.",
        "- Each requirement is a characterization placeholder — replace it with real intent "
        "when this module re-enters the legislature (Stage 1–2).",
        "",
        "## Requirements *(reconstructed — refine into EARS intent before changing the code)*",
        "",
        "### Functional Requirements",
        "",
    ]
    if not symbols:
        lines.append("- _(no public symbols found to characterize)_")
    for i, sym in enumerate(symbols, start=1):
        rid = f"{spec_id}-FR-{i:03d}"
        req_ids.append(rid)
        lines.append(
            f"- **{rid}**: The system shall preserve the observed behavior of `{sym}` "
            f"in `{module_rel}` until a new requirement supersedes it."
        )
    lines.append("")
    return "\n".join(lines), req_ids


def _render_python_test(
    spec_id: str, module_rel: str, module_load_rel: str, symbols: list[str], req_ids: list[str]
) -> str:
    head = f'''"""Characterization tests for {module_rel} — pin CURRENT behavior as the oracle.

Generated by `3pwr characterize`. Each test references its reconstructed
requirement ID so the spec-conformance gate traces it. These pin the module's public
surface; add behavioral golden-master assertions (call each symbol with representative
inputs and assert today's return value) before you change {module_rel}.
"""

from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest

# Path to the module under characterization, relative to THIS test file.
_MODULE = (Path(__file__).resolve().parent / "{module_load_rel}").resolve()


def _load():
    spec = importlib.util.spec_from_file_location("_characterized", _MODULE)
    if spec is None or spec.loader is None:
        pytest.skip(f"cannot load module under characterization: {{_MODULE}}")
    module = importlib.util.module_from_spec(spec)
    try:
        spec.loader.exec_module(module)
    except Exception as exc:  # legacy import deps absent → surface as skip, never a silent pass
        pytest.skip(f"module under characterization did not import: {{exc}}")
    return module
'''
    body = []
    for sym, rid in zip(symbols, req_ids):
        fn = f"test_{_safe_ident(rid)}_{_safe_ident(sym)}_present"
        body.append(
            f'''

def {fn}():
    """{rid}: `{sym}` is part of {module_rel}'s characterized public surface."""
    module = _load()
    assert hasattr(module, "{sym}"), "{sym} removed/renamed — observed behavior changed"
    # TODO(characterize): assert {sym}(<representative inputs>) == <current output>.'''
        )
    if not symbols:
        body.append(
            '''

def test_module_under_characterization_exists():
    """No public symbols were found to characterize; add cases as the module grows."""
    assert _MODULE.exists()'''
        )
    return head + "".join(body) + "\n"


def characterize_module(
    repo_root: Path,
    module_path: Path,
    *,
    specs_dir: Path,
    tests_dir: Path,
) -> CharacterizeResult:
    """Reconstruct a spec stub + characterization tests for one file ``module_path``.

    For a directory, use :func:`characterize_path`, which walks it and calls this per source file."""
    module_path = module_path.resolve()
    if not module_path.is_file():
        raise FileNotFoundError(
            f"module not found: {module_path} — expected a source file (e.g. "
            "`--module src/foo.py`) or a directory to walk"
        )
    try:
        module_rel = str(module_path.relative_to(repo_root.resolve()))
    except ValueError:
        module_rel = module_path.name

    spec_id = _spec_id_for(module_path)
    source = module_path.read_text(encoding="utf-8")
    is_python = module_path.suffix == ".py"
    symbols = public_symbols(source) if is_python else []

    spec_md, req_ids = _render_spec(spec_id, module_rel, symbols)

    # Reuse an existing characterization spec dir for this module if present (idempotent).
    slug = f"{_safe_ident(module_path.stem).lower()}-characterization"
    existing = next((d for d in specs_dir.glob(f"*-{slug}") if d.is_dir()), None)
    feature_dir = existing or specs_dir / f"{_next_feature_number(specs_dir):03d}-{slug}"
    feature_dir.mkdir(parents=True, exist_ok=True)
    spec_path = feature_dir / "spec.md"
    spec_path.write_text(spec_md, encoding="utf-8")

    char_dir = tests_dir / "characterization"
    char_dir.mkdir(parents=True, exist_ok=True)
    test_path = char_dir / f"test_{_safe_ident(module_path.stem)}_characterization.py"
    if is_python:
        module_load_rel = os.path.relpath(module_path, char_dir).replace(os.sep, "/")
        test_path.write_text(
            _render_python_test(spec_id, module_rel, module_load_rel, symbols, req_ids),
            encoding="utf-8",
        )
    else:
        test_path = test_path.with_suffix(".md")
        test_path.write_text(
            f"# Characterization scaffold for {module_rel}\n\n"
            f"Spec `{spec_id}` reconstructed. Add characterization tests in the target "
            f"language that reference {', '.join(req_ids) or 'the reconstructed requirement IDs'} "
            f"and pin current behavior.\n",
            encoding="utf-8",
        )

    return CharacterizeResult(
        spec_id=spec_id,
        spec_path=spec_path,
        test_path=test_path,
        symbols=symbols,
        requirement_ids=req_ids,
    )


# Source extensions the directory walk characterizes; the AST reconstruction is Python-only, other
# languages get the spec stub + a scaffold note (see characterize_module).
_SOURCE_EXTS = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".rb"}
# Dirs never worth characterizing — vendored deps, build output, tool caches, the trust spine itself.
_SKIP_DIRS = {
    ".3powers",
    "node_modules",
    "__pycache__",
    "venv",
    ".venv",
    "dist",
    "build",
    "vendor",
    "target",
    "site-packages",
    ".tox",
    ".mypy_cache",
    ".pytest_cache",
    ".ruff_cache",
}


def _is_test_file(path: Path) -> bool:
    """A test file, not production code to characterize (pinning a test would be circular)."""
    name = path.name
    return (
        name.startswith("test_")
        or name.endswith(f"_test{path.suffix}")
        or ".test." in name
        or ".spec." in name
    )


def iter_source_files(directory: Path) -> list[Path]:
    """Production source files under ``directory``, skipping vendored/hidden dirs and tests."""
    out: list[Path] = []
    for dirpath, dirnames, filenames in os.walk(directory):
        dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in _SKIP_DIRS]
        for fn in filenames:
            p = Path(dirpath) / fn
            if p.suffix in _SOURCE_EXTS and not _is_test_file(p):
                out.append(p)
    return sorted(out)


def characterize_path(
    repo_root: Path,
    target: Path,
    *,
    specs_dir: Path,
    tests_dir: Path | None = None,
) -> list[CharacterizeResult]:
    """Characterize a single file OR every source file under a directory.

    A file yields one result; a directory is walked (:func:`iter_source_files`) and each source file
    characterized, with its tests defaulting alongside it when ``tests_dir`` is not pinned."""
    target = target.resolve()
    if target.is_dir():
        files = iter_source_files(target)
        if not files:
            raise FileNotFoundError(f"no source files to characterize under: {target}")
        return [
            characterize_module(repo_root, f, specs_dir=specs_dir, tests_dir=tests_dir or f.parent)
            for f in files
        ]
    return [
        characterize_module(
            repo_root, target, specs_dir=specs_dir, tests_dir=tests_dir or target.parent
        )
    ]
