"""Coverage-critic 시드 프롬프트를 디스크의 사실에서 결정적으로 렌더한다.

Phase 5.5 의 critic 은 리드가 손으로 쓴 지시문을 받아 왔다. 시드가 Round 0
finding 목록뿐이라 critic 은 "이미 다뤄진 것"을 알 방법이 없었고, 직전 리포트가
이미 덮은 항목을 갭으로 다시 제안했다. 그 재제안 한 건의 값은 gap 검증 라운드
하나이고, 그 라운드는 직렬 경로 위에 있다.

이 모듈은 그 시드를 코드로 만든다. 네 덩어리를 싣는다.

- Round 0 통합 finding 목록 (리드가 붙여 넣던 것과 같은 내용).
- Phase 4 분석자별 결과 파일 경로와 그 워커가 낸 finding id — "이름을 댄 결과를
  열어라"는 지시가 실제 파일을 가리키게 한다.
- 직전 implementation-planning 리포트가 있으면(재실행) 그 리포트의 커버리지 행
  id, clarification 행 id, stage 제목만 뽑은 "이미 다뤄진 것" 색인. **본문은
  절대 싣지 않는다** — 283K 리포트를 통째로 실었다가 report-writer 가 timeout
  난 적이 있다.
- 두 갈래 지시문(누락 / `unrequested-scope`)과 중복 선언 규칙.

산출물은 프롬프트 materializer 의 `--instruction` 이 받는 task 지시문 파일
본문이다(`prompts/lead/convergence.md` §"Dispatch (fresh one-shot)"). 그래서 그
계약이 BLOCKING 으로 요구하는 두 줄(`**Prompt Delivery Mode:**`, `## Inputs`
아래의 Primary analysis packet)도 이 본문이 직접 낸다 — 리드가 손으로 덧붙이던
자리다.
"""
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence

from .convergence_provenance import worker_result_suffix
from .fixed_text import line
from .json_boundary import JsonBoundaryError, load_owned_object
from .paths import RunRef
from .plan_run_root import list_implementation_planning_reports
from .stage_map import StageMapError, parse_stage_map_file


PROMPT_DELIVERY_MODE = "eager-include"
PLANNING_TASK_TYPE = "implementation-planning"


class CriticPromptError(ValueError):
    """시드를 결정적으로 만들 수 없다."""


@dataclass(frozen=True)
class AnalyserResult:
    """한 Phase 4 분석자가 남긴 결과 파일과 그 워커가 낸 finding id."""

    worker_id: str
    result_path: str
    finding_ids: tuple[str, ...]


@dataclass(frozen=True)
class CoveredIndex:
    """직전 계획이 이미 덮은 것의 id 색인. 본문은 담지 않는다.

    ``stage_titles`` 의 ``None`` 은 "Stage Map 을 못 읽었다" 이고 빈 튜플과 다르다
    — 계획의 stage 번호는 `1..N` 이라 0 개인 계획은 없으므로, 못 읽은 것을 0 으로
    쓰면 critic 에게 거짓을 말하게 된다(`stage_ledger.build_stage_ledger` 가 같은
    이유로 세 결과를 구분한다).
    """

    plan_path: str
    requirement_ids: tuple[str, ...]
    clarification_ids: tuple[str, ...]
    stage_titles: tuple[tuple[int, str], ...] | None


def _nonempty_string(value: Any) -> str:
    return value if isinstance(value, str) and value.strip() else ""


def _project_relative(project_root: Path, path: Path) -> str:
    try:
        return path.resolve().relative_to(Path(project_root).resolve()).as_posix()
    except ValueError:
        # expected-miss: 프로젝트 루트 밖의 경로 = 상대화 대상이 아니다. run 트리
        # 밖을 가리키는 것은 정상 분기이며, 그때는 있는 그대로 적는다.
        return path.as_posix()


def analysis_roster(groups: Mapping[str, Any]) -> list[str]:
    workers = groups.get("workers")
    if not isinstance(workers, list):
        return []
    return [
        worker["workerId"]
        for worker in workers
        if isinstance(worker, Mapping)
        and worker.get("audience") == "analysis"
        and _nonempty_string(worker.get("workerId"))
    ]


