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

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
from .worker_request import verifier_extra_dirs
from .write_policy import build_invocation_write_contract


DYNAMIC_VERIFIER_SOURCE_ROLES = frozenset({
    "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"}},
    },
}


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_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,
    artifact_paths: tuple[Path, ...],
    worktree: Path | None = None,
) -> tuple[RoleExecution, Invocation]:
    """Reserve one provider-neutral verifier identity for a logical round.

    `worktree` 는 이 런의 워커가 실제로 서는 루트다. 디스패치가 정책을 다시
    계산할 때 쓰는 값(`dispatch_core._canonical_write_contract` 이 job 의
    worktree 를 넘긴다)과 같아야 한다. 여기서 None 으로 고정하면 예약된 정책의
    `sourcePolicy.allowedRoot` 는 프로젝트 루트, 디스패치가 계산한 정책은 스테이지
    워크트리가 되어 writePolicyDigest 가 갈리고, 같은 invocationRef 가
    `invocationRef drift` 로 거부된다 — 워크트리를 쓰는 런(implementation stage)
    에서만 나타나고 워크트리가 없는 런(implementation-planning)에서는 안 나타나
    버전 문제로 보이기 쉽다.
    """
    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")
    _validate_dynamic_verifier_request(
        manifest.role_executions,
        source_role_execution_ref,
        duty_id,
    )
    authority = load_owned_json_object(manifest_path)
    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"
            )
        try:
            policy, enforcement = build_invocation_write_contract(
                role="verifier",
                project_root=project_root,
                worktree=worktree,
                artifact_paths=artifact_paths,
                maximum_precision=capability.max_boundary_precision,
                auxiliary_roots=verifier_extra_dirs("verifier"),
                validated_auxiliary_roots=verifier_extra_dirs("verifier"),
            )
        except (OSError, ValueError) as exc:
            raise ExecutionManifestError(
                f"dynamic verifier write policy is invalid: {exc}"
            ) from exc
        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=f"reverify-r{round_number}",
            round=round_number,
            input_digest=input_digest,
            write_policy=policy.to_payload(),
            write_policy_digest=policy.digest,
            write_enforcement=enforcement.to_payload(),
        )

    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,
) -> None:
    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}"
        )


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")
