"""Edictum — Runtime safety for AI agents."""

from __future__ import annotations

from importlib.metadata import version as _pkg_version

try:
    __version__ = _pkg_version("edictum")
except Exception:  # pragma: no cover — editable installs, test envs
    __version__ = "0.0.0-dev"

import asyncio
import json
import logging
import uuid
from collections.abc import Callable
from dataclasses import asdict
from pathlib import Path
from typing import Any

from edictum.audit import (
    AuditAction,
    AuditEvent,
    AuditSink,
    FileAuditSink,
    RedactionPolicy,
    StdoutAuditSink,
)
from edictum.builtins import deny_sensitive_reads
from edictum.contracts import Verdict, postcondition, precondition, session_contract
from edictum.envelope import (
    BashClassifier,
    Principal,
    SideEffect,
    ToolEnvelope,
    ToolRegistry,
    create_envelope,
)
from edictum.evaluation import ContractResult, EvaluationResult
from edictum.findings import Finding, PostCallResult
from edictum.hooks import HookDecision, HookResult
from edictum.limits import OperationLimits
from edictum.otel import configure_otel, get_tracer, has_otel
from edictum.pipeline import GovernancePipeline, PostDecision, PreDecision
from edictum.session import Session
from edictum.storage import MemoryBackend, StorageBackend
from edictum.telemetry import GovernanceTelemetry
from edictum.types import HookRegistration
from edictum.yaml_engine.composer import CompositionReport

logger = logging.getLogger(__name__)

__all__ = [
    "__version__",
    "Edictum",
    "EdictumConfigError",
    "EdictumDenied",
    "EdictumToolError",
    "SideEffect",
    "Principal",
    "ToolEnvelope",
    "create_envelope",
    "ToolRegistry",
    "BashClassifier",
    "HookDecision",
    "HookResult",
    "Verdict",
    "precondition",
    "postcondition",
    "session_contract",
    "OperationLimits",
    "Session",
    "StorageBackend",
    "MemoryBackend",
    "AuditAction",
    "AuditEvent",
    "AuditSink",
    "StdoutAuditSink",
    "FileAuditSink",
    "RedactionPolicy",
    "GovernanceTelemetry",
    "GovernancePipeline",
    "PreDecision",
    "PostDecision",
    "deny_sensitive_reads",
    "configure_otel",
    "has_otel",
    "Finding",
    "PostCallResult",
    "EvaluationResult",
    "ContractResult",
    "CompositionReport",
]