def _findings_by_worker(groups: Mapping[str, Any]) -> dict[str, list[str]]:
    """워커 → 그 워커가 출처인 finding id. 그룹 순서를 유지한다.

    `sourceItems` 와 `discoveredBy` 를 합집합으로 본다 — 그룹 파서
    (`convergence_engine._parse_group`)가 출처 워커를 그렇게 센다. 한쪽만 보면
    다른 쪽에만 적힌 워커의 결과 파일이 "낸 것 없음"으로 보인다.
    """
    by_worker: dict[str, list[str]] = {}

    def _record(worker: Any, finding_id: str) -> None:
        name = _nonempty_string(worker)
        if name and finding_id not in by_worker.setdefault(name, []):
            by_worker[name].append(finding_id)

    for group in groups.get("groups") or []:
        if not isinstance(group, Mapping):
            continue
        finding_id = _nonempty_string(group.get("findingId"))
        if not finding_id:
            continue
        for item in group.get("sourceItems") or []:
            source = item.get("worker") if isinstance(item, Mapping) else None
            _record(source, finding_id)
        discovered = group.get("discoveredBy")
        for worker in discovered if isinstance(discovered, Mapping) else {}:
            _record(worker, finding_id)
    return by_worker


def analyser_results(
    groups: Mapping[str, Any],
    *,
    project_root: Path,
    run_dir: Path,
) -> list[AnalyserResult]:
    """분석 audience 워커마다 결과 파일 경로와 finding id 를 짝지어 돌려준다.

    결과 파일명은 `convergence_provenance` 가 이미 소유한 규칙을 그대로 쓴다 —
    `<worker>-worker-<task-type>-<workerResults seq>.md`. state 시퀀스와 다른
    카운터라 그룹 파일명에서 접미사를 빌려 오면 빗나간다.
    """
    suffix = worker_result_suffix(Path(run_dir), groups)
    if suffix is None:
        raise CriticPromptError(
            "cannot resolve the worker-result suffix from the grouping's "
            "runManifestPath"
        )
    results_dir = Path(run_dir) / "worker-results"
    by_worker = _findings_by_worker(groups)
    return [
        AnalyserResult(
            worker_id=worker,
            result_path=_project_relative(
                project_root, results_dir / f"{worker}-worker-{suffix}.md"
            ),
            finding_ids=tuple(by_worker.get(worker, ())),
        )
        for worker in analysis_roster(groups)
    ]


def covered_index(project_root: Path, run_dir: Path) -> CoveredIndex | None:
    """직전 implementation-planning 리포트의 id 색인. 없으면 ``None``.

    Phase 5.5 는 Phase 6 보다 앞이라 이 run 의 리포트는 아직 없다. 그래서
    reports 디렉터리의 최신 리포트는 언제나 **직전** run 의 것이다.

    계획 run 에서만 싣는다. 다른 phase 의 finding 은 계획의 커버리지 행이 덮는
    대상이 아니라서, 같은 목록을 "이미 다뤄진 것"으로 보여 주면 critic 이 실재하는
    갭을 중복으로 접는다.
    """
    try:
        ref = RunRef.from_run_dir(Path(run_dir))
    except ValueError:
        # expected-miss: `runs/` 앵커가 없는 경로 = okstra run 트리가 아니다.
        # 그런 곳에는 직전 계획도 없다.
        return None
    if ref.task_type != PLANNING_TASK_TYPE:
        return None
    reports_dir = RunRef.from_task_root(
        ref.task_root, PLANNING_TASK_TYPE
    ).reports_dir
    if not reports_dir.is_dir():
        return None
    reports = list_implementation_planning_reports(reports_dir)
    if not reports:
        return None
    report = reports[0]
    try:
        data = load_owned_object(report, artifact="planning final report")
    except (OSError, UnicodeError, JsonBoundaryError) as exc:
        raise CriticPromptError(
            f"prior planning report is unreadable: {report} ({exc})"
        ) from exc
    if not isinstance(data, Mapping):
        raise CriticPromptError(f"prior planning report is not an object: {report}")
    planning = data.get("implementationPlanning")
    planning = planning if isinstance(planning, Mapping) else {}
    try:
        stages: list[Any] | None = parse_stage_map_file(report)
    except StageMapError:
        # expected-miss: schema v1 마크다운 계획이거나 Stage Map 이 깨졌다. 둘 다
        # 나머지 색인(요구사항·clarification id)은 그대로 쓸 수 있는 분기이므로,
        # 여기서 멈추는 대신 "못 읽었다"를 그대로 실어 보낸다.
        stages = None
    return CoveredIndex(
        plan_path=_project_relative(project_root, report),
        requirement_ids=tuple(
            _nonempty_string(row.get("id"))
            for row in planning.get("requirementCoverage") or []
            if isinstance(row, Mapping) and _nonempty_string(row.get("id"))
        ),
        clarification_ids=tuple(
            _nonempty_string(row.get("id"))
            for row in data.get("clarificationItems") or []
            if isinstance(row, Mapping) and _nonempty_string(row.get("id"))
        ),
        stage_titles=None if stages is None else tuple(
            (stage.stage_number, stage.title) for stage in stages
        ),
    )


