"""Validator for final-report.md produced by the improvement-discovery phase.

Enforces the 11-item contract in
docs/superpowers/specs/2026-05-21-improvement-discovery-task-type-design.md §6.5.

Called by validators/validate-run.py when task_type == "improvement-discovery".
"""
from __future__ import annotations

import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path

# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
# insert whichever exists so okstra_ctl is importable directly.
_VALIDATORS_DIR = Path(__file__).resolve().parent
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
    if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
        sys.path.insert(0, str(_ssot_dir))

from okstra_ctl.improvement_lenses import (
    LENSES,
    DEFAULT_CANDIDATE_CAP,
    ABSOLUTE_CANDIDATE_CAP,
    SOURCE_WORKERS,
)
from okstra_ctl.final_report_schema import (
    load_schema_version,
    validate as validate_final_report_data,
)
from okstra_ctl.md_table import split_pipe_row


_VERDICT_TOKENS = ("candidates-ready", "no-candidates", "blocked")
_NEXT_PHASES = (
    "requirements-discovery",
    "implementation-option-selection",
    "error-analysis",
)
_CAND_ID_RE = re.compile(r"^I-\d{3}$")
_SOURCE_WORKER_RE = re.compile(r"^([a-z-]+):([A-Za-z0-9._-]+)$")
_CONSENSUS_VALUES = ("full", "partial", "contested", "worker-unique")
# The index/anchor post-pass (okstra-inject-report-index.py) injects an empty
# scroll anchor into ID-defining first cells (`<a id="i-001"></a>I-001`). Strip
# it during cell normalization so `_CAND_ID_RE` still matches the bare `I-NNN`.
_CELL_ANCHOR_RE = re.compile(r'<a id="[^"]*"></a>')


@dataclass
class ValidationResult:
    ok: bool
    errors: list[str] = field(default_factory=list)


def _read_section_table(body: str, heading: str) -> list[list[str]]:
    """Return rows of the markdown pipe-table directly under ``heading``.
    Each row is a list of trimmed cell values. Returns [] if heading absent
    or no table follows.
    """
    pattern = rf"(?m)^##\s+{re.escape(heading)}\b.*?\n(.*?)(?=^##\s|\Z)"
    m = re.search(pattern, body, flags=re.S)
    if not m:
        return []
    section = m.group(1)
    rows: list[list[str]] = []
    for line in section.splitlines():
        s = line.strip()
        if not s.startswith("|") or not s.endswith("|"):
            continue
        cells = [_CELL_ANCHOR_RE.sub("", c).strip() for c in split_pipe_row(s)]
        if all(set(c) <= set("-: ") for c in cells):
            continue
        rows.append(cells)
    return rows


def _candidate_cap(brief_frontmatter: dict) -> int:
    raw = brief_frontmatter.get("candidate-cap")
    if raw is None:
        return DEFAULT_CANDIDATE_CAP
    try:
        return int(raw)
    except (TypeError, ValueError):
        return DEFAULT_CANDIDATE_CAP


def _as_path_list(value) -> list[str]:
    """Normalize a frontmatter scope field to a list of path strings.

    ``_parse_brief_frontmatter`` only returns a list for inline-flow ``[a, b]``
    syntax; a scalar ``scan-scope: src/`` arrives as a bare string. Iterating a
    string yields characters, so coerce a scalar into a single-element list.
    """
    if isinstance(value, str):
        return [value] if value.strip() else []
    return list(value)


def _scope_subset(
    candidate_scope_csv: str,
    scan_scope: list[str],
    out_of_scope: list[str],
) -> tuple[bool, str]:
    scan_scope = _as_path_list(scan_scope)
    out_of_scope = _as_path_list(out_of_scope)
    paths = [p.strip() for p in candidate_scope_csv.split(",") if p.strip()]
    if not paths:
        return False, "empty Scope"
    for p in paths:
        if any(p == s or p.startswith(s.rstrip("/") + "/") for s in scan_scope):
            for o in out_of_scope:
                if p == o or p.startswith(o.rstrip("/") + "/"):
                    return False, f"Scope '{p}' is inside out-of-scope '{o}'"
            continue
        return False, f"Scope '{p}' is outside brief scan-scope {scan_scope}"
    return True, ""


def _check_grilling_log(run_dir: Path, errors: list[str]) -> None:
    """Item 10 — phase-1.5-grilling.md must exist with resolved blocks."""
    grilling = run_dir / "state" / "phase-1.5-grilling.md"
    if not grilling.exists():
        errors.append("missing phase-1.5-grilling.md log at runs/.../state/")
        return
    gtext = grilling.read_text(encoding="utf-8")
    if "Resolved scope" not in gtext:
        errors.append("phase-1.5-grilling.md missing 'Resolved scope' block")
    if "Resolved lenses" not in gtext:
        errors.append("phase-1.5-grilling.md missing 'Resolved lenses' block")


