"""역할별 리포트 입력을 검증해 계약 3.0 정본을 한 번 게시한다."""
from __future__ import annotations

import json
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence

from .agent_activity import agent_activity_rows
from .clarification_items import (
    clarification_disposition,
    incorporated_clarification_ids,
    row_blocks_progress,
)
from .final_report_schema import load_schema_version, validate
from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
from .report_narrative import parse_narrative
from .report_synthesis_packet import (
    report_synthesis_packet_paths,
    verify_report_synthesis_packet_sources,
)
from .report_projections import (
    ReportProjectionError,
    project_convergence,
    project_design,
    project_execution,
    project_token_usage,
)


@dataclass(frozen=True)
class AssemblyIssue:
    owner: str
    artifact_path: str
    field_path: str
    reason: str


class ReportAssemblyError(ValueError):
    """게시 전 입력 검증 실패와 실제 소유자를 전달한다."""

    def __init__(self, issues: AssemblyIssue | Sequence[AssemblyIssue]) -> None:
        rows = (issues,) if isinstance(issues, AssemblyIssue) else tuple(issues)
        if not rows:
            raise ValueError("report assembly error requires at least one issue")
        self.issues = rows
        self.issue = rows[0]
        super().__init__("; ".join(_issue_message(issue) for issue in rows))


def _issue_message(issue: AssemblyIssue) -> str:
    return (
        f"owner={issue.owner} artifactPath={issue.artifact_path} "
        f"fieldPath={issue.field_path} reason={issue.reason}"
    )


def _fail(owner: str, path: Path, field: str, reason: object) -> None:
    raise ReportAssemblyError(AssemblyIssue(owner, str(path), field, str(reason)))


def _read_json(row: ReportInputPath) -> dict[str, Any]:
    if not row.path.is_file():
        _fail(row.owner, row.path, "$", "required input is missing")
    try:
        value = load_owned_object(row.path, artifact=f"{row.owner} report input")
    except JsonBoundaryError as exc:
        _fail(row.owner, row.path, "$", exc)
    if not isinstance(value, dict):
        _fail(row.owner, row.path, "$", "input must be a JSON object")
    return value


def _input_map(project_root: Path, manifest: Mapping[str, Any]) -> dict[str, ReportInputPath]:
    try:
        return {row.key: row for row in report_input_paths(project_root, manifest)}
    except ValueError as exc:
        _fail("orchestrator", project_root, "manifest", exc)


def _input_preflight_issues(
    inputs: Mapping[str, ReportInputPath],
    schema: Mapping[str, Any],
) -> tuple[AssemblyIssue, ...]:
    issues: list[AssemblyIssue] = []
    for row in inputs.values():
        if not row.path.is_file():
            issues.append(
                AssemblyIssue(
                    row.owner,
                    str(row.path),
                    "$",
                    "required input is missing",
                )
            )
            continue
        try:
            if row.key == "narrative":
                parse_narrative(row.path.read_text(encoding="utf-8"), schema)
            elif row.key == "agent-activity":
                row.path.read_text(encoding="utf-8")
            else:
                value = load_owned_object(
                    row.path,
                    artifact=f"{row.owner} report input",
                )
                if not isinstance(value, dict):
                    raise ValueError("input must be a JSON object")
        except (JsonBoundaryError, OSError, UnicodeError, ValueError) as exc:
            issues.append(AssemblyIssue(row.owner, str(row.path), "$", str(exc)))
    return tuple(issues)


def _narrative(row: ReportInputPath, schema: Mapping[str, Any]) -> dict[str, Any]:
    if not row.path.is_file():
        _fail(row.owner, row.path, "$", "required input is missing")
    try:
        return parse_narrative(row.path.read_text(encoding="utf-8"), schema)
    except (OSError, UnicodeError, ValueError) as exc:
        _fail(row.owner, row.path, "$", exc)