class Edictum:
    """Main configuration and entrypoint.

    Two usage modes:
    1. With Claude Agent SDK: use ClaudeAgentSDKAdapter
    2. Framework-agnostic: use guard.run() directly
    """

    def __init__(
        self,
        *,
        environment: str = "production",
        mode: str = "enforce",
        limits: OperationLimits | None = None,
        tools: dict[str, dict] | None = None,
        contracts: list | None = None,
        hooks: list | None = None,
        audit_sink: AuditSink | None = None,
        redaction: RedactionPolicy | None = None,
        backend: StorageBackend | None = None,
        policy_version: str | None = None,
    ):
        self.environment = environment
        self.mode = mode
        self.limits = limits or OperationLimits()
        self.backend = backend or MemoryBackend()
        self.redaction = redaction or RedactionPolicy()
        self.audit_sink = audit_sink or StdoutAuditSink(self.redaction)
        self.telemetry = GovernanceTelemetry()
        self._gov_tracer = get_tracer("edictum.governance")
        self.policy_version = policy_version

        # Build tool registry
        self.tool_registry = ToolRegistry()
        if tools:
            for name, config in tools.items():
                self.tool_registry.register(
                    name,
                    side_effect=SideEffect(config.get("side_effect", "irreversible")),
                    idempotent=config.get("idempotent", False),
                )

        # Organize contracts and hooks by type
        self._preconditions: list = []
        self._postconditions: list = []
        self._session_contracts: list = []
        self._shadow_preconditions: list = []
        self._shadow_postconditions: list = []
        self._shadow_session_contracts: list = []
        self._before_hooks: list[HookRegistration] = []
        self._after_hooks: list[HookRegistration] = []

        # Persistent session for accumulating limits across run() calls
        self._session_id = str(uuid.uuid4())

        for item in contracts or []:
            self._register_contract(item)
        for item in hooks or []:
            self._register_hook(item)

    @classmethod
    def from_yaml(
        cls,
        *paths: str | Path,
        tools: dict[str, dict] | None = None,
        mode: str | None = None,
        audit_sink: AuditSink | None = None,
        redaction: RedactionPolicy | None = None,
        backend: StorageBackend | None = None,
        environment: str = "production",
        return_report: bool = False,
    ) -> Edictum | tuple[Edictum, CompositionReport]:
        """Create a Edictum instance from one or more YAML contract bundles.

        Args:
            *paths: One or more paths to YAML contract files. When multiple
                paths are given, bundles are composed left-to-right (later
                layers override earlier ones).
            tools: Tool side-effect classifications. Merged with any ``tools:``
                section in the YAML bundle (parameter wins on conflict).
            mode: Override the bundle's default mode (enforce/observe).
            audit_sink: Custom audit sink.
            redaction: Custom redaction policy.
            backend: Custom storage backend.
            environment: Environment name for envelope context.
            return_report: If True, return ``(guard, CompositionReport)``
                instead of just the guard.

        Returns:
            Configured Edictum instance, or a tuple of (Edictum, CompositionReport)
            when *return_report* is True.

        Raises:
            EdictumConfigError: If the YAML is invalid.
        """
        import hashlib

        from edictum.yaml_engine.compiler import compile_contracts
        from edictum.yaml_engine.composer import CompositionReport, compose_bundles
        from edictum.yaml_engine.loader import load_bundle

        if not paths:
            raise EdictumConfigError("from_yaml() requires at least one path")

        # Load all bundles
        loaded: list[tuple[dict, Any]] = []
        for p in paths:
            loaded.append(load_bundle(p))

        if len(loaded) == 1:
            # Single path — backward compatible, no composition
            bundle_data, bundle_hash = loaded[0]
            policy_version = str(bundle_hash)
            report = CompositionReport()
        else:
            # Multiple paths — compose bundles
            bundle_tuples = [(data, str(p)) for (data, _hash), p in zip(loaded, paths)]
            composed = compose_bundles(*bundle_tuples)
            bundle_data = composed.bundle
            report = composed.report
            # Combined hash from all individual hashes
            policy_version = hashlib.sha256(":".join(str(h) for _d, h in loaded).encode()).hexdigest()

        compiled = compile_contracts(bundle_data)

        # Handle observability config
        obs_config = bundle_data.get("observability", {})
        otel_config = obs_config.get("otel", {})
        if otel_config.get("enabled"):
            from edictum.otel import configure_otel

            configure_otel(
                service_name=otel_config.get("service_name", "edictum-agent"),
                endpoint=otel_config.get("endpoint", "http://localhost:4317"),
                protocol=otel_config.get("protocol", "grpc"),
                resource_attributes=otel_config.get("resource_attributes"),
            )

        # Auto-configure audit sink from observability block if not explicitly provided
        if audit_sink is None:
            obs_file = obs_config.get("file")
            obs_stdout = obs_config.get("stdout", True)
            if obs_file:
                audit_sink = FileAuditSink(obs_file, redaction)
            elif obs_stdout is False:

                class _NullSink:
                    async def emit(self, event):
                        pass

                audit_sink = _NullSink()

        effective_mode = mode or compiled.default_mode
        all_contracts = compiled.preconditions + compiled.postconditions + compiled.session_contracts

        # Merge YAML tools with parameter tools (parameter wins on conflict)
        yaml_tools = compiled.tools
        merged_tools = {**yaml_tools, **(tools or {})}

        guard = cls(
            environment=environment,
            mode=effective_mode,
            limits=compiled.limits,
            tools=merged_tools if merged_tools else None,
            contracts=all_contracts,
            audit_sink=audit_sink,
            redaction=redaction,
            backend=backend,
            policy_version=policy_version,
        )

        if return_report:
            return guard, report
        return guard

    @classmethod
    def from_template(
        cls,
        name: str,
        *,
        tools: dict[str, dict] | None = None,
        mode: str | None = None,
        audit_sink: AuditSink | None = None,
        redaction: RedactionPolicy | None = None,
        backend: StorageBackend | None = None,
        environment: str = "production",
    ) -> Edictum:
        """Create a Edictum instance from a built-in template.

        Args:
            name: Template name (e.g., "file-agent", "research-agent", "devops-agent").
            tools: Tool side-effect classifications. Forwarded to ``from_yaml()``.

        Returns:
            Configured Edictum instance.

        Raises:
            EdictumConfigError: If the template does not exist.
        """
        templates_dir = Path(__file__).parent / "yaml_engine" / "templates"
        template_path = templates_dir / f"{name}.yaml"
        if not template_path.exists():
            raise EdictumConfigError(
                f"Template '{name}' not found. Available: {', '.join(p.stem for p in templates_dir.glob('*.yaml'))}"
            )
        return cls.from_yaml(
            template_path,
            tools=tools,
            mode=mode,
            audit_sink=audit_sink,
            redaction=redaction,
            backend=backend,
            environment=environment,
        )

    @classmethod
    def from_multiple(cls, guards: list[Edictum]) -> Edictum:
        """Create a new Edictum instance by merging multiple guards.

        Concatenates preconditions, postconditions, and session contracts
        from all guards in order.  The first guard's audit config, mode,
        environment, and limits are used as the base.

        Duplicate contract IDs are detected: first occurrence wins and
        a warning is logged for each duplicate.

        Args:
            guards: List of Edictum instances to merge. Must not be empty.

        Returns:
            A new Edictum instance containing all contracts.

        Raises:
            EdictumConfigError: If the guards list is empty.
        """
        if not guards:
            raise EdictumConfigError("from_multiple() requires at least one guard")

        first = guards[0]
        merged = cls(
            environment=first.environment,
            mode=first.mode,
            limits=first.limits,
            audit_sink=first.audit_sink,
            redaction=first.redaction,
            backend=first.backend,
            policy_version=first.policy_version,
        )
        merged.tool_registry = first.tool_registry

        seen_ids: set[str] = set()

        for guard in guards:
            for contract in guard._preconditions:
                cid = getattr(contract, "_edictum_id", None)
                if cid and cid in seen_ids:
                    logger.warning("Duplicate contract id '%s' in from_multiple() — first wins", cid)
                    continue
                if cid:
                    seen_ids.add(cid)
                merged._preconditions.append(contract)

            for contract in guard._postconditions:
                cid = getattr(contract, "_edictum_id", None)
                if cid and cid in seen_ids:
                    logger.warning("Duplicate contract id '%s' in from_multiple() — first wins", cid)
                    continue
                if cid:
                    seen_ids.add(cid)
                merged._postconditions.append(contract)

            for contract in guard._session_contracts:
                cid = getattr(contract, "_edictum_id", None)
                if cid and cid in seen_ids:
                    logger.warning("Duplicate contract id '%s' in from_multiple() — first wins", cid)
                    continue
                if cid:
                    seen_ids.add(cid)
                merged._session_contracts.append(contract)

        return merged

    def _register_contract(self, item: Any) -> None:
        contract_type = getattr(item, "_edictum_type", None)
        is_shadow = getattr(item, "_edictum_shadow", False)

        if is_shadow:
            if contract_type == "precondition":
                self._shadow_preconditions.append(item)
            elif contract_type == "postcondition":
                self._shadow_postconditions.append(item)
            elif contract_type == "session_contract":
                self._shadow_session_contracts.append(item)
        elif contract_type == "precondition":
            self._preconditions.append(item)
        elif contract_type == "postcondition":
            self._postconditions.append(item)
        elif contract_type == "session_contract":
            self._session_contracts.append(item)

    def _register_hook(self, item: Any) -> None:
        if isinstance(item, HookRegistration):
            if item.phase == "before":
                self._before_hooks.append(item)
            else:
                self._after_hooks.append(item)

    def get_hooks(self, phase: str, envelope: ToolEnvelope) -> list[HookRegistration]:
        hooks = self._before_hooks if phase == "before" else self._after_hooks
        return [h for h in hooks if h.tool == "*" or h.tool == envelope.tool_name]

    def get_preconditions(self, envelope: ToolEnvelope) -> list:
        result = []
        for p in self._preconditions:
            tool = getattr(p, "_edictum_tool", "*")
            when = getattr(p, "_edictum_when", None)
            if tool != "*" and tool != envelope.tool_name:
                continue
            if when and not when(envelope):
                continue
            result.append(p)
        return result

    def get_postconditions(self, envelope: ToolEnvelope) -> list:
        result = []
        for p in self._postconditions:
            tool = getattr(p, "_edictum_tool", "*")
            when = getattr(p, "_edictum_when", None)
            if tool != "*" and tool != envelope.tool_name:
                continue
            if when and not when(envelope):
                continue
            result.append(p)
        return result

    def get_session_contracts(self) -> list:
        return self._session_contracts

    def get_shadow_preconditions(self, envelope: ToolEnvelope) -> list:
        result = []
        for p in self._shadow_preconditions:
            tool = getattr(p, "_edictum_tool", "*")
            when = getattr(p, "_edictum_when", None)
            if tool != "*" and tool != envelope.tool_name:
                continue
            if when and not when(envelope):
                continue
            result.append(p)
        return result

    def get_shadow_postconditions(self, envelope: ToolEnvelope) -> list:
        result = []
        for p in self._shadow_postconditions:
            tool = getattr(p, "_edictum_tool", "*")
            when = getattr(p, "_edictum_when", None)
            if tool != "*" and tool != envelope.tool_name:
                continue
            if when and not when(envelope):
                continue
            result.append(p)
        return result

    def get_shadow_session_contracts(self) -> list:
        return self._shadow_session_contracts

    def evaluate(
        self,
        tool_name: str,
        args: dict[str, Any],
        *,
        principal: Principal | None = None,
        output: str | None = None,
        environment: str | None = None,
    ) -> EvaluationResult:
        """Dry-run evaluation of a tool call against all matching contracts.

        Unlike run(), this never executes the tool and evaluates all
        matching contracts exhaustively (no short-circuit on first deny).
        Session contracts are skipped (no session state in dry-run).
        """
        env = environment or self.environment
        envelope = create_envelope(
            tool_name=tool_name,
            tool_input=args,
            environment=env,
            principal=principal,
            registry=self.tool_registry,
        )

        contracts: list[ContractResult] = []
        deny_reasons: list[str] = []
        warn_reasons: list[str] = []

        # Evaluate all matching preconditions (exhaustive, no short-circuit)
        for contract in self.get_preconditions(envelope):
            contract_id = getattr(contract, "_edictum_id", None) or getattr(contract, "__name__", "unknown")
            try:
                verdict = contract(envelope)
            except Exception as exc:
                contract_result = ContractResult(
                    contract_id=contract_id,
                    contract_type="precondition",
                    passed=False,
                    message=f"Precondition error: {exc}",
                    policy_error=True,
                )
                contracts.append(contract_result)
                deny_reasons.append(contract_result.message)
                continue

            tags = verdict.metadata.get("tags", []) if verdict.metadata else []
            is_observed = getattr(contract, "_edictum_mode", None) == "observe" and not verdict.passed
            pe = verdict.metadata.get("policy_error", False) if verdict.metadata else False

            contract_result = ContractResult(
                contract_id=contract_id,
                contract_type="precondition",
                passed=verdict.passed,
                message=verdict.message,
                tags=tags,
                observed=is_observed,
                policy_error=pe,
            )
            contracts.append(contract_result)

            if not verdict.passed and not is_observed:
                deny_reasons.append(verdict.message or "")

        # Evaluate postconditions only when output is provided
        if output is not None:
            for contract in self.get_postconditions(envelope):
                contract_id = getattr(contract, "_edictum_id", None) or getattr(contract, "__name__", "unknown")
                try:
                    verdict = contract(envelope, output)
                except Exception as exc:
                    contract_result = ContractResult(
                        contract_id=contract_id,
                        contract_type="postcondition",
                        passed=False,
                        message=f"Postcondition error: {exc}",
                        policy_error=True,
                    )
                    contracts.append(contract_result)
                    warn_reasons.append(contract_result.message)
                    continue

                tags = verdict.metadata.get("tags", []) if verdict.metadata else []
                is_observed = getattr(contract, "_edictum_mode", None) == "observe" and not verdict.passed
                pe = verdict.metadata.get("policy_error", False) if verdict.metadata else False
                effect = getattr(contract, "_edictum_effect", "warn")

                contract_result = ContractResult(
                    contract_id=contract_id,
                    contract_type="postcondition",
                    passed=verdict.passed,
                    message=verdict.message,
                    tags=tags,
                    observed=is_observed,
                    effect=effect,
                    policy_error=pe,
                )
                contracts.append(contract_result)

                if not verdict.passed and not is_observed:
                    warn_reasons.append(verdict.message or "")

        # Compute verdict
        if deny_reasons:
            verdict_str = "deny"
        elif warn_reasons:
            verdict_str = "warn"
        else:
            verdict_str = "allow"

        return EvaluationResult(
            verdict=verdict_str,
            tool_name=tool_name,
            contracts=contracts,
            deny_reasons=deny_reasons,
            warn_reasons=warn_reasons,
            contracts_evaluated=len(contracts),
            policy_error=any(r.policy_error for r in contracts),
        )

    def evaluate_batch(self, calls: list[dict[str, Any]]) -> list[EvaluationResult]:
        """Evaluate a batch of tool calls. Thin wrapper over evaluate()."""
        results: list[EvaluationResult] = []
        for call in calls:
            tool = call["tool"]
            args = call.get("args", {})

            # Convert principal dict to Principal object
            principal = None
            principal_data = call.get("principal")
            if principal_data and isinstance(principal_data, dict):
                principal = Principal(
                    role=principal_data.get("role"),
                    user_id=principal_data.get("user_id"),
                    ticket_ref=principal_data.get("ticket_ref"),
                    claims=principal_data.get("claims", {}),
                )

            # Normalize output
            output = call.get("output")
            if isinstance(output, dict):
                output = json.dumps(output)

            environment = call.get("environment")

            results.append(
                self.evaluate(
                    tool,
                    args,
                    principal=principal,
                    output=output,
                    environment=environment,
                )
            )
        return results

    async def run(
        self,
        tool_name: str,
        args: dict[str, Any],
        tool_callable: Callable,
        *,
        session_id: str | None = None,
        **envelope_kwargs,
    ) -> Any:
        """Framework-agnostic entrypoint."""
        session_id = session_id or self._session_id
        session = Session(session_id, self.backend)
        pipeline = GovernancePipeline(self)

        # Allow per-call environment override; fall back to guard-level default
        env = envelope_kwargs.pop("environment", self.environment)

        envelope = create_envelope(
            tool_name=tool_name,
            tool_input=args,
            run_id=session_id,
            environment=env,
            registry=self.tool_registry,
            **envelope_kwargs,
        )

        # Increment attempts
        await session.increment_attempts()

        # Start OTel span
        span = self.telemetry.start_tool_span(envelope)
        if self.policy_version:
            span.set_attribute("edictum.policy_version", self.policy_version)

        # Pre-execute
        pre = await pipeline.pre_execute(envelope, session)

        # Determine if this is a real deny or just per-contract observed denials
        real_deny = pre.action == "deny" and not pre.observed

        if real_deny:
            audit_action = AuditAction.CALL_WOULD_DENY if self.mode == "observe" else AuditAction.CALL_DENIED
            await self._emit_run_pre_audit(envelope, session, audit_action, pre)
            self.telemetry.record_denial(envelope, pre.reason)
            if self.mode == "enforce":
                span.set_attribute("governance.action", "denied")
                span.set_attribute("governance.reason", pre.reason or "")
                span.end()
                raise EdictumDenied(
                    reason=pre.reason,
                    decision_source=pre.decision_source,
                    decision_name=pre.decision_name,
                )
            # observe mode: fall through to execute
            span.set_attribute("governance.action", "would_deny")
            span.set_attribute("governance.would_deny_reason", pre.reason or "")
        else:
            # Emit CALL_WOULD_DENY for any per-contract observed denials
            for cr in pre.contracts_evaluated:
                if cr.get("observed") and not cr.get("passed"):
                    observed_event = AuditEvent(
                        action=AuditAction.CALL_WOULD_DENY,
                        run_id=envelope.run_id,
                        call_id=envelope.call_id,
                        tool_name=envelope.tool_name,
                        tool_args=self.redaction.redact_args(envelope.args),
                        side_effect=envelope.side_effect.value,
                        environment=envelope.environment,
                        principal=asdict(envelope.principal) if envelope.principal else None,
                        decision_source="precondition",
                        decision_name=cr["name"],
                        reason=cr["message"],
                        mode="observe",
                        policy_version=self.policy_version,
                        policy_error=pre.policy_error,
                    )
                    await self.audit_sink.emit(observed_event)
                    self._emit_otel_governance_span(observed_event)
            await self._emit_run_pre_audit(envelope, session, AuditAction.CALL_ALLOWED, pre)
            self.telemetry.record_allowed(envelope)
            span.set_attribute("governance.action", "allowed")

        # Emit shadow audit events (never affect the real decision)
        for sr in pre.shadow_results:
            shadow_action = AuditAction.CALL_WOULD_DENY if not sr["passed"] else AuditAction.CALL_ALLOWED
            shadow_event = AuditEvent(
                action=shadow_action,
                run_id=envelope.run_id,
                call_id=envelope.call_id,
                tool_name=envelope.tool_name,
                tool_args=self.redaction.redact_args(envelope.args),
                side_effect=envelope.side_effect.value,
                environment=envelope.environment,
                principal=asdict(envelope.principal) if envelope.principal else None,
                decision_source=sr["source"],
                decision_name=sr["name"],
                reason=sr["message"],
                mode="observe",
                policy_version=self.policy_version,
            )
            await self.audit_sink.emit(shadow_event)
            self._emit_otel_governance_span(shadow_event)

        # Execute tool
        try:
            result = tool_callable(**args)
            if asyncio.iscoroutine(result):
                result = await result
            tool_success = True
        except Exception as e:
            result = str(e)
            tool_success = False

        # Post-execute
        post = await pipeline.post_execute(envelope, result, tool_success)
        await session.record_execution(tool_name, success=tool_success)

        # Emit post-execute audit
        post_action = AuditAction.CALL_EXECUTED if tool_success else AuditAction.CALL_FAILED
        post_event = AuditEvent(
            action=post_action,
            run_id=envelope.run_id,
            call_id=envelope.call_id,
            tool_name=envelope.tool_name,
            tool_args=self.redaction.redact_args(envelope.args),
            side_effect=envelope.side_effect.value,
            environment=envelope.environment,
            principal=asdict(envelope.principal) if envelope.principal else None,
            tool_success=tool_success,
            postconditions_passed=post.postconditions_passed,
            contracts_evaluated=post.contracts_evaluated,
            session_attempt_count=await session.attempt_count(),
            session_execution_count=await session.execution_count(),
            mode=self.mode,
            policy_version=self.policy_version,
            policy_error=post.policy_error,
        )
        await self.audit_sink.emit(post_event)
        self._emit_otel_governance_span(post_event)

        span.set_attribute("governance.tool_success", tool_success)
        span.set_attribute("governance.postconditions_passed", post.postconditions_passed)
        span.end()

        if not tool_success:
            raise EdictumToolError(result)

        return post.redacted_response if post.redacted_response is not None else result

    async def _emit_run_pre_audit(self, envelope, session, action: AuditAction, pre: PreDecision) -> None:
        event = AuditEvent(
            action=action,
            run_id=envelope.run_id,
            call_id=envelope.call_id,
            tool_name=envelope.tool_name,
            tool_args=self.redaction.redact_args(envelope.args),
            side_effect=envelope.side_effect.value,
            environment=envelope.environment,
            principal=asdict(envelope.principal) if envelope.principal else None,
            decision_source=pre.decision_source,
            decision_name=pre.decision_name,
            reason=pre.reason,
            hooks_evaluated=pre.hooks_evaluated,
            contracts_evaluated=pre.contracts_evaluated,
            session_attempt_count=await session.attempt_count(),
            session_execution_count=await session.execution_count(),
            mode=self.mode,
            policy_version=self.policy_version,
            policy_error=pre.policy_error,
        )
        await self.audit_sink.emit(event)
        self._emit_otel_governance_span(event)

    def _emit_otel_governance_span(self, audit_event: AuditEvent) -> None:
        """Emit an OTel span with governance attributes from an AuditEvent."""
        if not has_otel():
            return

        from opentelemetry.trace import StatusCode

        with self._gov_tracer.start_as_current_span("edictum.evaluate") as span:
            span.set_attribute("edictum.tool.name", audit_event.tool_name)
            span.set_attribute("edictum.verdict", audit_event.action.value)
            span.set_attribute("edictum.verdict.reason", audit_event.reason or "")
            span.set_attribute("edictum.decision.source", audit_event.decision_source or "")
            span.set_attribute("edictum.decision.name", audit_event.decision_name or "")
            span.set_attribute("edictum.side_effect", audit_event.side_effect)
            span.set_attribute("edictum.environment", audit_event.environment)
            span.set_attribute("edictum.mode", audit_event.mode)
            span.set_attribute("edictum.session.attempt_count", audit_event.session_attempt_count or 0)
            span.set_attribute("edictum.session.execution_count", audit_event.session_execution_count or 0)

            tool_args_str = json.dumps(audit_event.tool_args, default=str) if audit_event.tool_args else "{}"
            span.set_attribute("edictum.tool.args", tool_args_str)

            if audit_event.principal:
                for key in ("role", "team", "ticket_ref", "user_id", "org_id"):
                    val = audit_event.principal.get(key)
                    if val:
                        span.set_attribute(f"edictum.principal.{key}", val)

            if audit_event.policy_version:
                span.set_attribute("edictum.policy_version", audit_event.policy_version)
            if audit_event.policy_error:
                span.set_attribute("edictum.policy_error", True)

            if audit_event.action.value in ("call_denied",):
                span.set_status(StatusCode.ERROR, audit_event.reason or "denied")
            else:
                span.set_status(StatusCode.OK)


class EdictumDenied(Exception):  # noqa: N818
    """Raised when guard.run() denies a tool call in enforce mode."""

    def __init__(self, reason, decision_source=None, decision_name=None):
        self.reason = reason
        self.decision_source = decision_source
        self.decision_name = decision_name
        super().__init__(reason)


class EdictumConfigError(Exception):
    """Raised for configuration/load-time errors (invalid YAML, schema failures, etc.)."""

    pass


class EdictumToolError(Exception):
    """Raised when the governed tool itself fails."""

    pass
