"""Strict Stage Map parsing shared by planning consumers."""
from __future__ import annotations

import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable

from .final_report_paths import final_report_data_path, is_full_reading_copy_path
from .json_boundary import JsonBoundaryError, load_owned_object
from .fixed_text import line, value_lines
from .md_table import is_separator_row, split_pipe_row
from .paths import RunRef
from .plan_run_root import list_implementation_planning_reports
from .task_target import infer_project_root


STAGE_MAP_HEADING = re.compile(r"^##\s+5\.5\s+Stage\s+Map\b", re.MULTILINE)
_STAGE_MAP_HEADER = (
    "stage",
    "title",
    "depends-on",
    "step-count",
    "exit-contract-summary",
)
_LEGACY_STAGE_MAP_HEADER = (
    "stage",
    "title",
    "depends_on",
    "step_count",
    "exit_contract",
)
_STAGE_MAP_HEADERS = {_STAGE_MAP_HEADER, _LEGACY_STAGE_MAP_HEADER}


@dataclass(frozen=True)
class StageMapStage:
    stage_number: int
    title: str
    depends_on: tuple[int, ...]
    step_count: int
    exit_contract_summary: str


@dataclass(frozen=True)
class StageMapSnapshot:
    state: str
    source_plan_path: str
    stages: list[dict[str, Any]]


def render_stage_map_text(payload: dict[str, Any]) -> str:
    """모델 소비용 Stage Map 스냅샷을 고정 텍스트로 투영한다."""
    rows = ["Okstra stage map\n"]
    rows.append(line("Status", "ready" if payload.get("ok") else "error"))
    for label, key in (
        ("Task key", "taskKey"), ("Task root", "taskRoot"),
        ("State", "state"), ("Source plan path", "sourcePlanPath"),
    ):
        rows.append(line(label, payload.get(key)))
    for label, key in (("Stages", "stages"), ("Done stages", "doneStages"),
                       ("Planning", "planning")):
        rows.extend(value_lines(label, payload.get(key)))
    if not payload.get("ok"):
        rows.append(line("Failure stage", payload.get("stage")))
        rows.append(line("Failure reason", payload.get("reason")))
    return "".join(rows)


# `stepwiseExecution` is what the stage DOES; the other three are what becomes
# true when it is finished. A schedule carrying only the latter reads as an
# analysis of a plan rather than the plan itself.
_PLANNING_STAGE_NARRATIVE_FIELDS = (
    "sliceValue",
    "stepwiseExecution",
    "acceptance",
    "exitContract",
)
_PLANNING_TASK_NARRATIVE_FIELDS = (
    "rollbackStrategy",
    "validationChecklist",
    "crossProjectDependencies",
    "dependencyMigrationRisk",
    "recommendedOption",
    "requirementCoverage",
)
# The report's own plain-language summary. `decisions`, `actions` and `blockers`
# are deliberately not carried: they ask the reader to approve something, and a
# schedule states work rather than requesting sign-off.
_REPORT_SUMMARY_FIELDS = ("headline", "outcome", "whyItMatters")


@dataclass(frozen=True)
class PlanningDetail:
    """The planning report's narrative rows, carried to a schedule verbatim.

    The Stage Map says which stages exist; these rows say what each one accepts,
    how it rolls back and what validates it. A consumer without them has to
    re-summarise a half-megabyte report by hand, which is where a schedule's
    rollback, verification and risk sections drift away from the plan.
    """

    stage_narratives: dict[int, dict[str, Any]]
    task_narratives: dict[str, Any]


class StageMapError(Exception):
    def __init__(
        self,
        code: str,
        reason: str,
        source_plan_path: str = "",
        conflicting_paths: tuple[str, ...] = (),
    ) -> None:
        self.code = code
        self.reason = reason
        self.source_plan_path = source_plan_path
        self.conflicting_paths = conflicting_paths
        details = reason
        if source_plan_path:
            details += f"; source={source_plan_path}"
        if conflicting_paths:
            details += "; conflicts=" + ", ".join(conflicting_paths)
        super().__init__(details)


