"""The fail-closed ScopeWrit policy decision point."""

from __future__ import annotations

import json
import os
import re
import uuid
from collections.abc import Callable, Mapping
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

from .canonical import canonical_json, sha256_value
from .crypto import (
    TrustStore,
    decode_token,
    encode_token,
    sign_envelope,
    verify_envelope,
)
from .detector import detect_scope_uncertainty
from .errors import ManifestError, ReplayError, ResourceError, SignatureError, StateError
from .ledger import FlightRecorder, build_audit_packet, file_lock, verify_audit_packet
from .models import (
    ActionDecision,
    ActionProposal,
    Decision,
    Effect,
    RunStart,
    ScopeManifest,
    SignedEnvelope,
)
from .resources import normalize_resource, rule_matches
from .state import StateStore


def _now() -> datetime:
    return datetime.now(UTC)


_SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$")


def _parse_timestamp(value: Any, field: str) -> datetime:
    if not isinstance(value, str):
        raise SignatureError(f"token {field} must be an ISO timestamp")
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError as exc:
        raise SignatureError(f"token {field} is invalid") from exc
    if parsed.tzinfo is None or parsed.utcoffset() is None:
        raise SignatureError(f"token {field} must include a timezone")
    return parsed.astimezone(UTC)


def _required_adapter(action: str) -> str:
    if action == "network.http":
        return "http"
    if action == "shell.exec":
        return "subprocess"
    if action == "mcp.call":
        return "mcp"
    if action.startswith("browser."):
        return "browser"
    if action.startswith("cloud."):
        return "cloud"
    if action.startswith("credential."):
        return "credential"
    if action.startswith("publish."):
        return "publish"
    if action.startswith("package."):
        return "package"
    return "generic"