def _check_candidates_cap(
    data_row_count: int, brief_frontmatter: dict, errors: list[str]
) -> int | None:
    """Item 5 — validate candidate-cap range and row count vs cap.

    Returns the validated cap, or None when the cap itself is out of range
    (in that case the caller must NOT also flag row-count-exceeds-cap).
    """
    cap = _candidate_cap(brief_frontmatter)
    if cap < 1 or cap > ABSOLUTE_CANDIDATE_CAP:
        errors.append(
            f"brief candidate-cap {cap} out of allowed range 1..{ABSOLUTE_CANDIDATE_CAP}"
        )
        return None
    if data_row_count > cap:
        errors.append(f"row count {data_row_count} exceeds candidate-cap {cap}")
    return cap


def _check_row_sources_and_consensus(
    idx: int,
    source_workers: str,
    consensus: str,
    errors: list[str],
) -> None:
    """Item 6 + Consensus enum + single-source → worker-unique invariant."""
    workers_in_row: list[str] = []
    for token in source_workers.split(","):
        token = token.strip()
        if not token:
            continue
        m = _SOURCE_WORKER_RE.match(token)
        if not m:
            errors.append(f"row {idx}: Source workers token '{token}' must match <worker>:<id>")
            continue
        worker, _item = m.group(1), m.group(2)
        if worker not in SOURCE_WORKERS:
            errors.append(
                f"row {idx}: Source workers '{worker}' is not in {SOURCE_WORKERS} (report-writer excluded)"
            )
        workers_in_row.append(worker)
    if not workers_in_row:
        errors.append(f"row {idx}: Source workers cell is empty")

    if consensus not in _CONSENSUS_VALUES:
        errors.append(f"row {idx}: Consensus '{consensus}' must be one of {_CONSENSUS_VALUES}")
    if len(set(workers_in_row)) == 1 and consensus != "worker-unique":
        errors.append(f"row {idx}: single-source-worker entries must use Consensus=worker-unique")


def _check_candidate_row(
    idx: int,
    row: list[str],
    scan_scope: list[str],
    out_of_scope: list[str],
    seen_ids: set[str],
    errors: list[str],
) -> None:
    """Items 2, 3, 4, 6, 7 — validate a single candidate row."""
    if len(row) != 11:
        errors.append(f"row {idx} has {len(row)} columns, expected 11")
        return
    (
        cand_id, lens_cell, _title, scope, _sev, _eff,
        consensus, source_workers, next_phase, expected_after, _evidence,
    ) = row

    if not _CAND_ID_RE.match(cand_id):
        errors.append(f"row {idx}: Cand ID '{cand_id}' must match I-NNN")
        return
    if cand_id in seen_ids:
        errors.append(f"row {idx}: duplicate Cand ID '{cand_id}'")
    seen_ids.add(cand_id)

    lenses_in_row = [ln.strip() for ln in lens_cell.split(",") if ln.strip()]
    for ln in lenses_in_row:
        if ln not in LENSES:
            errors.append(f"row {idx}: Lens '{ln}' is not in whitelist {LENSES}")
    if not (1 <= len(lenses_in_row) <= 2):
        errors.append(f"row {idx}: Lens cell must contain 1 or 2 values, got {len(lenses_in_row)}")

    ok, reason = _scope_subset(scope, scan_scope, out_of_scope)
    if not ok:
        errors.append(f"row {idx}: {reason}")

    _check_row_sources_and_consensus(idx, source_workers, consensus, errors)

    if next_phase not in _NEXT_PHASES:
        errors.append(f"row {idx}: Recommended next-phase '{next_phase}' must be one of {_NEXT_PHASES}")

    # A candidate nobody can describe an observable change for is not a finding;
    # it is a preference. Making this a cell rather than prose is what stops the
    # candidate list from growing past what a downstream brief can pin.
    if not expected_after.strip():
        errors.append(
            f"row {idx}: `Expected behavior after` is empty — state what becomes "
            "observably different once this candidate is applied, or drop the row"
        )


def _check_candidate_rows(
    data_rows: list[list[str]],
    scan_scope: list[str],
    out_of_scope: list[str],
    errors: list[str],
) -> None:
    """Items 2..7 — iterate over all data rows, accumulating seen IDs."""
    seen_ids: set[str] = set()
    for idx, row in enumerate(data_rows, start=1):
        _check_candidate_row(idx, row, scan_scope, out_of_scope, seen_ids, errors)


