"""Validated JSON loading and atomic persistence for convergence artifacts."""
from __future__ import annotations

import hashlib
from pathlib import Path
from typing import Any, Mapping

from .convergence_engine import ConvergenceContractError
from .domain.role import RoleCatalogError, role_for_duty
from .execution_identity import ExecutionManifestError, Invocation, RoleExecution
from .execution_manifest import (
    read_execution_manifest,
    reserve_derived_role_invocation,
)
from .final_report_schema import load_named_schema
from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic


DYNAMIC_VERIFIER_SOURCE_ROLES = frozenset({
    "critic",
    "analyser",
    "designer",
    "planner",
    "verifier",
})

_CRITIC_BATCH_SCHEMA = {
    "type": "object",
    "required": [
        "schemaVersion", "taskKey", "mode", "provider",
        "modelExecutionValue", "dispatches", "gaps",
    ],
    "additionalProperties": False,
    "properties": {
        "schemaVersion": {"const": "1.0"},
        "taskKey": {"type": "string", "pattern": "\\S"},
        "mode": {"const": "coverage"},
        "provider": {"type": "string", "pattern": "\\S"},
        "modelExecutionValue": {"type": "string", "pattern": "\\S"},
        "dispatches": {"type": "array", "items": {"type": "object"}},
        "gaps": {"type": "array", "items": {"type": "object"}},
    },
}


# acceptance 모드는 coverage 의 gap 어휘를 쓰지 않는다 — 후보 하나에 대한 판정이
# `confirmed`/`downgraded` 둘뿐이고, 회계는 이 배치를 읽는 쪽이 센다.
_ACCEPTANCE_BATCH_SCHEMA = {
    "type": "object",
    "required": [
        "schemaVersion", "taskKey", "mode", "provider",
        "modelExecutionValue", "candidates",
    ],
    "additionalProperties": False,
    "properties": {
        "schemaVersion": {"const": "1.0"},
        "taskKey": {"type": "string", "pattern": "\\S"},
        "mode": {"const": "acceptance-devils-advocate"},
        "provider": {"type": "string", "pattern": "\\S"},
        "modelExecutionValue": {"type": "string", "pattern": "\\S"},
        "candidates": {
            "type": "array",
            "items": {
                "type": "object",
                "required": ["candidateId", "verdict"],
                "additionalProperties": False,
                "properties": {
                    "candidateId": {"type": "string", "pattern": "\\S"},
                    "verdict": {"enum": ["confirmed", "downgraded"]},
                    "statement": {"type": "string"},
                },
            },
        },
    },
}


def load_owned_json_object(path: Path) -> dict[str, Any]:
    try:
        return load_owned_object(path, artifact="convergence artifact")
    except JsonBoundaryError as exc:
        raise ConvergenceContractError(str(exc)) from exc


def _load_convergence_result(
    path: Path, *, schema: Mapping[str, Any]
) -> dict[str, Any]:
    try:
        return load_owned_object(
            path, artifact="convergence result", schema=schema
        )
    except JsonBoundaryError as exc:
        raise ConvergenceContractError(str(exc)) from exc


def load_convergence_round_results(path: Path) -> dict[str, Any]:
    return _load_convergence_result(
        path,
        schema=load_named_schema("convergence-round-results-v1.0.schema.json"),
    )


def load_convergence_critic_batch(path: Path) -> dict[str, Any]:
    return _load_convergence_result(path, schema=_CRITIC_BATCH_SCHEMA)


def load_acceptance_critic_batch(path: Path) -> dict[str, Any]:
    return _load_convergence_result(path, schema=_ACCEPTANCE_BATCH_SCHEMA)


def load_json_object(path: Path) -> dict[str, Any]:
    """Compatibility alias for convergence artifacts owned by okstra."""
    return load_owned_json_object(path)


def write_json_atomic(path: Path, payload: Mapping[str, Any]) -> None:
    if not isinstance(payload, Mapping):
        raise ConvergenceContractError("JSON output payload must be an object")
    try:
        write_owned_object_atomic(path, payload, artifact="convergence artifact")
    except JsonBoundaryError as exc:
        raise ConvergenceContractError(str(exc)) from exc


def write_final_state_atomic(
    path: Path,
    payload: Mapping[str, Any],
    *,
    migration: Mapping[str, Any] | None,
) -> None:
    """Replace a legacy final only when its byte archive proves preservation."""
    if path.exists():
        _validate_final_replacement(path, migration)
    write_json_atomic(path, payload)


