"""Canonical invocation write policy and truthful runner enforcement."""
from __future__ import annotations

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

from .json_boundary import JsonBoundaryError, load_owned_object

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 build_write_policy(
    invocation: Mapping[str, Any],
    assignment: Mapping[str, Any],
    worktree: Path | None,
) -> WritePolicy:
    """Build one provider-neutral policy from invocation and adapter authority."""
    role = _required_text(invocation, "role")
    project_root = Path(_required_text(invocation, "projectRoot")).resolve()
    source_root = _trusted_root(invocation, worktree)
    artifact_paths = _relative_paths(invocation.get("artifactPaths", ()))
    if not artifact_paths:
        raise WritePolicyError("artifactPaths must contain standard worker artifacts")
    source_mode = "project-mutation" if role == "implementer" else "source-readonly"
    source_policy: dict[str, Any] = {
        "mode": source_mode,
        "allowedRoot": str(source_root),
        "plannedPaths": list(_relative_paths(invocation.get("plannedPaths", ()))),
        "protectedPaths": list(
            _relative_paths(invocation.get("protectedPaths", (".okstra", ".git")))
        ),
    }
    git_policy = _git_policy(invocation, source_root, source_mode)
    external_roots = _validated_auxiliary_roots(invocation, assignment)
    auxiliary_policy = {
        "scratchRoots": [
            str(path) for path in _absolute_roots(invocation.get("scratchRoots", ()))
        ],
        "generatedPaths": list(
            _relative_paths(invocation.get("generatedPaths", ()))
        ),
        "externalRoots": [str(path) for path in external_roots],
    }
    policy = WritePolicy(
        artifact_policy={
            "allowedRoot": str(project_root),
            "allowedPaths": list(artifact_paths),
        },
        source_policy=source_policy,
        git_policy=git_policy,
        auxiliary_policy=auxiliary_policy,
        maximum_boundary_precision=_assignment_precision(assignment),
    )
    validate_write_policy_payload(policy.to_payload())
    return policy


def build_invocation_write_contract(
    *,
    role: str,
    project_root: Path,
    worktree: Path | None,
    artifact_paths: Sequence[Path],
    maximum_precision: BoundaryPrecision,
    planned_paths: Sequence[str] = (),
    planned_paths_declared: bool = True,
    scratch_roots: Sequence[Path] = (),
    generated_paths: Sequence[str] = (),
    auxiliary_roots: Sequence[Path] = (),
    validated_auxiliary_roots: Sequence[Path] = (),
) -> tuple[WritePolicy, WriteEnforcement]:
    """Build the single canonical contract used by every v2 dispatcher."""
    root = project_root.resolve()
    relative_artifacts = tuple(
        _relative_to_root(path, root, "artifact") for path in artifact_paths
    )
    invocation: dict[str, Any] = {
        "role": role,
        "projectRoot": str(root),
        "artifactPaths": list(relative_artifacts),
        "plannedPaths": list(planned_paths),
        "plannedPathsDeclared": planned_paths_declared,
        "protectedPaths": [".okstra", ".git"],
        "generatedPaths": list(generated_paths),
        "scratchRoots": [str(path) for path in scratch_roots],
        "auxiliaryRoots": [str(path) for path in auxiliary_roots],
    }
    source_root = worktree.resolve() if worktree is not None else root
    if role == "implementer":
        invocation.update({
            "expectedBaseCommit": _git_output(source_root, "rev-parse", "HEAD"),
            "allowedBranchRef": _git_output(
                source_root, "symbolic-ref", "-q", "HEAD"
            ),
        })
    assignment = {
        "workerWriteCapability": {
            "maxBoundaryPrecision": maximum_precision,
        },
        "validatedAuxiliaryRoots": [str(path) for path in validated_auxiliary_roots],
    }
    policy = build_write_policy(invocation, assignment, source_root)
    return policy, derive_write_enforcement(policy, maximum_precision, role=role)