def _check_candidates_table(
    body: str, brief_frontmatter: dict, errors: list[str]
) -> None:
    """Items 1–7 coordinator — parse table, check header, delegate cap and rows."""
    rows = _read_section_table(body, "5.9 Improvement Candidates")
    if not rows:
        errors.append("missing or empty `## 5.9 Improvement Candidates` table")
        return
    header, *data = rows
    expected_columns = [
        "Cand ID", "Lens", "Title", "Scope", "Severity", "Effort",
        "Consensus", "Source workers", "Recommended next-phase",
        "Expected behavior after", "Evidence",
    ]
    if header != expected_columns:
        errors.append(
            f"`## 5.9 Improvement Candidates` header must be {expected_columns}, "
            f"got {header}"
        )
    _check_candidates_cap(len(data), brief_frontmatter, errors)
    scan_scope = brief_frontmatter.get("scan-scope") or []
    out_of_scope = brief_frontmatter.get("out-of-scope") or []
    _check_candidate_rows(data, scan_scope, out_of_scope, errors)


def _check_final_verdict(
    body: str, errors: list[str]
) -> list[list[str]]:
    """Item 8 — ## 7. Final Verdict verdict token is in enum.

    Returns the parsed final_verdict_rows (empty list when absent).
    """
    final_verdict_rows = _read_section_table(body, "7. Final Verdict")
    if not final_verdict_rows:
        errors.append("missing `## 7. Final Verdict` block")
        return []
    token_cell = final_verdict_rows[-1][0] if final_verdict_rows[-1] else ""
    if token_cell not in _VERDICT_TOKENS:
        errors.append(
            f"`## 7. Final Verdict` Verdict Token '{token_cell}' must be one of {_VERDICT_TOKENS}"
        )
    return final_verdict_rows


def _check_verdict_card(
    body: str, final_verdict_rows: list[list[str]], errors: list[str]
) -> None:
    """Item 9 — Verdict Card must be present and byte-match Final Verdict row."""
    verdict_card_rows = _read_section_table(body, "Verdict Card")
    if not verdict_card_rows:
        errors.append("missing `## Verdict Card` block")
        return
    if final_verdict_rows and verdict_card_rows[-1] != final_verdict_rows[-1]:
        errors.append("Verdict Card row must byte-match `## 7. Final Verdict` row")


def _v2_candidate_rows(data: dict) -> list[list[str]]:
    candidates = data["improvementDiscovery"]["candidates"]
    return [
        [
            row["id"],
            ",".join(row["lens"]),
            row["title"],
            ",".join(row["scope"]),
            row["severity"],
            row["effort"],
            row["consensus"],
            ",".join(row["sourceWorkers"]),
            row["recommendedNextPhase"],
            row["expectedBehaviorAfter"],
            ",".join(row["evidence"]),
        ]
        for row in candidates
    ]


def _validate_v2_report(
    data: dict,
    run_dir: Path,
    brief_frontmatter: dict,
) -> ValidationResult:
    errors = validate_final_report_data(data, load_schema_version("2.0"))
    _check_grilling_log(run_dir, errors)
    if errors:
        return ValidationResult(ok=False, errors=errors)

    rows = _v2_candidate_rows(data)
    _check_candidates_cap(len(rows), brief_frontmatter, errors)
    _check_candidate_rows(
        rows,
        brief_frontmatter.get("scan-scope") or [],
        brief_frontmatter.get("out-of-scope") or [],
        errors,
    )
    return ValidationResult(ok=not errors, errors=errors)


def validate_improvement_report(
    report_path: Path, run_dir: Path, brief_frontmatter: dict
) -> ValidationResult:
    errors: list[str] = []

    if not report_path.exists():
        errors.append(f"report file not found: {report_path}")
        return ValidationResult(ok=False, errors=errors)

    data_path = report_path.with_suffix(".data.json")
    if data_path.exists():
        try:
            data = json.loads(data_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            return ValidationResult(ok=False, errors=[f"invalid report data: {exc}"])
        if data.get("schemaVersion") == "2.0":
            return _validate_v2_report(data, run_dir, brief_frontmatter)

    body = report_path.read_text(encoding="utf-8")

    _check_grilling_log(run_dir, errors)
    _check_candidates_table(body, brief_frontmatter, errors)
    final_verdict_rows = _check_final_verdict(body, errors)
    _check_verdict_card(body, final_verdict_rows, errors)

    return ValidationResult(ok=not errors, errors=errors)
