"""쓰기 정책 값 타입 · 직렬화 · 검증.

`..write_policy`(623줄) 안에 있던 값 코어를 도메인으로 내렸다. 그 모듈은
git 을 실행하고 run manifest 를 읽는 응용 로직인데, `domain/worker_exec.py` 가
`WritePolicy` / `WriteEnforcement` 타입 하나 때문에 그 응용 모듈을 최상위
import 하고 있었다 — 도메인이 응용을 부르는 역전이고, 그 탓에
`write_policy → path_hints` 가 함수 본문 지연 import 로 밀려나 있었다.

값과 그 값의 정합성(직렬화·다이제스트·payload 검증)까지가 여기다.
정책을 **만들거나**(build_*) **유도하는**(derive_*) 일은 응용에 남는다.
`..write_policy` 가 이 이름들을 재노출하므로 기존 호출부는 바뀌지 않는다.
"""
from __future__ import annotations

import hashlib
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any, Literal


BoundaryPrecision = Literal["exact-path", "directory-boundary", "none"]

_PRECISION_RANK = {"none": 0, "directory-boundary": 1, "exact-path": 2}

_POLICY_KEYS = {
    "artifactPolicy",
    "sourcePolicy",
    "gitPolicy",
    "auxiliaryPolicy",
}

_ENFORCEMENT_KEYS = {
    "boundaryPrecision",
    "mutationAudit",
    "attribution",
    "auditedRoots",
    "observationCoverage",
    "unobservedWriteSurfaces",
}

class WritePolicyError(ValueError):
    """Raised when a write policy cannot be made canonical or trustworthy."""

@dataclass(frozen=True)
class WritePolicy:
    artifact_policy: Mapping[str, Any]
    source_policy: Mapping[str, Any]
    git_policy: Mapping[str, Any]
    auxiliary_policy: Mapping[str, Any]
    maximum_boundary_precision: BoundaryPrecision = "none"

    def to_payload(self) -> dict[str, Any]:
        return {
            "artifactPolicy": dict(self.artifact_policy),
            "sourcePolicy": dict(self.source_policy),
            "gitPolicy": dict(self.git_policy),
            "auxiliaryPolicy": dict(self.auxiliary_policy),
        }

    @property
    def digest(self) -> str:
        return write_policy_digest(self.to_payload())

    @property
    def source_mode(self) -> str:
        return str(self.source_policy["mode"])

@dataclass(frozen=True)
class WriteEnforcement:
    boundary_precision: BoundaryPrecision
    mutation_audit: Literal["none", "batch"]
    attribution: Literal["call", "batch-unattributed"]
    audited_roots: tuple[Path, ...]
    observation_coverage: Literal["complete", "scoped", "partial"]
    unobserved_write_surfaces: tuple[str, ...]

    def to_payload(self) -> dict[str, Any]:
        return {
            "boundaryPrecision": self.boundary_precision,
            "mutationAudit": self.mutation_audit,
            "attribution": self.attribution,
            "auditedRoots": [str(path) for path in self.audited_roots],
            "observationCoverage": self.observation_coverage,
            "unobservedWriteSurfaces": list(self.unobserved_write_surfaces),
        }

def write_policy_from_payload(payload: Mapping[str, Any]) -> WritePolicy:
    validate_write_policy_payload(payload)
    return WritePolicy(
        artifact_policy=dict(payload["artifactPolicy"]),
        source_policy=dict(payload["sourcePolicy"]),
        git_policy=dict(payload["gitPolicy"]),
        auxiliary_policy=dict(payload["auxiliaryPolicy"]),
    )

def write_enforcement_from_payload(
    payload: Mapping[str, Any],
) -> WriteEnforcement:
    validate_write_enforcement_payload(payload)
    return WriteEnforcement(
        boundary_precision=payload["boundaryPrecision"],
        mutation_audit=payload["mutationAudit"],
        attribution=payload["attribution"],
        audited_roots=tuple(Path(value) for value in payload["auditedRoots"]),
        observation_coverage=payload["observationCoverage"],
        unobserved_write_surfaces=tuple(payload["unobservedWriteSurfaces"]),
    )

def write_policy_digest(payload: Mapping[str, Any]) -> str:
    validate_write_policy_payload(payload)
    encoded = json.dumps(
        payload,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    ).encode("utf-8")
    return "sha256:" + hashlib.sha256(encoded).hexdigest()