def _stage_map_table_lines(text: str, source_plan_path: str) -> list[str]:
    heading = STAGE_MAP_HEADING.search(text)
    if heading is None:
        raise StageMapError(
            "stage_map", "section '## 5.5 Stage Map' is missing", source_plan_path
        )
    body = text[heading.end():]
    next_heading = re.search(r"^##\s", body, re.MULTILINE)
    if next_heading is not None:
        body = body[:next_heading.start()]
    lines = body.splitlines()
    headers = [
        index
        for index, line in enumerate(lines)
        if tuple(cell.lower() for cell in split_pipe_row(line))
        in _STAGE_MAP_HEADERS
    ]
    if len(headers) != 1:
        raise StageMapError(
            "stage_map",
            "Stage Map requires exactly one canonical 5-column header",
            source_plan_path,
        )
    header_index = headers[0]
    if header_index + 1 >= len(lines) or not is_separator_row(lines[header_index + 1]):
        raise StageMapError(
            "stage_map", "Stage Map requires a separator row", source_plan_path
        )
    return [
        line for line in lines[header_index + 2:]
        if line.strip().startswith("|") and not is_separator_row(line)
    ]


def _parse_depends_on(
    value: str, row_number: int, source_plan_path: str,
) -> tuple[int, ...]:
    if value in {"", "(none)"}:
        return ()
    dependencies: list[int] = []
    for token in value.split(","):
        normalized = token.strip()
        if not normalized.isdigit() or int(normalized) < 1:
            raise StageMapError(
                "stage_map",
                f"Stage Map row {row_number} has invalid depends-on token "
                f"{normalized!r}",
                source_plan_path,
            )
        dependencies.append(int(normalized))
    return tuple(dependencies)


def _parse_stage_map_row(
    line: str, row_number: int, source_plan_path: str,
) -> StageMapStage:
    cells = split_pipe_row(line)
    if len(cells) != 5:
        raise StageMapError(
            "stage_map",
            f"Stage Map row {row_number} requires 5 columns, got {len(cells)}",
            source_plan_path,
        )
    try:
        stage_number = int(cells[0])
    except ValueError as exc:
        raise StageMapError(
            "stage_map",
            f"Stage Map row {row_number} has invalid stage number {cells[0]!r}",
            source_plan_path,
        ) from exc
    try:
        step_count = int(cells[3])
    except ValueError as exc:
        raise StageMapError(
            "stage_map",
            f"Stage Map row {row_number} has invalid step-count {cells[3]!r}",
            source_plan_path,
        ) from exc
    if stage_number < 1 or step_count < 1:
        field = "stage number" if stage_number < 1 else "step-count"
        value = stage_number if stage_number < 1 else step_count
        raise StageMapError(
            "stage_map",
            f"Stage Map row {row_number} has invalid {field} {value!r}",
            source_plan_path,
        )
    return StageMapStage(
        stage_number,
        cells[1],
        _parse_depends_on(cells[2].strip(), row_number, source_plan_path),
        step_count,
        cells[4],
    )


def _validate_stage_numbers(
    stages: list[StageMapStage], source_plan_path: str,
) -> None:
    numbers = [stage.stage_number for stage in stages]
    duplicates = sorted({number for number in numbers if numbers.count(number) > 1})
    if duplicates:
        raise StageMapError(
            "stage_map",
            f"Stage Map has duplicate stage {duplicates[0]}",
            source_plan_path,
        )
    for row_number, stage in enumerate(stages, start=1):
        if stage.stage_number != row_number:
            raise StageMapError(
                "stage_map",
                "stage numbers must be 1..N monotonic, "
                f"got {stage.stage_number} at row {row_number}",
                source_plan_path,
            )