_MANDATES = """
Your job has exactly two halves. Answer both.

(1) MISSING — name what nobody covered:
- files / directories / execution paths nobody inspected,
- requirements or acceptance points with zero findings,
- claims raised but never verified.
For each, emit a NEW finding with evidence (file:line or the requirement quote).

(2) UNREQUESTED — name work these findings propose that no requirement asked for:
- a finding whose proposed change serves no requirement, scope item, or
  acceptance point you can QUOTE from the analysis packet,
- an abstraction, configuration knob, or generalization proposed for a caller or
  a case nobody has stated,
- a rewrite, migration, or cleanup of code the requirements never mention.
For each, emit a candidate with `category: "unrequested-scope"`, quote the
proposed work verbatim, and state which requirement you searched for and did not
find.

Do NOT restate an existing finding. An overlap with an existing finding is
declared as `duplicateOf` naming that finding id, not restated as a new
candidate — a restated finding costs a whole verification round to reject, while
a declared duplicate costs none. Judge (2) against the analysis packet's
requirements and scope, never against your own preference for how the code
should look — "I would have done it differently" is not unrequested work, and
neither is work the packet authorizes but you consider unnecessary. If a half
has nothing, say so explicitly for that half; silence on one half is an
incomplete result.

Read before proposing anything: the analysis packet named above for requirements
and phase scope, the Round 0 findings below, and the named result file of any
analyser whose coverage you intend to call incomplete. Do NOT infer missing
verification from a one-line summary.
""".strip()


def critic_prompt_body(
    groups: Mapping[str, Any],
    *,
    analysis_packet_path: str,
    results: Sequence[AnalyserResult],
    covered: CoveredIndex | None,
) -> str:
    """critic 지시문 파일 본문. 같은 입력이면 같은 바이트를 낸다."""
    task_key = _nonempty_string(groups.get("taskKey"))
    if not task_key:
        raise CriticPromptError("convergence groups carry no taskKey")
    packet = _nonempty_string(analysis_packet_path)
    if not packet.endswith("analysis-packet.md"):
        raise CriticPromptError(
            "run manifest analysisPacketPath must name analysis-packet.md; "
            f"found `{analysis_packet_path}`"
        )
    rows: list[str] = [
        f"**Prompt Delivery Mode:** {PROMPT_DELIVERY_MODE}\n",
        "\n",
        f"# Coverage critic — {task_key}\n",
        "\n",
        "## Inputs\n",
        f"- Primary analysis packet: `{packet}`\n",
        "\n",
        "## Mandate\n",
        "\n",
        _MANDATES,
        "\n\n",
    ]
    rows.extend(_round0_rows(groups))
    rows.extend(_analyser_rows(results))
    rows.extend(_covered_rows(covered))
    return "".join(rows)


def _round0_rows(groups: Mapping[str, Any]) -> list[str]:
    findings = [
        group for group in groups.get("groups") or [] if isinstance(group, Mapping)
    ]
    rows = ["## Round 0 consolidated findings\n", line("Finding count", len(findings))]
    for group in findings:
        rows.append(f"\n### {_nonempty_string(group.get('findingId')) or '-'}\n")
        rows.append(line("Summary", group.get("summary")))
        rows.append(line("Category", group.get("category")))
        rows.append(line("Ticket ids", ", ".join(group.get("ticketIds") or [])))
        rows.append(line("Origin worker", group.get("originWorker")))
        rows.append(line("Origin evidence", group.get("originEvidence")))
    rows.append("\n")
    return rows


def _analyser_rows(results: Sequence[AnalyserResult]) -> list[str]:
    rows = [
        "## Phase 4 analyser results\n",
        line("Analyser count", len(results)),
    ]
    for index, result in enumerate(results, 1):
        rows.append(line(f"Analyser {index} worker id", result.worker_id))
        rows.append(line(f"Analyser {index} result path", result.result_path))
        rows.append(
            line(f"Analyser {index} finding ids", ", ".join(result.finding_ids))
        )
    rows.append("\n")
    return rows


def _covered_rows(covered: CoveredIndex | None) -> list[str]:
    if covered is None:
        return []
    rows = [
        "## Already covered by the prior plan\n",
        "\n",
        "Ids and titles only. A candidate that repeats one of these is a "
        "duplicate: declare it with `duplicateOf`, or do not raise it.\n",
        "\n",
        line("Prior plan path", covered.plan_path),
        line("Requirement coverage row ids", ", ".join(covered.requirement_ids)),
        line("Clarification row ids", ", ".join(covered.clarification_ids)),
    ]
    if covered.stage_titles is None:
        rows.append(line("Stage map", "unreadable"))
        rows.append("\n")
        return rows
    rows.append(line("Stage count", len(covered.stage_titles)))
    for number, title in covered.stage_titles:
        rows.append(line(f"Stage {number} title", title))
    rows.append("\n")
    return rows