def planned_paths_from_run_manifest(
    project_root: Path, manifest: Mapping[str, Any]
) -> tuple[str, ...]:
    active_value = manifest.get("activeRunContextPath")
    if not isinstance(active_value, str) or not active_value:
        raise WritePolicyError("implementer write policy has no active run context")
    # `compact_active_run_context` drops `sourceArtifacts` and stores path
    # hints instead; the hydrator is what puts the block back. Reading the file
    # raw always saw `None` here and reported the run as having no approved
    # stage authority, which blocked every implementer dispatch.
    from .path_hints import hydrate_active_run_context

    active = hydrate_active_run_context(
        _read_json(_rooted(project_root, active_value), "active run context")
    )
    source = active.get("sourceArtifacts")
    run_inputs_value = source.get("runInputsPath") if isinstance(source, Mapping) else None
    run = active.get("run")
    stage_value = run.get("stage") if isinstance(run, Mapping) else None
    if not isinstance(run_inputs_value, str) or not str(stage_value).isdigit():
        raise WritePolicyError("implementer write policy has no approved stage authority")
    inputs_payload = _read_json(_rooted(project_root, run_inputs_value), "run inputs")
    inputs = inputs_payload.get("inputs")
    approved = inputs.get("approvedPlanPath") if isinstance(inputs, Mapping) else None
    if not isinstance(approved, str) or not approved:
        raise WritePolicyError("implementer write policy has no approved plan path")
    return _planned_paths_from_report(Path(approved), int(stage_value))


def planned_paths_declared(project_root: Path, manifest: Mapping[str, Any]) -> bool:
    """Whether this run's approved plan states its paths in the declared column."""
    try:
        return planned_paths_from_run_manifest(project_root, manifest)[1]
    except WritePolicyError:
        return False


def derive_write_enforcement(
    policy: WritePolicy,
    maximum_precision: BoundaryPrecision,
    *,
    role: str | None = None,
) -> WriteEnforcement:
    """Lower runner capability when policy paths cannot be enforced exactly."""
    if role == "leader":
        enforcement = WriteEnforcement(
            boundary_precision="none",
            mutation_audit="none",
            attribution="batch-unattributed",
            audited_roots=_audited_roots(policy),
            observation_coverage="partial",
            unobserved_write_surfaces=(
                "lead-session-host-writes",
                "other-stage-refs",
                "shared-git-object-store",
            ),
        )
        validate_write_enforcement_payload(enforcement.to_payload())
        return enforcement
    if maximum_precision not in _PRECISION_RANK:
        raise WritePolicyError("worker write capability is invalid")
    exact_policy = (
        policy.source_mode == "source-readonly"
        and not policy.auxiliary_policy.get("externalRoots")
        and not policy.auxiliary_policy.get("scratchRoots")
        and not policy.auxiliary_policy.get("generatedPaths")
    )
    if maximum_precision == "exact-path" and exact_policy:
        boundary: BoundaryPrecision = "exact-path"
    elif _PRECISION_RANK[maximum_precision] >= _PRECISION_RANK["directory-boundary"]:
        boundary = "directory-boundary"
    else:
        boundary = "none"
    exact = boundary == "exact-path" and exact_policy
    external_roots = tuple(policy.auxiliary_policy.get("externalRoots", ()))
    unobserved: list[str] = []
    if boundary == "none":
        unobserved.append("provider-process-outside-audited-roots")
    if external_roots:
        unobserved.append("shared-tool-cache")
    if policy.source_mode == "project-mutation":
        unobserved.extend(("other-stage-refs", "shared-git-object-store"))
    coverage: Literal["complete", "scoped", "partial"]
    if exact:
        coverage = "complete"
    elif unobserved:
        coverage = "partial"
    else:
        coverage = "scoped"
    enforcement = WriteEnforcement(
        boundary_precision=boundary,
        mutation_audit="none" if exact else "batch",
        attribution="call" if exact else "batch-unattributed",
        audited_roots=_audited_roots(policy),
        observation_coverage=coverage,
        unobserved_write_surfaces=tuple(unobserved),
    )
    validate_write_enforcement_payload(enforcement.to_payload())
    return enforcement


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) != {"mode", "allowedRoot", "plannedPaths", "protectedPaths"}:
        raise WritePolicyError("sourcePolicy is incomplete")
    _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 _git_policy(
    invocation: Mapping[str, Any], root: Path, source_mode: str
) -> dict[str, Any]:
    if source_mode == "source-readonly":
        return {"mode": "disabled"}
    git_dir = _git_path(root, "--git-dir")
    common_dir = _git_path(root, "--git-common-dir")
    expected_base = _required_text(invocation, "expectedBaseCommit")
    allowed_ref = _required_text(invocation, "allowedBranchRef")
    _require_commit(root, expected_base)
    current_ref = _git_output(root, "symbolic-ref", "-q", "HEAD")
    if current_ref != allowed_ref or not allowed_ref.startswith("refs/heads/"):
        raise WritePolicyError("allowedBranchRef does not match assigned worktree")
    return {
        "mode": "fast-forward-descendant-chain",
        "worktreeGitDir": str(git_dir),
        "gitCommonDir": str(common_dir),
        "expectedBaseCommit": expected_base,
        "allowedBranchRef": allowed_ref,
        "commitMode": "fast-forward-descendant-chain",
    }


