"""Round-0 grouping provenance: does every cited source item actually exist?

The lead mints its own finding IDs, but each ``sourceItems[].itemId`` and
``discoveredBy.<worker>.itemId`` is copied verbatim from a worker's result. A
grouping citing ``codex-worker:F-007`` when codex never wrote F-007 is a
fabricated provenance link, and the shapes it arrives in are all
transcription damage: a descriptive suffix (``F-105 FontLookupPort row``), two
ids comma-joined into one field, a section title, or a reverify verdict cited
as if it were a discovery.

This module is the single implementation. ``okstra convergence seed`` runs it
when the grouping is handed over — while the lead can still fix it — and
``validators/validate-run.py`` runs it again over the finished run, so a
grouping written out of band is still caught.
"""
from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Mapping

from okstra_ctl.convergence_engine import grouped_input_digest
from okstra_ctl.json_boundary import (
    load_owned_object,
)

GROUPS_BASENAME_RE = re.compile(
    r"^convergence-groups-(?P<suffix>[a-z][a-z-]*?-\d{3})\.json$"
)


def _nonempty_string(value: object) -> bool:
    return isinstance(value, str) and bool(value.strip())


def group_claims(document: object) -> list[tuple[str, str, str]] | None:
    """Extract `(findingId, worker, itemId)` provenance claims from a grouping.

    Returns ``None`` when the document does not match the convergence-groups
    schema shape closely enough to read its provenance fields safely — the
    caller refuses to judge such a file. Otherwise returns the deduplicated
    claims drawn from every group's ``sourceItems[]`` and ``discoveredBy`` map.
    """
    if not isinstance(document, dict) or not isinstance(document.get("groups"), list):
        return None
    claims: list[tuple[str, str, str]] = []
    seen: set[tuple[str, str, str]] = set()
    for group in document["groups"]:
        if not isinstance(group, dict):
            return None
        finding_id = group.get("findingId")
        source_items = group.get("sourceItems")
        discovered_by = group.get("discoveredBy")
        if (
            not _nonempty_string(finding_id)
            or not isinstance(source_items, list)
            or not isinstance(discovered_by, dict)
        ):
            return None
        pairs: list[tuple[object, object]] = []
        for item in source_items:
            if not isinstance(item, dict):
                return None
            pairs.append((item.get("worker"), item.get("itemId")))
        for worker, discovery in discovered_by.items():
            if not isinstance(discovery, dict):
                return None
            pairs.append((worker, discovery.get("itemId")))
        for worker, item_id in pairs:
            if not _nonempty_string(worker) or not _nonempty_string(item_id):
                return None
            claim = (finding_id, worker, item_id)
            if claim not in seen:
                seen.add(claim)
                claims.append(claim)
    return claims


def groups_digest_ok(state_dir: Path, suffix: str, document: dict) -> bool:
    """False only when a recorded groupsDigest exists and no longer matches.

    ``okstra convergence seed`` pins ``grouped_input_digest`` of the grouping
    into the working state. When that record is present and disagrees with the
    on-disk grouping, the file was tampered with or written out of band — not
    evidence of fabrication — so the caller refuses to judge it. A missing
    working state or missing digest means judge the file as parsed.
    """
    work_path = state_dir / f"convergence-work-{suffix}.json"
    try:
        work = load_owned_object(work_path, artifact="convergence work state")
    except (OSError, ValueError):
        return True
    recorded = work.get("groupsDigest") if isinstance(work, dict) else None
    if not _nonempty_string(recorded):
        return True
    return grouped_input_digest(document) == recorded


def read_canonical_worker_result(
    worker_results_dir: Path, worker: str, suffix: str
) -> str | None:
    """Return the `<worker>-worker-<suffix>.md` text, or ``None`` when it is not
    a resolvable file directly inside ``worker-results/`` (missing, unreadable,
    or a worker slug that is not a single path component).

    The literal `-worker` marker comes from the writer,
    `worker_artifacts.artifacts_from_context`, which names results
    `f"{worker_id}-worker{result_suffix}.md"` from a bare worker id. Dropping it
    here made every lookup miss once the roster moved to bare ids, and a missed
    lookup is silent by design (see `provenance_errors`), so the guard stopped
    firing without any signal.
    """
    candidate = worker_results_dir / f"{worker}-worker-{suffix}.md"
    try:
        if candidate.resolve().parent != worker_results_dir.resolve():
            return None
        return candidate.read_text(encoding="utf-8")
    except (OSError, ValueError):
        return None