def validate_write_policy_payload(payload: Mapping[str, Any]) -> None:
    if set(payload) != _POLICY_KEYS:
        raise WritePolicyError("writePolicy must contain four canonical policy sections")
    artifact = _mapping(payload.get("artifactPolicy"), "artifactPolicy")
    source = _mapping(payload.get("sourcePolicy"), "sourcePolicy")
    git = _mapping(payload.get("gitPolicy"), "gitPolicy")
    auxiliary = _mapping(payload.get("auxiliaryPolicy"), "auxiliaryPolicy")
    if set(artifact) != {"allowedRoot", "allowedPaths"}:
        raise WritePolicyError("artifactPolicy is incomplete")
    _absolute_text(artifact.get("allowedRoot"), "artifactPolicy.allowedRoot")
    _relative_paths(artifact.get("allowedPaths", ()))
    if source.get("mode") not in {"source-readonly", "project-mutation"}:
        raise WritePolicyError("sourcePolicy mode is invalid")
    if set(source) - {"plannedPathsDeclared"} != {
        "mode", "allowedRoot", "plannedPaths", "protectedPaths"
    }:
        raise WritePolicyError("sourcePolicy is incomplete")
    if "plannedPathsDeclared" in source and not isinstance(
        source["plannedPathsDeclared"], bool
    ):
        raise WritePolicyError("sourcePolicy.plannedPathsDeclared must be a boolean")
    _absolute_text(source.get("allowedRoot"), "sourcePolicy.allowedRoot")
    _relative_paths(source.get("plannedPaths", ()))
    _relative_paths(source.get("protectedPaths", ()))
    if git.get("mode") not in {"disabled", "fast-forward-descendant-chain"}:
        raise WritePolicyError("gitPolicy mode is invalid")
    if git.get("mode") == "disabled" and set(git) != {"mode"}:
        raise WritePolicyError("disabled gitPolicy must not grant Git paths")
    required_git = {
        "mode", "worktreeGitDir", "gitCommonDir", "expectedBaseCommit",
        "allowedBranchRef", "commitMode",
    }
    if git.get("mode") != "disabled" and set(git) != required_git:
        raise WritePolicyError("project mutation gitPolicy is incomplete")
    if set(auxiliary) != {"scratchRoots", "generatedPaths", "externalRoots"}:
        raise WritePolicyError("auxiliaryPolicy is incomplete")
    _relative_paths(auxiliary.get("generatedPaths", ()))
    for key in ("scratchRoots", "externalRoots"):
        for value in _text_sequence(auxiliary.get(key), f"auxiliaryPolicy.{key}"):
            _absolute_text(value, f"auxiliaryPolicy.{key}")

def validate_write_enforcement_payload(payload: Mapping[str, Any]) -> None:
    if set(payload) != _ENFORCEMENT_KEYS:
        raise WritePolicyError("writeEnforcement is incomplete")
    boundary = payload.get("boundaryPrecision")
    mutation_audit = payload.get("mutationAudit")
    attribution = payload.get("attribution")
    coverage = payload.get("observationCoverage")
    if boundary not in _PRECISION_RANK:
        raise WritePolicyError("writeEnforcement boundaryPrecision is invalid")
    if mutation_audit not in {"none", "batch"}:
        raise WritePolicyError("writeEnforcement mutationAudit is invalid")
    if attribution not in {"call", "batch-unattributed"}:
        raise WritePolicyError("writeEnforcement attribution is invalid")
    if coverage not in {"complete", "scoped", "partial"}:
        raise WritePolicyError("writeEnforcement observationCoverage is invalid")
    if (boundary, mutation_audit, attribution) == ("exact-path", "none", "call"):
        if coverage != "complete":
            raise WritePolicyError("exact enforcement must have complete coverage")
    elif (boundary, mutation_audit, attribution) == (
        "none",
        "none",
        "batch-unattributed",
    ):
        if coverage != "partial":
            raise WritePolicyError("host-session enforcement must be partial")
    elif mutation_audit != "batch" or attribution != "batch-unattributed":
        raise WritePolicyError("non-exact enforcement must use batch attribution")
    elif boundary == "exact-path" or coverage == "complete":
        raise WritePolicyError("batch enforcement cannot claim exact coverage")
    for root in _text_sequence(payload.get("auditedRoots"), "auditedRoots"):
        _absolute_text(root, "auditedRoots")
    _text_sequence(payload.get("unobservedWriteSurfaces"), "unobservedWriteSurfaces")

def _relative_paths(value: object) -> tuple[str, ...]:
    paths: list[str] = []
    for raw in _text_sequence(value, "relative paths"):
        candidate = PurePosixPath(raw)
        if candidate.is_absolute() or not candidate.parts or ".." in candidate.parts:
            raise WritePolicyError("policy paths must be normalized relative paths")
        normalized = candidate.as_posix()
        if normalized in {"", "."} or normalized != raw:
            raise WritePolicyError("policy paths must be normalized relative paths")
        paths.append(normalized)
    if len(set(paths)) != len(paths):
        raise WritePolicyError("policy paths must be unique")
    return tuple(paths)

def _mapping(value: object, label: str) -> Mapping[str, Any]:
    if not isinstance(value, Mapping):
        raise WritePolicyError(f"{label} must be an object")
    return value

def _text_sequence(value: object, label: str) -> tuple[str, ...]:
    if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
        raise WritePolicyError(f"{label} must be an array")
    rows = tuple(value)
    if any(not isinstance(row, str) or not row for row in rows):
        raise WritePolicyError(f"{label} must contain non-empty strings")
    return rows

def _absolute_text(value: object, label: str) -> str:
    if not isinstance(value, str) or not value or not Path(value).is_absolute():
        raise WritePolicyError(f"{label} must be an absolute path")
    return value