def _clarifications(
    ledger: Mapping[str, Any], activities: list[dict[str, Any]], path: Path,
) -> list[dict[str, Any]]:
    if ledger.get("owner") != "lead":
        _fail("lead", path, "owner", "approval ledger owner must be lead")
    active = ledger.get("activeClarifications")
    if not isinstance(active, list):
        _fail("lead", path, "activeClarifications", "must be an array")
    for index, row in enumerate(active):
        if not isinstance(row, Mapping):
            _fail("lead", path, f"activeClarifications[{index}]", "must be an object")
    activity_by_id = {row.get("activityId"): row for row in activities}
    rows = [
        _clarification_row(row, activity_by_id, path)
        for row in active
    ]
    rows.extend(_carried_clarification_rows(ledger, {row.get("id") for row in rows}, path))
    return rows


def _carried_clarification_rows(
    ledger: Mapping[str, Any], seen_ids: set[object], path: Path,
) -> list[dict[str, Any]]:
    """이월 결정은 active 질문이 아니다. 이번 런 활동 원장을 요구하지 않는다."""
    carried = ledger.get("carriedDecisions")
    if not isinstance(carried, list):
        return []
    rows: list[dict[str, Any]] = []
    seen = set(seen_ids)
    for index, entry in enumerate(carried):
        if not isinstance(entry, Mapping):
            _fail("lead", path, f"carriedDecisions[{index}]", "must be an object")
        decision = entry.get("decision")
        if not isinstance(decision, Mapping):
            _fail(
                "lead", path, f"carriedDecisions[{index}].decision",
                "must be an object",
            )
        cid = decision.get("id")
        if cid in seen:
            continue
        rows.append(_carried_clarification_row(decision))
        seen.add(cid)
    return rows


def _carried_clarification_row(source: Mapping[str, Any]) -> dict[str, Any]:
    row = {
        key: source[key]
        for key in (
            "id", "ticketId", "kind", "statement", "expectedForm", "blocks",
            "origin", "userConfirmation", "options",
        )
        if key in source
    }
    if "approval" in source:
        row["approvalContext"] = source["approval"]
    resolution = source.get("resolutionInput")
    if not isinstance(resolution, Mapping):
        row["status"] = "resolved"
        return row
    row.update(
        status="resolved",
        userInput=resolution.get("userText", ""),
        resolution={
            "disposition": resolution.get("disposition"),
            "userText": resolution.get("userText"),
            "checkRefs": list(resolution.get("checkRefs") or []),
        },
    )
    return row


def _project_relative(project_root: Path, path: Path) -> str:
    try:
        return path.relative_to(project_root).as_posix()
    except ValueError:
        return str(path)


def _clarification_response_value(
    project_root: Path,
    manifest: Mapping[str, Any],
) -> str:
    direct = manifest.get("clarificationResponsePath")
    if isinstance(direct, str) and direct.strip():
        return direct.strip()
    context_value = manifest.get("activeRunContextPath")
    if isinstance(context_value, str) and context_value.strip():
        context_path = Path(context_value)
        if not context_path.is_absolute():
            context_path = project_root / context_path
        context = _read_json(
            ReportInputPath("active-run-context", "orchestrator", context_path)
        )
        instruction_set = context.get("instructionSet")
        value = (
            instruction_set.get("clarificationResponsePath")
            if isinstance(instruction_set, Mapping)
            else ""
        )
        if isinstance(value, str) and value.strip():
            return value.strip()
    return _latest_user_response_value(project_root, manifest)


def _latest_user_response_value(
    project_root: Path,
    manifest: Mapping[str, Any],
) -> str:
    record_value = manifest.get("expectedReportRecordPath")
    if not isinstance(record_value, str) or not record_value.strip():
        return ""
    record_path = Path(record_value)
    if not record_path.is_absolute():
        record_path = project_root / record_path
    if record_path.parent.name != "reports":
        return ""
    task_type = str(manifest.get("taskType") or "").strip()
    if not task_type:
        return ""
    responses = sorted(
        path
        for path in (record_path.parent.parent / "user-responses").glob(
            f"user-response-{task_type}-*.md"
        )
        if path.is_file()
    )
    if not responses:
        return ""
    return _project_relative(project_root, responses[-1])