def id_occurs_wordbounded(text: str, item_id: str) -> bool:
    """True when `item_id` occurs as a literal not flanked by another
    identifier char, so `F-7` matches neither `F-70` nor `xF-7`."""
    pattern = r"(?<![0-9A-Za-z_-])" + re.escape(item_id) + r"(?![0-9A-Za-z_-])"
    return re.search(pattern, text) is not None


def provenance_errors(
    document: object,
    *,
    worker_results_dir: Path,
    suffix: str,
    groups_label: str,
) -> list[str]:
    """Every claim in ``document`` whose cited ID is absent from its worker's
    result file. An unjudgeable document yields no errors — a caller that
    blocks on these must not block on a shape it could not read."""
    claims = group_claims(document)
    if claims is None:
        return []
    contents: dict[str, str | None] = {}
    errors: list[str] = []
    for finding_id, worker, item_id in claims:
        if worker not in contents:
            contents[worker] = read_canonical_worker_result(
                worker_results_dir, worker, suffix
            )
        text = contents[worker]
        if text is None or id_occurs_wordbounded(text, item_id):
            continue
        errors.append(
            f"convergence groups `{groups_label}` group {finding_id} "
            f"cites source item `{worker}:{item_id}`, but that ID does not "
            f"occur in the worker's result file `{worker}-worker-{suffix}.md` — a "
            "grouping may not invent a provenance link to an item the "
            "worker never reported."
        )
    return errors


def worker_result_suffix(run_dir: Path, document: object) -> str | None:
    """`<task-type>-<seq>` naming the worker results a grouping cites.

    A grouping's own filename carries the **state** sequence, but worker results
    are named with the **workerResults** sequence — separate counters, both
    written by `render.render_run_manifest`. They happened to agree on early
    runs, which is why reusing the groups suffix went unnoticed.

    The link is the grouping's `runManifestPath`, required by
    `convergence-groups-v2.0.schema.json`. Only its basename is used: the
    grouping and its run manifest live in the same run directory, so this does
    not need a project root.
    """
    if not isinstance(document, Mapping):
        return None
    recorded = document.get("runManifestPath")
    if not _nonempty_string(recorded):
        return None
    manifest_path = Path(run_dir) / "manifests" / Path(str(recorded)).name
    try:
        manifest = load_owned_object(manifest_path, artifact="run manifest")
    except (OSError, ValueError):
        return None
    if not isinstance(manifest, Mapping):
        return None
    task_type = manifest.get("taskType")
    sequences = manifest.get("runSequencesByCategory")
    seq = sequences.get("workerResults") if isinstance(sequences, Mapping) else None
    if not _nonempty_string(task_type) or not _nonempty_string(seq):
        return None
    return f"{task_type}-{seq}"


def run_dir_provenance_errors(run_dir: Path) -> list[str]:
    """Provenance errors across every grouping persisted under ``run_dir``.

    Unjudgeable input is skipped silently: no groups file, unreadable/malformed
    JSON, a groups file that fails the schema shape, a missing worker result
    file (provider-unavailable substitution is legitimate), or a recorded
    groupsDigest that no longer matches.
    """
    state_dir = Path(run_dir) / "state"
    worker_results_dir = Path(run_dir) / "worker-results"
    if not state_dir.is_dir() or not worker_results_dir.is_dir():
        return []
    errors: list[str] = []
    for groups_path in sorted(state_dir.glob("convergence-groups-*.json")):
        match = GROUPS_BASENAME_RE.match(groups_path.name)
        if match is None:
            continue
        suffix = match.group("suffix")
        try:
            document = load_owned_object(
                groups_path, artifact="convergence groups input"
            )
        except (OSError, ValueError):
            continue
        if not isinstance(document, dict):
            continue
        if not groups_digest_ok(state_dir, suffix, document):
            continue
        result_suffix = worker_result_suffix(Path(run_dir), document)
        if result_suffix is None:
            continue
        errors.extend(
            provenance_errors(
                document,
                worker_results_dir=worker_results_dir,
                suffix=result_suffix,
                groups_label=groups_path.name,
            )
        )
    return errors