def parse_stage_map_text(
    text: str, *, source_plan_path: str = "",
) -> list[StageMapStage]:
    if not isinstance(text, str):
        raise StageMapError(
            "stage_map", "Stage Map text must be a string", source_plan_path
        )
    try:
        lines = _stage_map_table_lines(text, source_plan_path)
        if not lines:
            raise StageMapError(
                "stage_map", "Stage Map table is empty", source_plan_path
            )
        stages = [
            _parse_stage_map_row(line, row_number, source_plan_path)
            for row_number, line in enumerate(lines, start=1)
        ]
        _validate_stage_numbers(stages, source_plan_path)
        return stages
    except StageMapError:
        raise
    except (AttributeError, OSError, TypeError, UnicodeError, ValueError) as exc:
        raise StageMapError("stage_map", str(exc), source_plan_path) from exc


def _parse_stage_map_markdown(path: Path) -> list[StageMapStage]:
    """Parse the `## 5.5 Stage Map` table out of a schema-v1 report body.

    Private on purpose: a v2 report has no such section, so a caller reaching
    for this directly gets `section '## 5.5 Stage Map' is missing` on every
    modern report. `parse_stage_map_file` is the entry point — it reads the
    structured sidecar when there is one and falls back here when there is not.
    """
    resolved = Path(path).resolve()
    try:
        text = resolved.read_text(encoding="utf-8")
    except (OSError, UnicodeError) as exc:
        raise StageMapError("stage_map", str(exc), str(resolved)) from exc
    return parse_stage_map_text(text, source_plan_path=str(resolved))


def _require_data_positive_int(
    value: Any, field: str, row_number: int, source_plan_path: str,
) -> int:
    if not isinstance(value, int) or isinstance(value, bool) or value < 1:
        raise StageMapError(
            "stage_map",
            f"structured Stage Map row {row_number} has invalid {field} {value!r}",
            source_plan_path,
        )
    return value


def _require_data_text(
    value: Any, field: str, row_number: int, source_plan_path: str,
) -> str:
    if not isinstance(value, str) or not value.strip():
        raise StageMapError(
            "stage_map",
            f"structured Stage Map row {row_number} has invalid {field} {value!r}",
            source_plan_path,
        )
    return value


def _parse_data_stage_map_row(
    value: Any, row_number: int, source_plan_path: str,
) -> StageMapStage:
    if not isinstance(value, dict):
        raise StageMapError(
            "stage_map",
            f"structured Stage Map row {row_number} must be an object",
            source_plan_path,
        )
    stage_number = _require_data_positive_int(
        value.get("stage"), "stage", row_number, source_plan_path
    )
    step_count = _require_data_positive_int(
        value.get("stepCount"), "stepCount", row_number, source_plan_path
    )
    title = _require_data_text(
        value.get("title"), "title", row_number, source_plan_path
    )
    depends_on = _require_data_text(
        value.get("dependsOn"), "dependsOn", row_number, source_plan_path
    )
    exit_summary = _require_data_text(
        value.get("exitContractSummary"),
        "exitContractSummary",
        row_number,
        source_plan_path,
    )
    return StageMapStage(
        stage_number,
        title,
        _parse_depends_on(depends_on.strip(), row_number, source_plan_path),
        step_count,
        exit_summary,
    )


def _parse_schema_v2_stage_map(
    data: dict[str, Any], source_plan_path: str,
) -> list[StageMapStage]:
    planning = data.get("implementationPlanning")
    stage_map = planning.get("stageMap") if isinstance(planning, dict) else None
    if not isinstance(stage_map, list) or not stage_map:
        raise StageMapError(
            "stage_map",
            "structured report requires a non-empty implementationPlanning.stageMap",
            source_plan_path,
        )
    stages = [
        _parse_data_stage_map_row(value, row_number, source_plan_path)
        for row_number, value in enumerate(stage_map, start=1)
    ]
    _validate_stage_numbers(stages, source_plan_path)
    return stages