def _audited_roots(policy: WritePolicy) -> tuple[Path, ...]:
    values = [
        Path(str(policy.source_policy["allowedRoot"])),
        Path(str(policy.artifact_policy["allowedRoot"])),
    ]
    values.extend(Path(value) for value in policy.auxiliary_policy.get("scratchRoots", ()))
    git_dir = policy.git_policy.get("worktreeGitDir")
    if isinstance(git_dir, str) and git_dir:
        values.append(Path(git_dir))
    seen: set[Path] = set()
    roots: list[Path] = []
    for value in values:
        resolved = value.resolve()
        if resolved not in seen:
            roots.append(resolved)
            seen.add(resolved)
    return tuple(roots)


def _trusted_root(invocation: Mapping[str, Any], worktree: Path | None) -> Path:
    raw = worktree if worktree is not None else Path(_required_text(invocation, "projectRoot"))
    if not raw.is_absolute() or not raw.is_dir():
        raise WritePolicyError("assigned worktree must be an existing absolute path")
    return raw.resolve()


def _validated_auxiliary_roots(
    invocation: Mapping[str, Any], assignment: Mapping[str, Any]
) -> tuple[Path, ...]:
    requested = _absolute_roots(invocation.get("auxiliaryRoots", ()))
    declared = set(_absolute_roots(assignment.get("validatedAuxiliaryRoots", ())))
    if any(path not in declared for path in requested):
        raise WritePolicyError("auxiliary root is not adapter-validated")
    return requested


def _assignment_precision(assignment: Mapping[str, Any]) -> BoundaryPrecision:
    capability = assignment.get("workerWriteCapability")
    if not isinstance(capability, Mapping):
        raise WritePolicyError("workerWriteCapability is required")
    value = capability.get("maxBoundaryPrecision")
    if value not in _PRECISION_RANK:
        raise WritePolicyError("workerWriteCapability is invalid")
    return value


def _absolute_roots(value: object) -> tuple[Path, ...]:
    roots: list[Path] = []
    for text in _text_sequence(value, "absolute roots"):
        path = Path(text)
        if not path.is_absolute() or not path.exists():
            raise WritePolicyError("auxiliary root must be an existing absolute path")
        if path.absolute() != path.resolve():
            raise WritePolicyError("auxiliary root contains a symlink component")
        roots.append(path.resolve())
    if len(set(roots)) != len(roots):
        raise WritePolicyError("auxiliary roots must be unique")
    return tuple(roots)


def _relative_to_root(path: Path, root: Path, label: str) -> str:
    absolute = path if path.is_absolute() else root / path
    try:
        relative = absolute.absolute().relative_to(root).as_posix()
    except ValueError as exc:
        raise WritePolicyError(f"{label} path is outside project root") from exc
    if not relative or relative == ".":
        raise WritePolicyError(f"{label} path must name a file")
    current = root
    for part in PurePosixPath(relative).parts:
        current /= part
        if current.is_symlink():
            raise WritePolicyError(f"{label} path contains a symlink component")
    return relative


