"""Autonomy guardrails and audit trail models.

Extends the automation guard framework with:

- **AutonomyGuardrails** — runtime constraints for max steps, tool
  budget, wall-clock time, per-actor limits, and required confirmations
  during plan execution.
- **GuardrailAuditEntry** — a single audit trail entry recording a
  guardrail enforcement event with timestamp, event type, result, and
  contextual metadata.
- **GuardrailAuditTrail** — a bounded, ordered collection of audit
  entries persisted to plan metadata with configurable maximum size
  and oldest-first eviction.

These models support the M6 autonomy hardening milestone by providing
a structured, auditable record of every guardrail decision made during
plan execution.

Based on ``docs/specification.md`` Section "Automation Profiles"
(autonomy guardrails and audit trail) and "Cost and Rate Limits"
(per-plan budgets including wall-clock time, per-actor limits).
"""

from __future__ import annotations

import threading
from datetime import UTC, datetime
from enum import StrEnum

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


class GuardrailEventType(StrEnum):
    """Types of guardrail enforcement events."""

    STEP_BLOCKED = "step_blocked"
    BUDGET_BLOCKED = "budget_blocked"
    CONFIRMATION_REQUIRED = "confirmation_required"
    STEP_ALLOWED = "step_allowed"
    BUDGET_ALLOWED = "budget_allowed"
    CONFIRMATION_GRANTED = "confirmation_granted"
    TIME_BLOCKED = "time_blocked"
    TIME_ALLOWED = "time_allowed"
    ACTOR_LIMIT_BLOCKED = "actor_limit_blocked"
    ACTOR_LIMIT_ALLOWED = "actor_limit_allowed"
    RETRY_BLOCKED = "retry_blocked"
    RETRY_ALLOWED = "retry_allowed"


class GuardrailResult(StrEnum):
    """Outcome of a guardrail enforcement check."""

    ALLOWED = "allowed"
    DENIED = "denied"


class GuardrailAuditEntry(BaseModel):
    """A single guardrail enforcement audit trail entry.

    Records the outcome of a guardrail check including the event type,
    which guard was evaluated, whether the action was allowed or denied,
    and optional contextual metadata.
    """

    timestamp: str = Field(
        default_factory=lambda: datetime.now(tz=UTC).isoformat(),
        description="ISO-8601 timestamp of the enforcement event",
    )
    event_type: GuardrailEventType = Field(
        ...,
        description="Type of guardrail event",
    )
    guard_name: str = Field(
        ...,
        min_length=1,
        description="Name of the guard that was evaluated",
    )
    result: GuardrailResult = Field(
        ...,
        description="Outcome of the guard check (allowed/denied)",
    )
    reason: str | None = Field(
        default=None,
        description="Human-readable reason for the decision",
    )
    context: dict[str, str | int | float | bool | None] = Field(
        default_factory=dict,
        description="Additional context about the enforcement event",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
    )


_DEFAULT_MAX_AUDIT_ENTRIES = 10_000


class GuardrailAuditTrail(BaseModel):
    """Bounded, ordered collection of guardrail audit entries.

    Persisted to plan metadata to provide a complete record of
    autonomy-related decisions during plan execution.

    When the number of entries exceeds ``max_entries``, the oldest
    entries are evicted to keep the trail bounded.
    """

    entries: list[GuardrailAuditEntry] = Field(
        default_factory=list,
        description="Ordered list of guardrail enforcement entries",
    )
    max_entries: int = Field(
        default=_DEFAULT_MAX_AUDIT_ENTRIES,
        ge=1,
        le=1_000_000,
        description="Maximum number of audit entries to retain",
    )
    _allowed_count: int = 0
    _denied_count: int = 0

    def model_post_init(self, _context: object) -> None:
        """Recompute counters from pre-existing entries after init."""
        self._allowed_count = sum(
            1 for e in self.entries if e.result == GuardrailResult.ALLOWED
        )
        self._denied_count = sum(
            1 for e in self.entries if e.result == GuardrailResult.DENIED
        )

    def add_entry(self, entry: GuardrailAuditEntry) -> None:
        """Append an audit entry, evicting the oldest if at capacity."""
        if len(self.entries) >= self.max_entries:
            evicted = self.entries.pop(0)
            if evicted.result == GuardrailResult.ALLOWED:
                self._allowed_count -= 1
            else:
                self._denied_count -= 1
        self.entries.append(entry)
        if entry.result == GuardrailResult.ALLOWED:
            self._allowed_count += 1
        else:
            self._denied_count += 1

    @property
    def denied_count(self) -> int:
        """Count of denied enforcement events."""
        return self._denied_count

    @property
    def allowed_count(self) -> int:
        """Count of allowed enforcement events."""
        return self._allowed_count

    model_config = ConfigDict(
        str_strip_whitespace=True,
    )