def reserve_dynamic_verifier(
    manifest_path: Path,
    source_role_execution_ref: str,
    duty_id: str,
    round_number: int,
    task_key: str,
    *,
    input_digest: str,
    invocation_ref: str | None = None,
    dispatch_kind: str | None = None,
) -> tuple[RoleExecution, Invocation]:
    """Reserve one provider-neutral verifier identity for a logical round.

    ``dispatch_kind`` 를 주지 않으면 번호 라운드(`reverify-r<N>`)다. critic gap
    검증(`critic-verify`)은 같은 verifier 신원을 예약하되 kind 를 그대로 적는다 —
    validate-run 이 team-state 의 dispatch kind 와 예약된 invocation 의
    `dispatchKind` 를 대조하므로 예약이 `reverify-r1` 로 남으면 그 디스패치가
    거부된다.

    예약은 정체성만 잡는다. 쓰기 계약은 디스패치가 attempt 를 열 때 한 번만
    계산해 그 attempt 행에 적는다 — 예약이 같은 값을 두 번째로 계산하던 동안,
    두 계산의 입력(worktree)이 갈리면 같은 invocationRef 가 `invocationRef
    drift` 로 거부됐다.
    """
    if round_number < 1:
        raise ExecutionManifestError("dynamic verifier round must be positive")
    manifest_path = Path(manifest_path).resolve()
    manifest = read_execution_manifest(manifest_path)
    if manifest.legacy:
        raise ExecutionManifestError("cannot reserve a dynamic verifier in v1 data")
    authority = load_owned_json_object(manifest_path)
    _validate_dynamic_verifier_request(
        manifest.role_executions,
        source_role_execution_ref,
        duty_id,
        task_type=str(authority.get("taskType", "")),
        dispatch_kind=dispatch_kind or f"reverify-r{round_number}",
    )
    if authority.get("taskKey") not in (None, task_key):
        raise ExecutionManifestError("dynamic verifier taskKey does not match manifest")
    project_root = _manifest_project_root(authority, manifest_path)
    task_manifest_path = _manifest_authority_path(
        authority,
        "taskManifestPath",
        project_root,
    )
    run_ref = _manifest_run_ref(authority, manifest_path, project_root)

    def invocation_factory(verifier: RoleExecution) -> Invocation:
        selected_ref = invocation_ref or (
            f"{verifier.role_execution_ref}-{duty_id}-r{round_number:03d}"
        )
        capability = (
            verifier.binding.worker_write_capability
            if verifier.binding is not None
            else None
        )
        if capability is None:
            raise ExecutionManifestError(
                "dynamic verifier has no runner write capability"
            )
        return Invocation(
            invocation_ref=selected_ref,
            participant_ref=verifier.participant_ref,
            role_execution_ref=verifier.role_execution_ref,
            source_invocation_ref=None,
            recovery_ref=None,
            duty_id=duty_id,
            dispatch_kind=dispatch_kind or f"reverify-r{round_number}",
            round=round_number,
            input_digest=input_digest,
        )

    return reserve_derived_role_invocation(
        manifest_path,
        source_role_execution_ref=source_role_execution_ref,
        role="verifier",
        invocation_factory=invocation_factory,
        task_key=task_key,
        task_manifest_path=task_manifest_path,
        run_ref=run_ref,
    )


def _validate_dynamic_verifier_request(
    roles: tuple[RoleExecution, ...],
    source_role_execution_ref: str,
    duty_id: str,
    *,
    task_type: str,
    dispatch_kind: str,
) -> None:
    from .worker_prompt_policy import is_plan_verify_dispatch_kind

    try:
        duty_role = role_for_duty(duty_id)
    except RoleCatalogError as exc:
        raise ExecutionManifestError(f"unknown verifier duty: {duty_id}") from exc
    if duty_role != "verifier":
        raise ExecutionManifestError(f"dynamic verifier duty is not verifier: {duty_id}")
    source = next(
        (
            role
            for role in roles
            if role.role_execution_ref == source_role_execution_ref
        ),
        None,
    )
    if source is not None and source.role not in DYNAMIC_VERIFIER_SOURCE_ROLES:
        raise ExecutionManifestError(
            f"dynamic verifier source role is not eligible: {source.role}"
        )
    if source is not None and source.role == "critic" and not (
        task_type == "implementation-planning"
        and is_plan_verify_dispatch_kind(dispatch_kind)
    ):
        raise ExecutionManifestError(
            "critic source role is eligible only for planning verification"
        )


def _manifest_project_root(
    manifest: Mapping[str, Any],
    manifest_path: Path,
) -> Path:
    value = manifest.get("projectRoot")
    if not isinstance(value, str) or not value.strip():
        raise ExecutionManifestError("run manifest has no projectRoot")
    project_root = Path(value).resolve()
    if not manifest_path.is_relative_to(project_root):
        raise ExecutionManifestError("run manifest resolves outside projectRoot")
    return project_root


def _manifest_authority_path(
    manifest: Mapping[str, Any],
    key: str,
    project_root: Path,
) -> Path:
    value = manifest.get(key)
    if not isinstance(value, str) or not value.strip():
        raise ExecutionManifestError(f"run manifest has no {key}")
    path = (project_root / value).resolve()
    if not path.is_relative_to(project_root):
        raise ExecutionManifestError(f"run manifest {key} escapes projectRoot")
    return path


def _manifest_run_ref(
    manifest: Mapping[str, Any],
    manifest_path: Path,
    project_root: Path,
) -> str:
    value = manifest.get("runManifestPath")
    if not isinstance(value, str) or not value.strip():
        raise ExecutionManifestError("run manifest has no runManifestPath")
    if (project_root / value).resolve() != manifest_path:
        raise ExecutionManifestError(
            "runManifestPath does not identify the execution manifest"
        )
    return value


def _validate_final_replacement(
    path: Path,
    migration: Mapping[str, Any] | None,
) -> None:
    if not isinstance(migration, Mapping) or migration.get("mode") != "restart-round0":
        raise ConvergenceContractError(
            "existing final state requires a restart-round0 migration archive"
        )
    if migration.get("sourcePath") != str(path):
        raise ConvergenceContractError("migration archive sourcePath does not match final state")
    archive_value = migration.get("archivePath")
    digest = migration.get("legacyDigest")
    if not isinstance(archive_value, str) or not isinstance(digest, str):
        raise ConvergenceContractError("migration archive metadata is incomplete")
    archive_path = Path(archive_value)
    if not archive_path.is_file():
        raise ConvergenceContractError(f"migration archive is missing: {archive_path}")
    archived = archive_path.read_bytes()
    current = path.read_bytes()
    if hashlib.sha256(archived).hexdigest() != digest or archived != current:
        raise ConvergenceContractError("migration archive bytes do not match legacy final")