class ScopeWritEngine:
    """Verifies signed manifests and mints consume-once action tickets.

    The engine is the policy decision point. Enforcement only occurs when a
    trusted adapter consumes a returned ticket before performing the action.
    """

    def __init__(
        self,
        *,
        data_dir: str | Path,
        principal_trust: TrustStore,
        issuer_private_key: Ed25519PrivateKey,
        issuer_key_id: str,
        issuer_name: str = "scopewrit.local",
        clock: Callable[[], datetime] = _now,
    ):
        self.data_dir = Path(data_dir)
        self.data_dir.mkdir(parents=True, exist_ok=True)
        self.runs_dir = self.data_dir / "runs"
        self.runs_dir.mkdir(parents=True, exist_ok=True)
        self.state = StateStore(self.data_dir / "state.sqlite3")
        self.principal_trust = principal_trust
        self.issuer_private_key = issuer_private_key
        self.issuer_key_id = issuer_key_id
        self.issuer_name = issuer_name
        self.issuer_trust = TrustStore({issuer_key_id: issuer_private_key.public_key()})
        self.clock = clock
        self._manifest_cache: dict[str, tuple[ScopeManifest, str]] = {}
        self._recorder_cache: dict[str, FlightRecorder] = {}

    def _run_dir(self, run_id: str) -> Path:
        if not _SAFE_RUN_ID.fullmatch(run_id):
            raise StateError("invalid run id")
        target = (self.runs_dir / run_id).resolve()
        if target.parent != self.runs_dir.resolve():
            raise StateError("run directory escapes the data root")
        return target

    def _recorder(self, run_id: str) -> FlightRecorder:
        recorder = self._recorder_cache.get(run_id)
        if recorder is None:
            recorder = FlightRecorder(
                self._run_dir(run_id) / "flight-recorder.jsonl",
                clock=self.clock,
            )
            self._recorder_cache[run_id] = recorder
        return recorder

    def _write_json_atomic(self, path: Path, value: Mapping[str, Any]) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        temporary = path.with_suffix(path.suffix + f".{uuid.uuid4().hex}.tmp")
        try:
            temporary.write_bytes(canonical_json(value) + b"\n")
            os.replace(temporary, path)
        finally:
            if temporary.exists():
                temporary.unlink()

    def _verified_manifest(
        self,
        envelope: SignedEnvelope | Mapping[str, Any],
    ) -> tuple[ScopeManifest, str]:
        try:
            payload = verify_envelope(envelope, self.principal_trust)
            manifest = ScopeManifest.model_validate(payload)
        except (SignatureError, ValueError) as exc:
            raise ManifestError("manifest signature or schema is invalid") from exc
        digest = sha256_value(manifest.model_dump(mode="json"))
        return manifest, digest

    def _load_manifest(self, run_id: str) -> tuple[ScopeManifest, str]:
        cached = self._manifest_cache.get(run_id)
        if cached is not None:
            return cached
        path = self._run_dir(run_id) / "manifest.signed.json"
        try:
            raw = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            raise StateError("verified run manifest is unavailable") from exc
        manifest, digest = self._verified_manifest(raw)
        if manifest.run_id != run_id:
            raise StateError("persisted manifest run id mismatch")
        self._manifest_cache[run_id] = (manifest, digest)
        return manifest, digest

    def start_run(
        self,
        envelope: SignedEnvelope | Mapping[str, Any],
    ) -> RunStart:
        manifest, manifest_sha256 = self._verified_manifest(envelope)
        now = self.clock().astimezone(UTC)
        if now < manifest.not_before:
            raise ManifestError("manifest is not active yet")
        if now >= manifest.expires_at:
            raise ManifestError("manifest has expired")

        self.state.start_run(
            run_id=manifest.run_id,
            mandate_id=manifest.mandate_id,
            manifest_sha256=manifest_sha256,
            nonce=manifest.nonce,
            started_at=now,
            expires_at=manifest.expires_at,
        )
        run_dir = self._run_dir(manifest.run_id)
        try:
            parsed_envelope = (
                envelope
                if isinstance(envelope, SignedEnvelope)
                else SignedEnvelope.model_validate(envelope)
            )
            self._write_json_atomic(
                run_dir / "manifest.signed.json",
                parsed_envelope.model_dump(mode="json"),
            )
            self._manifest_cache[manifest.run_id] = (manifest, manifest_sha256)
            recorder = self._recorder(manifest.run_id)
            head = recorder.append(
                "run_started",
                {
                    "run_id": manifest.run_id,
                    "mandate_id": manifest.mandate_id,
                    "agent_id": manifest.agent_id,
                    "principal": manifest.principal,
                    "manifest_sha256": manifest_sha256,
                    "expires_at": manifest.expires_at.isoformat(),
                },
            )
        except Exception:
            try:
                self.state.finish_run(manifest.run_id, "revoked")
            except StateError:
                pass
            raise

        attestation = encode_token(
            {
                "kind": "run-attestation",
                "iss": self.issuer_name,
                "sub": manifest.agent_id,
                "aud": "scopewrit-adapter",
                "run_id": manifest.run_id,
                "mandate_id": manifest.mandate_id,
                "manifest_sha256": manifest_sha256,
                "jti": uuid.uuid4().hex,
                "iat": now.isoformat(),
                "nbf": manifest.not_before.isoformat(),
                "exp": manifest.expires_at.isoformat(),
            },
            self.issuer_private_key,
            self.issuer_key_id,
        )
        return RunStart(
            run_id=manifest.run_id,
            mandate_id=manifest.mandate_id,
            manifest_sha256=manifest_sha256,
            attestation=attestation,
            expires_at=manifest.expires_at,
            ledger_head_sha256=head,
        )

    def _verify_attestation(
        self,
        token: str,
        run_id: str,
    ) -> tuple[dict[str, Any], ScopeManifest, str]:
        claims = decode_token(
            token,
            self.issuer_trust,
            expected_kind="run-attestation",
        )
        if claims.get("iss") != self.issuer_name:
            raise SignatureError("attestation issuer mismatch")
        if claims.get("aud") != "scopewrit-adapter":
            raise SignatureError("attestation audience mismatch")
        if claims.get("run_id") != run_id:
            raise SignatureError("attestation run binding mismatch")
        now = self.clock().astimezone(UTC)
        if now < _parse_timestamp(claims.get("nbf"), "nbf"):
            raise SignatureError("attestation is not active yet")
        if now >= _parse_timestamp(claims.get("exp"), "exp"):
            raise SignatureError("attestation has expired")
        manifest, manifest_sha256 = self._load_manifest(run_id)
        if claims.get("manifest_sha256") != manifest_sha256:
            raise SignatureError("attestation manifest binding mismatch")
        self.state.require_active(run_id, manifest_sha256)
        return claims, manifest, manifest_sha256

    @staticmethod
    def proposal_sha256(
        proposal: ActionProposal,
        normalized_resource: str | None,
    ) -> str:
        body = proposal.model_dump(mode="json")
        body["resource"] = normalized_resource or proposal.resource
        return sha256_value(body)

    @staticmethod
    def create_approval(
        *,
        proposal: ActionProposal,
        normalized_resource: str,
        private_key: Ed25519PrivateKey,
        key_id: str,
        issued_at: datetime,
        expires_at: datetime,
    ) -> str:
        proposal_sha256 = ScopeWritEngine.proposal_sha256(
            proposal,
            normalized_resource,
        )
        return encode_token(
            {
                "kind": "action-approval",
                "run_id": proposal.run_id,
                "proposal_sha256": proposal_sha256,
                "jti": uuid.uuid4().hex,
                "iat": issued_at.astimezone(UTC).isoformat(),
                "exp": expires_at.astimezone(UTC).isoformat(),
            },
            private_key,
            key_id,
        )

    def _approval_valid(
        self,
        approval: str,
        *,
        proposal: ActionProposal,
        normalized_resource: str,
    ) -> bool:
        claims = decode_token(
            approval,
            self.principal_trust,
            expected_kind="action-approval",
        )
        if claims.get("run_id") != proposal.run_id:
            raise SignatureError("approval run binding mismatch")
        expected = self.proposal_sha256(proposal, normalized_resource)
        if claims.get("proposal_sha256") != expected:
            raise SignatureError("approval proposal binding mismatch")
        now = self.clock().astimezone(UTC)
        if now >= _parse_timestamp(claims.get("exp"), "exp"):
            raise SignatureError("approval has expired")
        jti = claims.get("jti")
        if not isinstance(jti, str):
            raise SignatureError("approval is missing a token id")
        self.state.consume_token(
            jti=jti,
            run_id=proposal.run_id,
            # Public state-machine label, not a credential.
            token_kind="action-approval",  # nosec B106
            consumed_at=now,
        )
        return True

    def _record_decision(
        self,
        proposal: ActionProposal,
        *,
        decision: Decision,
        normalized_resource: str | None,
        request_sha256: str,
        reasons: tuple[str, ...],
        matched_rule_id: str | None,
        signals: tuple[str, ...],
        ticket: str | None = None,
    ) -> ActionDecision:
        head = self._recorder(proposal.run_id).append(
            "decision",
            {
                "request_id": proposal.request_id,
                "action": proposal.action,
                "resource": normalized_resource,
                "method": proposal.method,
                "request_sha256": request_sha256,
                "decision": decision.value,
                "reasons": list(reasons),
                "matched_rule_id": matched_rule_id,
                "uncertainty_signals": list(signals),
                "ticket_issued": ticket is not None,
            },
        )
        return ActionDecision(
            decision=decision,
            request_id=proposal.request_id,
            run_id=proposal.run_id,
            action=proposal.action,
            normalized_resource=normalized_resource,
            request_sha256=request_sha256,
            reasons=reasons,
            matched_rule_id=matched_rule_id,
            uncertainty_signals=signals,
            ticket=ticket,
            ledger_head_sha256=head,
        )

    def authorize(
        self,
        *,
        attestation: str,
        proposal: ActionProposal | Mapping[str, Any],
        approval: str | None = None,
    ) -> ActionDecision:
        parsed = (
            proposal
            if isinstance(proposal, ActionProposal)
            else ActionProposal.model_validate(proposal)
        )
        _, manifest, manifest_sha256 = self._verify_attestation(
            attestation,
            parsed.run_id,
        )
        signals = detect_scope_uncertainty(parsed.justification)
        try:
            normalized = normalize_resource(parsed.resource)
        except ResourceError:
            request_sha256 = self.proposal_sha256(parsed, None)
            return self._record_decision(
                parsed,
                decision=Decision.DENY,
                normalized_resource=None,
                request_sha256=request_sha256,
                reasons=("invalid_or_ambiguous_resource",),
                matched_rule_id=None,
                signals=signals,
            )
        request_sha256 = self.proposal_sha256(parsed, normalized.value)

        deny = next(
            (
                rule
                for rule in manifest.rules
                if rule.effect == Effect.DENY and rule_matches(rule, parsed, normalized)
            ),
            None,
        )
        if deny is not None:
            return self._record_decision(
                parsed,
                decision=Decision.DENY,
                normalized_resource=normalized.value,
                request_sha256=request_sha256,
                reasons=("explicit_deny_rule",),
                matched_rule_id=deny.rule_id,
                signals=signals,
            )

        allowed = next(
            (
                rule
                for rule in manifest.rules
                if rule.effect == Effect.ALLOW and rule_matches(rule, parsed, normalized)
            ),
            None,
        )
        if allowed is None:
            return self._record_decision(
                parsed,
                decision=Decision.DENY,
                normalized_resource=normalized.value,
                request_sha256=request_sha256,
                reasons=("no_matching_authority",),
                matched_rule_id=None,
                signals=signals,
            )

        if signals and manifest.pause_on_scope_uncertainty:
            if approval is None:
                return self._record_decision(
                    parsed,
                    decision=Decision.PAUSE,
                    normalized_resource=normalized.value,
                    request_sha256=request_sha256,
                    reasons=("scope_uncertainty_requires_principal_approval",),
                    matched_rule_id=allowed.rule_id,
                    signals=signals,
                )
            try:
                self._approval_valid(
                    approval,
                    proposal=parsed,
                    normalized_resource=normalized.value,
                )
            except (SignatureError, ReplayError, StateError):
                return self._record_decision(
                    parsed,
                    decision=Decision.DENY,
                    normalized_resource=normalized.value,
                    request_sha256=request_sha256,
                    reasons=("invalid_or_replayed_approval",),
                    matched_rule_id=allowed.rule_id,
                    signals=signals,
                )

        try:
            self.state.consume_rule_use(
                parsed.run_id,
                allowed.rule_id,
                allowed.max_uses,
            )
        except StateError:
            return self._record_decision(
                parsed,
                decision=Decision.DENY,
                normalized_resource=normalized.value,
                request_sha256=request_sha256,
                reasons=("rule_use_limit_exhausted",),
                matched_rule_id=allowed.rule_id,
                signals=signals,
            )

        now = self.clock().astimezone(UTC)
        ticket_expires = min(
            manifest.expires_at,
            now + timedelta(seconds=manifest.ticket_ttl_seconds),
        )
        ticket = encode_token(
            {
                "kind": "action-ticket",
                "iss": self.issuer_name,
                "aud": "scopewrit-adapter",
                "run_id": parsed.run_id,
                "manifest_sha256": manifest_sha256,
                "proposal_sha256": request_sha256,
                "action": parsed.action,
                "resource": normalized.value,
                "adapter": _required_adapter(parsed.action),
                "jti": uuid.uuid4().hex,
                "iat": now.isoformat(),
                "exp": ticket_expires.isoformat(),
            },
            self.issuer_private_key,
            self.issuer_key_id,
        )
        return self._record_decision(
            parsed,
            decision=Decision.ALLOW,
            normalized_resource=normalized.value,
            request_sha256=request_sha256,
            reasons=("signed_authority_matched",),
            matched_rule_id=allowed.rule_id,
            signals=signals,
            ticket=ticket,
        )

    def consume_ticket(
        self,
        ticket: str,
        *,
        proposal: ActionProposal,
        adapter: str,
    ) -> dict[str, Any]:
        claims = decode_token(
            ticket,
            self.issuer_trust,
            expected_kind="action-ticket",
        )
        if claims.get("iss") != self.issuer_name:
            raise SignatureError("ticket issuer mismatch")
        if claims.get("aud") != "scopewrit-adapter":
            raise SignatureError("ticket audience mismatch")
        if claims.get("run_id") != proposal.run_id:
            raise SignatureError("ticket run binding mismatch")
        normalized = normalize_resource(proposal.resource)
        expected = self.proposal_sha256(proposal, normalized.value)
        if claims.get("proposal_sha256") != expected:
            raise SignatureError("ticket proposal binding mismatch")
        if claims.get("action") != proposal.action or claims.get("resource") != normalized.value:
            raise SignatureError("ticket action binding mismatch")
        expected_adapter = _required_adapter(proposal.action)
        if adapter != expected_adapter or claims.get("adapter") != expected_adapter:
            raise SignatureError("ticket adapter binding mismatch")
        now = self.clock().astimezone(UTC)
        if now >= _parse_timestamp(claims.get("exp"), "exp"):
            raise SignatureError("action ticket has expired")
        _, manifest_sha256 = self._load_manifest(proposal.run_id)
        if claims.get("manifest_sha256") != manifest_sha256:
            raise SignatureError("ticket manifest binding mismatch")
        self.state.require_active(proposal.run_id, manifest_sha256)
        jti = claims.get("jti")
        if not isinstance(jti, str):
            raise SignatureError("action ticket is missing a token id")
        self.state.consume_token(
            jti=jti,
            run_id=proposal.run_id,
            # Public state-machine label, not a credential.
            token_kind="action-ticket",  # nosec B106
            consumed_at=now,
        )
        self._recorder(proposal.run_id).append(
            "ticket_consumed",
            {
                "request_id": proposal.request_id,
                "proposal_sha256": expected,
                "ticket_jti_sha256": sha256_value(jti),
            },
        )
        return claims

    def record_execution(
        self,
        *,
        proposal: ActionProposal,
        outcome: Mapping[str, Any],
    ) -> str:
        normalized = normalize_resource(proposal.resource)
        return self._recorder(proposal.run_id).append(
            "execution",
            {
                "request_id": proposal.request_id,
                "action": proposal.action,
                "resource": normalized.value,
                "proposal_sha256": self.proposal_sha256(proposal, normalized.value),
                "outcome": dict(outcome),
            },
        )

    def close_run(self, run_id: str) -> SignedEnvelope:
        # Finalization serializes with another closer or revoker. Individual
        # effects use the recorder lock and cannot cross its terminal marker.
        with file_lock(self._run_dir(run_id) / "closure.lock", write=True):
            return self._close_run_locked(run_id)

    def read_audit_packet(self, run_id: str) -> SignedEnvelope:
        _, manifest_sha256 = self._load_manifest(run_id)
        if self.state.run_status(run_id) not in {"closed", "revoked"}:
            raise StateError("run has not been finalized")
        path = self._run_dir(run_id) / "audit-packet.signed.json"
        if not path.is_file():
            raise StateError("audit packet is not available; retry run closure")
        packet = SignedEnvelope.model_validate_json(path.read_text(encoding="utf-8"))
        verified = verify_audit_packet(packet, self.issuer_trust, self._recorder(run_id).path)
        if verified.run_id != run_id or verified.manifest_sha256 != manifest_sha256:
            raise StateError("audit packet is bound to another run or manifest")
        return packet

    def _close_run_locked(self, run_id: str) -> SignedEnvelope:
        manifest, manifest_sha256 = self._load_manifest(run_id)
        packet_path = self._run_dir(run_id) / "audit-packet.signed.json"
        if packet_path.is_file():
            return self.read_audit_packet(run_id)
        recorder = self._recorder(run_id)
        status = "revoked" if self.state.run_status(run_id) == "revoked" else "closed"
        recorder.append(
            "run_closed",
            {"run_id": run_id, "status": status, "closure_policy": "settled-executions-v1"},
        )
        self.state.finish_run(run_id, status, allow_same=True)
        packet = build_audit_packet(
            recorder=recorder,
            run_id=run_id,
            mandate_id=manifest.mandate_id,
            manifest_sha256=manifest_sha256,
            claim_boundary=manifest.claim_boundary,
            private_key=self.issuer_private_key,
            key_id=self.issuer_key_id,
            generated_at=self.clock(),
        )
        self._write_json_atomic(
            packet_path,
            packet.model_dump(mode="json"),
        )
        return packet

    def revoke_run(self, run_id: str, reason: str) -> None:
        with file_lock(self._run_dir(run_id) / "closure.lock", write=True):
            if (
                self.state.run_status(run_id) == "revoked"
                and (self._run_dir(run_id) / "audit-packet.signed.json").is_file()
            ):
                self.read_audit_packet(run_id)
                return
            # Withdraw future authority even if recording the revocation fails.
            self.state.finish_run(run_id, "revoked", allow_same=True)
            self._recorder(run_id).append("run_revoked", {"run_id": run_id, "reason": reason})

    def sign_manifest_payload(
        self,
        manifest: ScopeManifest,
        *,
        principal_private_key: Ed25519PrivateKey,
        principal_key_id: str,
    ) -> SignedEnvelope:
        """Convenience helper for local tooling; principals normally sign offline."""
        return sign_envelope(
            manifest.model_dump(mode="json"),
            principal_private_key,
            principal_key_id,
        )
