#!/usr/bin/env python3
"""
US-ONBOARD-017: render the three Phase 2 analysis sections of onboard-plan.yaml
(domain_model / tech_analysis / test_assessment, produced by US-ONBOARD-016)
into human-readable markdown inside the onboarded project, and surface the
BACKLOG / FIX seed candidates for bash to gate behind a [Y/n] confirm.

This script NEVER writes to BACKLOG and NEVER touches the offboard changeset.
Those mutations stay in bash (bin/roll `_init_apply`) so the confirm gate and
the rollback registry remain the single source of truth. This script only:

  1. Renders three markdown files into <project_dir>/.roll/ , deterministically
     (same plan.yaml -> byte-identical markdown; no wall-clock timestamps or
     random IDs are embedded in the body).
  2. Emits a machine-parseable manifest on stdout describing exactly what it
     wrote and what could be seeded, in a pipe-delimited line format that bash
     parses with `IFS='|' read` (no jq dependency):

        FILE|.roll/domain/context-map.md
        FILE|.roll/tech-analysis.md
        FILE|.roll/test-assessment.md
        SEED|US-SEED-001|add a macOS CI runner
        FIX|FIX-SEED-001|no automated test run on macOS bash 3.2

     `FILE|` lines are project-relative paths bash records into the changeset's
     `files_created` (so `roll offboard` removes them). `SEED|` / `FIX|` lines
     are candidates; bash prints a preview and only writes them on explicit
     confirmation.

Atomicity (per peer review): all three files are staged to temp paths inside
the project dir and atomically renamed into place. If rendering any file fails,
nothing is left half-written and no manifest is emitted, so bash records no
changeset entries for files that do not exist.

Usage:
    python3 roll-onboard-render.py <plan.yaml> <project_dir>

Exit codes:
    0   rendered OK (manifest on stdout)
    1   plan unreadable / not a mapping / render failure
    2   plan has none of the three sections (nothing to render; no-op)
"""

from __future__ import annotations

import os
import sys
import tempfile
from pathlib import Path

try:
    import yaml  # PyYAML
except ImportError:
    print(
        "[onboard-render] PyYAML not installed. Install with: pip install pyyaml\n"
        "[onboard-render] PyYAML 未安装，请运行: pip install pyyaml",
        file=sys.stderr,
    )
    sys.exit(1)


# Project-relative output paths (the AC fixes these three exact locations).
CONTEXT_MAP_REL = ".roll/domain/context-map.md"
TECH_ANALYSIS_REL = ".roll/tech-analysis.md"
TEST_ASSESSMENT_REL = ".roll/test-assessment.md"

# HIGH-severity risks are the only ones eligible to seed as FIX candidates.
HIGH_SEVERITY = "HIGH"


def _as_list(value) -> list:
    """Coerce a possibly-missing field into a list, preserving order.

    A scalar becomes a one-item list; None/missing becomes []. Never sorts —
    determinism comes from honouring the author's order in the plan.
    """
    if value is None:
        return []
    if isinstance(value, list):
        return value
    return [value]


def _term_text(term) -> str:
    """Render a ubiquitous_language entry, which may be a bare string or a
    {term, definition} mapping (the schema allows both)."""
    if isinstance(term, dict):
        name = str(term.get("term", "")).strip()
        definition = str(term.get("definition", "")).strip()
        if name and definition:
            return f"{name} — {definition}"
        return name or definition
    return str(term).strip()


def _claim_text(claim) -> tuple[str, str]:
    """Return (text, evidence) for a test_assessment claim.

    Claims are validated upstream as mappings carrying an `evidence` tag, but we
    stay defensive so a malformed-but-parseable plan still renders rather than
    crashing.
    """
    if isinstance(claim, dict):
        text = str(claim.get("claim", "")).strip()
        evidence = str(claim.get("evidence", "")).strip()
        return text, evidence
    return str(claim).strip(), ""