def _clarification_carry_in(
    project_root: Path,
    manifest: Mapping[str, Any],
) -> dict[str, str] | None:
    value = _clarification_response_value(project_root, manifest)
    if not value:
        return None
    path = Path(value)
    if not path.is_absolute():
        path = project_root / path
    if not path.is_file():
        _fail("user", path, "$", "attached clarification response is missing")
    return {"sourceFile": _project_relative(project_root, path)}


def _clarification_row(
    source: Mapping[str, Any], activities: Mapping[object, Mapping[str, Any]], path: Path,
) -> dict[str, Any]:
    row = {
        key: source[key]
        for key in (
            "id", "ticketId", "kind", "statement", "expectedForm", "blocks",
            "origin", "userConfirmation", "options",
        )
        if key in source
    }
    if "approval" in source:
        row["approvalContext"] = source["approval"]
    resolution = source.get("resolutionInput")
    if not isinstance(resolution, Mapping):
        row["status"] = "open"
        return row
    clarification_id = source.get("id")
    check_refs = resolution.get("checkRefs")
    if not isinstance(check_refs, list) or not check_refs:
        _fail("lead", path, f"{clarification_id}.resolutionInput.checkRefs", "must not be empty")
    for activity_id in check_refs:
        activity = activities.get(activity_id)
        if activity is None:
            _fail("lead", path, f"{clarification_id}.resolutionInput.checkRefs", f"unknown activity {activity_id}")
        if clarification_id not in (activity.get("clarificationRefs") or []):
            _fail("activity-ledger", path, str(activity_id), f"missing clarificationRefs {clarification_id}")
    row.update(
        status="resolved",
        userInput=resolution.get("userText", ""),
        resolution={
            "disposition": resolution.get("disposition"),
            "userText": resolution.get("userText"),
            "checkRefs": list(check_refs),
        },
    )
    return row


def _attach_plan_backlinks(data: dict[str, Any], activities: list[dict[str, Any]]) -> None:
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        return
    verification = planning.get("planBodyVerification")
    if not isinstance(verification, dict):
        return
    refs_by_item: dict[str, set[str]] = {}
    for activity in activities:
        for item_id in activity.get("planItemIds") or []:
            refs_by_item.setdefault(str(item_id), set()).update(
                str(ref) for ref in activity.get("clarificationRefs") or []
            )
    for item in verification.get("planItems") or []:
        if isinstance(item, dict):
            item["clarificationRefs"] = sorted(refs_by_item.get(str(item.get("id")), set()))


def _compose(
    project_root: Path,
    manifest_path: Path,
    manifest: Mapping[str, Any],
    inputs: Mapping[str, ReportInputPath],
    schema: Mapping[str, Any],
) -> dict[str, Any]:
    narrative = _narrative(inputs["narrative"], schema)
    ledger = _read_json(inputs["approval-decisions"])
    team_state = _read_json(inputs["execution-status"])
    convergence = _read_json(inputs["convergence"])
    if not inputs["agent-activity"].path.is_file():
        _fail("activity-ledger", inputs["agent-activity"].path, "$", "required input is missing")
    try:
        activities = list(agent_activity_rows(project_root, manifest_path))
    except ValueError as exc:
        _fail("activity-ledger", inputs["agent-activity"].path, "$", exc)
    try:
        convergence_data = project_convergence(convergence)
    except ReportProjectionError as exc:
        _fail("convergence", inputs["convergence"].path, "$", exc)
    try:
        execution = project_execution(manifest, team_state)
        usage = project_token_usage(team_state)
    except (TypeError, ValueError) as exc:
        _fail("team-state", inputs["execution-status"].path, "$", exc)
    data = {"schemaVersion": "3.0", **narrative, **execution}
    data["tokenUsage"] = usage
    data["crossVerification"] = convergence_data["crossVerification"]
    data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
    carry_in = _clarification_carry_in(project_root, manifest)
    if carry_in is not None:
        data["clarificationCarryIn"] = carry_in
    data["agentActivity"] = activities
    data["evidence"] = {"primary": _promoted_evidence(convergence_data)}
    data["missingInformation"] = []
    _attach_metadata(data, manifest)
    _attach_machine_planning(data, inputs)
    _attach_plan_backlinks(data, activities)
    return data