def _planned_paths_from_report(
    report_path: Path, stage: int
) -> tuple[tuple[str, ...], bool]:
    """(paths, declared). `declared` is False for a plan written before the
    structured `plannedPaths` column existed."""
    payload = _read_json(report_path.with_suffix(".data.json"), "approved plan data")
    planning = payload.get("implementationPlanning")
    stages = planning.get("stages") if isinstance(planning, Mapping) else None
    selected = next(
        (
            row for row in stages
            if isinstance(row, Mapping) and row.get("stage") == stage
        ),
        None,
    ) if isinstance(stages, list) else None
    steps = selected.get("stepwiseExecution") if isinstance(selected, Mapping) else None
    # `plannedPaths`, not `files`: the latter is prose written for a reader and
    # splitting it on commas produced entries like `.npmrc (template only` and
    # `no literal token)`, while a single cell naming a glob migration counted
    # as one literal path. Every file the plan actually asked for then read as
    # an unauthorized source change, which is a failure the executor cannot
    # avoid by following the plan.
    rows = steps if isinstance(steps, list) else []
    paths = {
        str(path).strip().strip("`")
        for step in rows if isinstance(step, Mapping)
        for path in (step.get("plannedPaths") or [])
        if isinstance(path, str) and path.strip()
    }
    if paths:
        return _relative_paths(tuple(sorted(paths))), True
    # A plan approved before `plannedPaths` existed carries its paths only in
    # the prose `files` cell. That prose does not survive being split into
    # paths — it mis-splits globs and parentheticals — so the ledger stays
    # EMPTY rather than carrying values nobody can be held to. An empty ledger
    # on a `project-mutation` policy is what tells the mutation audit to stand
    # its path comparison down (`execution_mutation_audit`); carrying the
    # derived fragments instead is what made every file the plan asked for read
    # as an unauthorized source change. An in-flight task keeps running; the
    # next planning run produces a plan the audit can actually enforce.
    #
    # The prose is still read, for one decision only: telling a pre-column plan
    # apart from a malformed one whose rows name no files at all.
    names_files = any(
        str(step.get("files") or "").strip()
        for step in rows if isinstance(step, Mapping)
    )
    if not names_files:
        raise WritePolicyError(
            "implementer write policy stage has no planned paths: the approved "
            "plan's stepwise rows carry neither `plannedPaths` nor `files`"
        )
    return (), False


def _rooted(project_root: Path, value: str) -> Path:
    path = Path(value)
    return path if path.is_absolute() else project_root / path


def _read_json(path: Path, label: str) -> Mapping[str, Any]:
    try:
        value = load_owned_object(path, artifact=label)
    except (OSError, UnicodeError, JsonBoundaryError) as exc:
        raise WritePolicyError(f"{label} is invalid: {path}") from exc
    if not isinstance(value, Mapping):
        raise WritePolicyError(f"{label} must be an object: {path}")
    return value


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 _required_text(payload: Mapping[str, Any], key: str) -> str:
    value = payload.get(key)
    if not isinstance(value, str) or not value.strip():
        raise WritePolicyError(f"{key} must be a non-empty string")
    return value


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


def _git_path(root: Path, flag: str) -> Path:
    raw = _git_output(root, "rev-parse", "--path-format=absolute", flag)
    path = Path(raw).resolve()
    if not path.exists():
        raise WritePolicyError(f"Git projection path does not exist: {path}")
    return path


def _require_commit(root: Path, commit: str) -> None:
    result = subprocess.run(
        ["git", "-C", str(root), "cat-file", "-e", f"{commit}^{{commit}}"],
        capture_output=True,
        text=True,
        check=False,
    )
    if result.returncode != 0:
        raise WritePolicyError(result.stderr.strip() or "expected base is not a commit")


def _git_output(root: Path, *args: str) -> str:
    result = subprocess.run(
        ["git", "-C", str(root), *args],
        capture_output=True,
        text=True,
        check=False,
    )
    if result.returncode != 0 or not result.stdout.strip():
        detail = result.stderr.strip() or "Git command returned no value"
        raise WritePolicyError(detail)
    return result.stdout.strip()