def render_context_map(domain_model: dict | None) -> str:
    """Render .roll/domain/context-map.md from domain_model. Deterministic."""
    lines: list[str] = ["# Domain Context Map", ""]
    lines.append(
        "> Generated by `roll init --apply` from `.roll/onboard-plan.yaml` "
        "(US-ONBOARD-016/017). Regenerate by re-running onboard."
    )
    lines.append("")
    contexts = _as_list((domain_model or {}).get("bounded_contexts"))
    if not contexts:
        lines.append("_No bounded contexts were inferred from the codebase._")
        lines.append("")
        return "\n".join(lines) + "\n"

    for ctx in contexts:
        if not isinstance(ctx, dict):
            continue
        name = str(ctx.get("name", "")).strip() or "(unnamed context)"
        lines.append(f"## {name}")
        lines.append("")
        aggregates = _as_list(ctx.get("aggregates"))
        lines.append("**Aggregates**")
        lines.append("")
        if aggregates:
            for agg in aggregates:
                lines.append(f"- {str(agg).strip()}")
        else:
            lines.append("- _none identified_")
        lines.append("")
        language = _as_list(ctx.get("ubiquitous_language"))
        lines.append("**Ubiquitous language**")
        lines.append("")
        if language:
            for term in language:
                text = _term_text(term)
                if text:
                    lines.append(f"- {text}")
        else:
            lines.append("- _none identified_")
        lines.append("")
    return "\n".join(lines) + "\n"


def render_tech_analysis(tech: dict | None) -> str:
    """Render .roll/tech-analysis.md from tech_analysis. Deterministic."""
    lines: list[str] = ["# Technical Analysis", ""]
    lines.append(
        "> Generated by `roll init --apply` from `.roll/onboard-plan.yaml` "
        "(US-ONBOARD-016/017). Regenerate by re-running onboard."
    )
    lines.append("")
    tech = tech or {}

    def _bullet_section(title: str, key: str) -> None:
        lines.append(f"## {title}")
        lines.append("")
        items = _as_list(tech.get(key))
        if items:
            for item in items:
                lines.append(f"- {str(item).strip()}")
        else:
            lines.append("- _none detected_")
        lines.append("")

    _bullet_section("Stack", "stack")
    _bullet_section("Dependencies", "dependencies")
    _bullet_section("Architecture notes", "architecture_notes")

    lines.append("## Risks")
    lines.append("")
    risks = _as_list(tech.get("risks"))
    if not risks:
        lines.append("- _none detected_")
        lines.append("")
        return "\n".join(lines) + "\n"

    for risk in risks:
        if not isinstance(risk, dict):
            lines.append(f"- {str(risk).strip()}")
            continue
        desc = str(risk.get("description", "")).strip()
        severity = str(risk.get("severity", "")).strip()
        evidence = str(risk.get("evidence", "")).strip()
        tags = []
        if severity:
            tags.append(f"severity: {severity}")
        if evidence:
            tags.append(f"evidence: {evidence}")
        suffix = f" ({', '.join(tags)})" if tags else ""
        lines.append(f"- {desc}{suffix}")
    lines.append("")
    return "\n".join(lines) + "\n"


def render_test_assessment(test: dict | None) -> str:
    """Render .roll/test-assessment.md from test_assessment. Deterministic.

    Distinguishes `detected` (a real scan finding) from `inferred` (a judgement)
    by labelling each bullet, honouring US-ONBOARD-016's evidence contract.
    """
    lines: list[str] = ["# Test Coverage Assessment", ""]
    lines.append(
        "> Generated by `roll init --apply` from `.roll/onboard-plan.yaml` "
        "(US-ONBOARD-016/017). Every claim is evidence-tagged: **detected** "
        "(found by a filesystem scan) or **inferred** (a judgement traceable to "
        "a detected fact)."
    )
    lines.append("")
    test = test or {}

    def _claims_section(title: str, key: str) -> None:
        lines.append(f"## {title}")
        lines.append("")
        claims = _as_list(test.get(key))
        if claims:
            for claim in claims:
                text, evidence = _claim_text(claim)
                if not text:
                    continue
                if evidence:
                    lines.append(f"- {text} _(evidence: {evidence})_")
                else:
                    lines.append(f"- {text}")
        else:
            lines.append("- _none recorded_")
        lines.append("")

    _claims_section("Current layers", "current_layers")
    _claims_section("Gaps", "gaps")
    _claims_section("Recommended actions", "recommended_actions")
    return "\n".join(lines) + "\n"