def _identity(manifest: Mapping[str, Any]) -> tuple[str, str, str]:
    task_key = str(manifest.get("taskKey") or "")
    parts = task_key.split(":")
    if len(parts) >= 3:
        return parts[0], parts[-2], parts[-1]
    return (
        str(manifest.get("projectId") or "unknown"),
        str(manifest.get("taskGroup") or "unknown"),
        str(manifest.get("taskId") or task_key or "unknown"),
    )


def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
    project_id, task_group, task_id = _identity(manifest)
    task_type = str(manifest.get("taskType") or "")
    task_key = str(manifest.get("taskKey") or "")
    created = str(manifest.get("runTimestamp") or manifest.get("createdAt") or "unknown")
    data["meta"] = {"reportLanguage": str(manifest.get("reportLanguage") or "en")}
    clarifications = data.get("clarificationItems") or []
    incorporated = incorporated_clarification_ids(data)
    blocked = any(
        isinstance(row, Mapping)
        and row_blocks_progress(
            str(row.get("status") or ""),
            clarification_disposition(row),
            incorporated=str(row.get("id") or "") in incorporated,
        )
        for row in clarifications
    )
    frontmatter = {
        "title": f"OKSTRA Final Report - {task_key}",
        "id": task_key.replace(":", "-"),
        "tags": ["final-report"],
        "status": "in-progress" if blocked else "completed",
        "aliases": [f"{task_id}-{task_type}"],
        "date": created,
        "taskId": task_id,
        "taskGroup": task_group,
        "projectId": project_id,
        "taskType": task_type,
        "workerId": "report-writer",
        "approved": False,
    }
    planning = data.get("implementationPlanning")
    if isinstance(planning, Mapping) and planning.get("planningContract") == "selected-direction":
        selected = planning.get("selectedDirectionRef")
        frontmatter["selectedDirectionRef"] = str(
            selected.get("snapshotPath") if isinstance(selected, Mapping) else ""
        )
    else:
        frontmatter["implementationOption"] = ""
    data["frontmatter"] = frontmatter
    data["header"] = {
        "taskKey": task_key,
        "createdAt": created,
        "taskType": task_type,
        "reportOwner": "Okstra lead",
        "reportAuthor": "Report writer worker",
        "leadModel": str(manifest.get("leadModel") or "unknown"),
        "okstraVersion": str(manifest.get("okstraVersion") or "unknown"),
    }


def _promoted_evidence(convergence: Mapping[str, Any]) -> list[dict[str, Any]]:
    return [
        {
            "id": f"E-{index:03d}",
            "ticketId": row["ticketId"],
            "evidence": row["evidence"],
            "sourceItems": row["sourceItems"],
            "source": "convergence",
        }
        for index, row in enumerate(convergence.get("promotedEvidence") or [], start=1)
    ]