class ActorLimits(BaseModel):
    """Per-actor runtime limits (spec line 27891).

    Controls the maximum number of tool calls per actor invocation and
    the maximum number of retries per tool failure within a single actor
    step.
    """

    max_tool_calls_per_invocation: int | None = Field(
        default=None,
        description=(
            "Maximum tool calls allowed per actor invocation. None means unlimited."
        ),
    )
    max_retries_per_failure: int | None = Field(
        default=None,
        description=(
            "Maximum retries per tool failure within an actor step. "
            "None means unlimited."
        ),
    )

    @field_validator("max_tool_calls_per_invocation")
    @classmethod
    def validate_max_tool_calls(
        cls: type[ActorLimits],
        v: int | None,
    ) -> int | None:
        """Validate max_tool_calls_per_invocation is positive if set."""
        if v is not None and v < 1:
            raise ValueError(f"max_tool_calls_per_invocation must be >= 1, got {v}")
        return v

    @field_validator("max_retries_per_failure")
    @classmethod
    def validate_max_retries(
        cls: type[ActorLimits],
        v: int | None,
    ) -> int | None:
        """Validate max_retries_per_failure is non-negative if set."""
        if v is not None and v < 0:
            raise ValueError(f"max_retries_per_failure must be >= 0, got {v}")
        return v

    model_config = ConfigDict(
        str_strip_whitespace=True,
    )