def parse_stage_map_file(plan_path: Path) -> list[StageMapStage]:
    """Read one report's Stage Map from the report record, or v1 markdown.

    `--approved-plan` now passes the `.data.json` record. A `.md` path is the
    schema-v1 reading-copy form and is parsed as markdown only — this function
    does not look for a sibling record.
    """
    from .final_report_paths import is_report_record_path

    resolved = Path(plan_path).resolve()
    if is_report_record_path(resolved):
        try:
            data = load_owned_object(resolved, artifact="planning final report")
        except (OSError, UnicodeError, JsonBoundaryError) as exc:
            raise StageMapError("stage_map", str(exc), str(resolved)) from exc
        if not isinstance(data, dict):
            raise StageMapError(
                "stage_map", "structured report must be an object", str(resolved)
            )
        return _parse_schema_v2_stage_map(data, str(resolved))
    return _parse_stage_map_markdown(resolved)


def structured_report(plan_path: Path) -> dict[str, Any]:
    """계약 2.0·3.0 구조화 리포트를 반환하고 Markdown에는 빈 값을 반환한다.

    Public because every caller that must branch on report schema needs it —
    including `validators/validate-implementation-plan-stages.py`, which is a
    separate process and cannot reach a private helper without copying the
    sidecar-detection rule and letting the two drift.
    """
    from .final_report_paths import is_report_record_path

    resolved = Path(plan_path).resolve()
    if not is_report_record_path(resolved) or not resolved.exists():
        return {}
    try:
        data = load_owned_object(resolved, artifact="planning final report")
    except (OSError, UnicodeError, JsonBoundaryError) as exc:
        raise StageMapError("stage_map", str(exc), str(resolved)) from exc
    if not isinstance(data, dict) or data.get("schemaVersion") not in {"2.0", "3.0"}:
        return {}
    return data


def schema_v2_report(plan_path: Path) -> dict[str, Any]:
    """기존 호출자를 위한 구조화 리포트 판독 별칭."""
    return structured_report(plan_path)


def _planning_section(markdown_path: Path) -> dict[str, Any]:
    """The report's `implementationPlanning` block, `{}` for v1."""
    planning = structured_report(markdown_path).get("implementationPlanning")
    return planning if isinstance(planning, dict) else {}


def _stage_narratives(value: Any) -> dict[int, dict[str, Any]]:
    narratives: dict[int, dict[str, Any]] = {}
    if not isinstance(value, list):
        return narratives
    for row in value:
        if not isinstance(row, dict):
            continue
        number = row.get("stage")
        if not isinstance(number, int) or isinstance(number, bool):
            continue
        narratives[number] = {
            field: row[field]
            for field in _PLANNING_STAGE_NARRATIVE_FIELDS
            if field in row
        }
    return narratives


def load_planning_detail(markdown_path: Path) -> PlanningDetail:
    """Read one report's narrative rows; empty for a schema-v1 report."""
    report = structured_report(markdown_path)
    planning = report.get("implementationPlanning")
    if not isinstance(planning, dict) or not planning:
        return PlanningDetail({}, {})
    task = {
        field: planning[field]
        for field in _PLANNING_TASK_NARRATIVE_FIELDS
        if field in planning
    }
    summary = report.get("humanSummary")
    if isinstance(summary, dict):
        carried = {
            field: summary[field]
            for field in _REPORT_SUMMARY_FIELDS
            if field in summary
        }
        if carried:
            task["reportSummary"] = carried
    return PlanningDetail(_stage_narratives(planning.get("stages")), task)


def merge_planning_detail(
    records: list[dict[str, Any]], detail: PlanningDetail,
) -> list[dict[str, Any]]:
    """Join each stage's narrative onto its `stage_map_records` row."""
    return [
        {**record, **detail.stage_narratives.get(record["stage_number"], {})}
        for record in records
    ]


def stage_map_records(stages: Iterable[StageMapStage]) -> list[dict[str, Any]]:
    return [
        {
            "stage_number": stage.stage_number,
            "title": stage.title,
            "depends_on": list(stage.depends_on),
            "step_count": stage.step_count,
            "exit_contract_summary": stage.exit_contract_summary,
        }
        for stage in stages
    ]


def _latest_plan_report(task_root: Path) -> Path | None:
    reports_dir = RunRef.from_task_root(
        task_root, "implementation-planning"
    ).reports_dir
    reports = list_implementation_planning_reports(reports_dir)
    return reports[0] if reports else None