def collect_seed_candidates(plan: dict) -> tuple[list[str], list[str]]:
    """Return (story_titles, fix_titles) in plan order.

    Stories come from test_assessment.recommended_actions (each becomes a
    candidate BACKLOG story). FIX candidates come from tech_analysis.risks whose
    severity == HIGH. Titles are the human-readable claim/description text;
    bash assigns the deterministic US-SEED-NNN / FIX-SEED-NNN ids.
    """
    stories: list[str] = []
    test = plan.get("test_assessment") or {}
    for claim in _as_list(test.get("recommended_actions")):
        text, _ = _claim_text(claim)
        if text and text.lower() != "none detected":
            stories.append(text)

    fixes: list[str] = []
    tech = plan.get("tech_analysis") or {}
    for risk in _as_list(tech.get("risks")):
        if not isinstance(risk, dict):
            continue
        if str(risk.get("severity", "")).strip().upper() == HIGH_SEVERITY:
            desc = str(risk.get("description", "")).strip()
            if desc:
                fixes.append(desc)
    return stories, fixes


def _atomic_write(project_dir: Path, rel: str, content: str) -> None:
    """Write content to <project_dir>/<rel> atomically (temp + os.replace).

    The temp file is created in the same directory as the target so the rename
    is atomic on the same filesystem.
    """
    target = project_dir / rel
    target.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp_name = tempfile.mkstemp(
        prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
    )
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(content)
        os.replace(tmp_name, str(target))
    except BaseException:
        # Clean up the temp file on any failure so we never leak half-writes.
        try:
            os.unlink(tmp_name)
        except OSError:
            pass
        raise


def _emit(line: str) -> None:
    sys.stdout.write(line + "\n")


def main(argv: list[str]) -> int:
    if len(argv) < 3:
        print(
            "[onboard-render] usage: roll-onboard-render.py <plan.yaml> <project_dir>",
            file=sys.stderr,
        )
        return 1

    plan_path = Path(argv[1])
    project_dir = Path(argv[2])

    if not plan_path.is_file():
        print(f"[onboard-render] plan not found: {plan_path}", file=sys.stderr)
        return 1
    if not project_dir.is_dir():
        print(
            f"[onboard-render] project dir not found: {project_dir}", file=sys.stderr
        )
        return 1

    try:
        with plan_path.open("r", encoding="utf-8") as f:
            plan = yaml.safe_load(f)
    except (yaml.YAMLError, OSError) as e:
        print(f"[onboard-render] failed to parse plan: {e}", file=sys.stderr)
        return 1

    if not isinstance(plan, dict):
        print("[onboard-render] plan must be a top-level mapping", file=sys.stderr)
        return 1

    has_dm = isinstance(plan.get("domain_model"), dict)
    has_ta = isinstance(plan.get("tech_analysis"), dict)
    has_test = isinstance(plan.get("test_assessment"), dict)
    if not (has_dm or has_ta or has_test):
        # No Phase 2 content at all — nothing to render. Bash treats exit 2 as a
        # clean no-op (an old plan, or a minimal onboard).
        return 2

    # Render bodies first (pure, in-memory) so a render bug fails before we
    # touch the filesystem at all.
    try:
        bodies = [
            (CONTEXT_MAP_REL, render_context_map(plan.get("domain_model"))),
            (TECH_ANALYSIS_REL, render_tech_analysis(plan.get("tech_analysis"))),
            (TEST_ASSESSMENT_REL, render_test_assessment(plan.get("test_assessment"))),
        ]
    except Exception as e:  # pragma: no cover - defensive
        print(f"[onboard-render] render failed: {e}", file=sys.stderr)
        return 1

    # Stage + atomically write all three. If any write fails, roll back the
    # ones already renamed so we never leave partial state.
    written: list[str] = []
    try:
        for rel, body in bodies:
            _atomic_write(project_dir, rel, body)
            written.append(rel)
    except OSError as e:
        for rel in written:
            try:
                os.unlink(str(project_dir / rel))
            except OSError:
                pass
        print(f"[onboard-render] write failed: {e}", file=sys.stderr)
        return 1

    stories, fixes = collect_seed_candidates(plan)

    # Emit the manifest only after every file is on disk.
    for rel, _ in bodies:
        _emit(f"FILE|{rel}")
    for i, title in enumerate(stories, start=1):
        _emit(f"SEED|US-SEED-{i:03d}|{title}")
    for i, desc in enumerate(fixes, start=1):
        _emit(f"FIX|FIX-SEED-{i:03d}|{desc}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