def _attach_machine_planning(
    data: dict[str, Any], inputs: Mapping[str, ReportInputPath],
) -> None:
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        return
    design_row = inputs.get("design-preparation")
    if design_row is None:
        return
    try:
        design = project_design(planning, _read_json(design_row))
    except ReportProjectionError as exc:
        _fail(design_row.owner, design_row.path, "$", exc)
    planning["designPreparation"] = design["designPreparation"]
    stages = {row.get("stage"): row for row in planning.get("stages") or [] if isinstance(row, dict)}
    for coverage in design["stageCoverage"]:
        stage = stages.get(coverage.get("stage"))
        if stage is not None:
            stage["designSurfaceCoverage"] = coverage.get("rows") or []
    verification_row = inputs.get("plan-body-verification")
    if verification_row is None:
        return
    verification = _read_json(verification_row)
    if verification.get("owner") != "convergence":
        _fail(
            verification_row.owner, verification_row.path, "owner",
            "plan-body verification owner must be convergence",
        )
    projection = verification.get("planBodyVerification")
    if not isinstance(projection, Mapping):
        _fail(
            verification_row.owner, verification_row.path,
            "planBodyVerification", "must be an object",
        )
    planning["planBodyVerification"] = dict(projection)


def _schema_owner(error: str) -> str:
    field = error.split(" ", 1)[0]
    owners = {
        "executionStatus": "team-state",
        "tokenUsage": "team-state",
        "crossVerification": "convergence",
        "clarificationItems": "lead",
        "agentActivity": "activity-ledger",
        "executionRoles": "run-manifest",
        "frontmatter": "orchestrator",
        "header": "orchestrator",
        "meta": "orchestrator",
    }
    if "designPreparation" in error or "designSurfaceCoverage" in error:
        return "design-surface-detector"
    if "planBodyVerification" in error:
        return "convergence"
    if "selectedDirectionRef" in error:
        return "report-writer"
    return owners.get(field.split(".", 1)[0], "report-writer")


def _publish(
    target: Path, data: Mapping[str, Any], replace: Callable[[str, str], None],
) -> None:
    target.parent.mkdir(parents=True, exist_ok=True)
    serialized = serialize_owned_object(target, data, artifact="final report record")
    descriptor, name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
            handle.write(serialized)
            handle.flush()
            os.fsync(handle.fileno())
        replace(name, str(target))
    finally:
        if os.path.exists(name):
            os.unlink(name)


def assemble_report(
    project_root: Path, manifest_path: Path, *,
    replace: Callable[[str, str], None] = os.replace,
) -> dict[str, Any]:
    """계약 3.0 정본을 조립해 게시한다.

    입력이 없어 조립 자체가 안 되면 기존 파일을 건드리지 않는다. 조립은
    됐는데 스키마만 깨진 기록은 게시한 뒤 거부한다. 게시를 건너뛰면
    `validate-run` 이 이번 런 내용을 보지 못한다.
    """
    manifest_row = ReportInputPath("run-manifest", "orchestrator", manifest_path)
    manifest = _read_json(manifest_row)
    if not uses_report_contract_v3(manifest):
        _fail("orchestrator", manifest_path, "reportContractVersion", "requires 3.0")
    schema = load_schema_version("3.0")
    inputs = _input_map(project_root, manifest)
    input_issues = list(_input_preflight_issues(inputs, schema))
    packet_data_path, _ = report_synthesis_packet_paths(inputs["narrative"].path)
    if packet_data_path.is_file():
        input_issues.extend(
            AssemblyIssue(
                issue.owner,
                str(issue.path),
                f"sources.{issue.label}",
                issue.reason,
            )
            for issue in verify_report_synthesis_packet_sources(
                project_root,
                packet_data_path,
            )
        )
    if input_issues:
        raise ReportAssemblyError(tuple(input_issues))
    data = _compose(project_root, manifest_path, manifest, inputs, schema)
    errors = validate(data, schema)
    value = manifest.get("expectedReportRecordPath")
    if not isinstance(value, str) or not value:
        _fail("orchestrator", manifest_path, "expectedReportRecordPath", "required")
    target = Path(value)
    target = target if target.is_absolute() else project_root / target
    _publish(target, data, replace)
    if errors:
        raise ReportAssemblyError(
            tuple(
                AssemblyIssue(
                    _schema_owner(error),
                    str(target),
                    error.split(" ", 1)[0],
                    error,
                )
                for error in errors
            )
        )
    return data