def _snapshot_for(source: Path | None) -> StageMapSnapshot:
    if source is None:
        return StageMapSnapshot("missing", "", [])
    resolved = source.resolve()
    return StageMapSnapshot(
        "ready",
        str(resolved),
        stage_map_records(parse_stage_map_file(resolved)),
    )


def load_task_stage_map(
    task_root: Path, manifest: dict[str, Any],
) -> StageMapSnapshot:
    """완료된 stage 를 무엇으로 지었는지 답하는 Stage Map.

    carry 사이드카가 가리키는 계획을 우선한다 — 그게 실행이 실제로 따른
    문서이기 때문이다. "어떤 stage 번호가 이미 쓰였나" 는 다른 질문이며
    `load_latest_plan_stage_map` 이 답한다.
    """
    carried = _unique_carry_source_paths(task_root)
    if len(carried) > 1:
        paths = tuple(str(path) for path in carried)
        raise StageMapError(
            "plan-source-conflict",
            "implementation carries reference different source plans",
            conflicting_paths=paths,
        )
    source = carried[0] if carried else _latest_plan_report(task_root)
    return _snapshot_for(source)


def load_latest_plan_stage_map(task_root: Path) -> StageMapSnapshot:
    """이 task 의 최신 계획 리포트가 선언한 Stage Map.

    번호 점유의 판정 기준이다. ADR-0015 는 stage 번호를 append-only 로 두고
    새 stage 를 `max+1` 로 붙이라고 정하는데, 그 `max` 는 최신 계획에서만
    나온다 — 완료된 stage 를 지을 때 쓴 계획 뒤에 stage 를 덧붙인 계획이
    있으면 그 번호들도 이미 점유된 상태다.
    """
    return _snapshot_for(_latest_plan_report(task_root))


def _unique_carry_source_paths(task_root: Path) -> list[Path]:
    carry_dir = RunRef.from_task_root(task_root, "implementation").carry_dir
    paths: set[Path] = set()
    for carry_path in sorted(carry_dir.glob("stage-*.json")):
        try:
            carry = load_owned_object(carry_path, artifact="implementation stage carry")
        except (OSError, UnicodeError, JsonBoundaryError):
            continue
        value = carry.get("sourcePlanPath") if isinstance(carry, dict) else None
        if isinstance(value, str) and value:
            # carry 의 이 값은 executor 워커가 손으로 적는 자유 문자열이고,
            # `--approved-plan` 이 레코드 전용으로 조여지기 전에 준비된 run 은
            # 읽기본(`.md`) 경로를 남겼다. v2 리포트의 `.md` 에는 Stage Map
            # 섹션이 없으므로 그대로 읽으면 원장이 통째로 사라진다. 같은 legacy
            # 포인터 정규화를 run-index·타임라인 소비자는 이미 하고 있다.
            # 읽는 쪽에서만 낮춘다 — 동결된 carry 사이드카는 고치지 않는다.
            paths.add(_carry_source_path(task_root, value))
    return sorted(paths, key=str)


def _carry_source_path(task_root: Path, value: str) -> Path:
    """carry 가 가리키는 계획의 읽을 경로.

    레코드 형제가 실재할 때만 읽기본(`.md`)을 레코드로 낮춘다. schema-v1 계획은
    레코드가 아예 없고 마크다운이 유일한 정본이므로, 무조건 낮추면 v1 태스크의
    Stage Map 을 없는 파일에서 찾게 된다.
    """
    resolved = _resolve_plan_path(task_root, value).resolve()
    if not is_full_reading_copy_path(resolved):
        return resolved
    record = final_report_data_path(resolved)
    return record if record.is_file() else resolved


def _resolve_plan_path(task_root: Path, value: str) -> Path:
    path = Path(value)
    if path.is_absolute():
        return path
    project_root = infer_project_root(task_root)
    project_relative = project_root / path
    if project_relative.exists():
        return project_relative
    task_relative = task_root / path
    if task_relative.exists():
        return task_relative
    return project_relative