class AutonomyGuardrails(BaseModel):
    """Runtime autonomy constraints for plan execution.

    Extends the automation guard framework with step limits, tool
    budgets, wall-clock time limits, per-actor limits, and required
    confirmation gates.  Tracks current step count and budget spent
    for enforcement decisions.
    """

    max_steps: int | None = Field(
        default=None,
        description=(
            "Maximum number of execution steps allowed. None means unlimited."
        ),
    )
    tool_budget: float | None = Field(
        default=None,
        description=("Maximum cost budget for tool invocations. None means unlimited."),
    )
    max_wall_clock_seconds: float | None = Field(
        default=None,
        description=(
            "Maximum wall-clock time in seconds for plan execution. "
            "None means unlimited."
        ),
    )
    actor_limits: ActorLimits = Field(
        default_factory=ActorLimits,
        description="Per-actor runtime limits",
    )
    required_confirmations: list[str] = Field(
        default_factory=list,
        description=(
            "List of operation names requiring human confirmation before execution."
        ),
    )
    step_count: int = Field(
        default=0,
        ge=0,
        description="Current execution step counter",
    )
    budget_spent: float = Field(
        default=0.0,
        ge=0.0,
        description="Current cumulative budget spent on tool invocations",
    )
    start_time: str | None = Field(
        default=None,
        description="ISO-8601 timestamp of when plan execution started",
    )

    @field_validator("start_time")
    @classmethod
    def validate_start_time(
        cls: type[AutonomyGuardrails],
        v: str | None,
    ) -> str | None:
        """Validate start_time is a parseable ISO-8601 string if set."""
        if v is not None:
            try:
                datetime.fromisoformat(v)
            except (ValueError, TypeError) as exc:
                raise ValueError(
                    f"start_time must be a valid ISO-8601 timestamp, got {v!r}"
                ) from exc
        return v

    @field_validator("max_steps")
    @classmethod
    def validate_max_steps(
        cls: type[AutonomyGuardrails],
        v: int | None,
    ) -> int | None:
        """Validate max_steps is positive if set."""
        if v is not None and v < 1:
            raise ValueError(f"max_steps must be >= 1, got {v}")
        return v

    @field_validator("tool_budget")
    @classmethod
    def validate_tool_budget(
        cls: type[AutonomyGuardrails],
        v: float | None,
    ) -> float | None:
        """Validate tool_budget is non-negative if set."""
        if v is not None and v < 0.0:
            raise ValueError(f"tool_budget must be >= 0.0, got {v}")
        return v

    @field_validator("max_wall_clock_seconds")
    @classmethod
    def validate_max_wall_clock(
        cls: type[AutonomyGuardrails],
        v: float | None,
    ) -> float | None:
        """Validate max_wall_clock_seconds is positive if set."""
        if v is not None and v <= 0.0:
            raise ValueError(f"max_wall_clock_seconds must be > 0.0, got {v}")
        return v

    @model_validator(mode="after")
    def _normalise_confirmations(self) -> AutonomyGuardrails:
        """Normalise required_confirmations to lowercase for matching."""
        lowered = [c.lower() for c in self.required_confirmations]
        if lowered != self.required_confirmations:
            object.__setattr__(self, "required_confirmations", lowered)
        return self

    def check_step_limit(self) -> tuple[bool, str | None]:
        """Check if the current step count exceeds the max step limit.

        Returns:
            A tuple of (allowed, reason). If allowed is False, reason
            contains a human-readable explanation.
        """
        if self.max_steps is not None and self.step_count >= self.max_steps:
            return (
                False,
                f"Step limit reached: {self.step_count}/{self.max_steps}",
            )
        return (True, None)

    def check_tool_budget(self, tool_cost: float) -> tuple[bool, str | None]:
        """Check if the budget allows a tool invocation of the given cost.

        Args:
            tool_cost: The cost of the proposed tool invocation.

        Returns:
            A tuple of (allowed, reason).
        """
        if tool_cost < 0.0:
            raise ValueError(f"tool_cost must be >= 0.0, got {tool_cost}")
        if self.tool_budget is not None:
            projected = self.budget_spent + tool_cost
            if projected > self.tool_budget:
                return (
                    False,
                    f"Budget exceeded: {projected:.2f} > {self.tool_budget:.2f}",
                )
        return (True, None)

    def check_wall_clock(self) -> tuple[bool, str | None]:
        """Check if the plan has exceeded its wall-clock time limit.

        Returns:
            A tuple of (allowed, reason).
        """
        if self.max_wall_clock_seconds is None or self.start_time is None:
            return (True, None)
        try:
            elapsed = (
                datetime.now(UTC) - datetime.fromisoformat(self.start_time)
            ).total_seconds()
        except (ValueError, TypeError):
            return (
                False,
                f"Wall-clock check failed: malformed start_time {self.start_time!r}",
            )
        if elapsed >= self.max_wall_clock_seconds:
            return (
                False,
                f"Wall-clock limit reached: {elapsed:.1f}s >= "
                f"{self.max_wall_clock_seconds:.1f}s",
            )
        return (True, None)

    def check_actor_tool_calls(
        self,
        current_calls: int,
    ) -> tuple[bool, str | None]:
        """Check if the actor has exceeded its per-invocation tool call limit.

        Args:
            current_calls: Number of tool calls made so far in this invocation.

        Returns:
            A tuple of (allowed, reason).
        """
        limit = self.actor_limits.max_tool_calls_per_invocation
        if limit is not None and current_calls >= limit:
            return (
                False,
                f"Actor tool-call limit reached: {current_calls}/{limit}",
            )
        return (True, None)

    def check_retries_per_failure(
        self,
        current_retries: int,
    ) -> tuple[bool, str | None]:
        """Check if the current retry count exceeds the per-failure retry limit.

        Args:
            current_retries: Number of retries attempted so far for this failure.

        Returns:
            A tuple of (allowed, reason).
        """
        limit = self.actor_limits.max_retries_per_failure
        if limit is not None and current_retries >= limit:
            return (
                False,
                f"Retry limit reached: {current_retries}/{limit}",
            )
        return (True, None)

    def check_confirmation_required(self, operation: str) -> tuple[bool, str | None]:
        """Check if an operation requires human confirmation.

        Args:
            operation: The operation name to check (case-insensitive).

        Returns:
            A tuple of (confirmation_required, reason). If True, the
            operation needs human confirmation before proceeding.
        """
        if operation.lower() in self.required_confirmations:
            return (
                True,
                f"Operation '{operation}' requires human confirmation",
            )
        return (False, None)

    def increment_step(self) -> None:
        """Increment the step counter by one."""
        self.step_count += 1

    def record_cost(self, cost: float) -> None:
        """Record a tool invocation cost.

        Args:
            cost: The cost to add to the budget spent.
        """
        if cost < 0.0:
            raise ValueError(f"cost must be >= 0.0, got {cost}")
        self.budget_spent += cost

    def mark_started(self) -> None:
        """Record the plan execution start time for wall-clock tracking."""
        self.start_time = datetime.now(UTC).isoformat()

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# Re-export threading lock for service-level thread safety
GuardrailLock = threading.Lock
