"""Safety Profile domain model for CleverAgents v3.

Defines safety constraints for plan execution: allowed skill categories,
sandbox/checkpoint requirements, unsafe-tool gating, human-approval flag,
and cost/retry limits.

## Resolution Precedence

Safety profiles are resolved at plan-use time following precedence:
``plan > action > project > global``.  The ``SafetyProfileRef`` captures
which level the profile was resolved from via ``SafetyProfileProvenance``.

## Interaction with Automation Profiles

When an Action carries both a ``safety_profile`` and an ``automation_profile``,
the SafetyProfile takes precedence for overlapping safety flags
(``require_sandbox``, ``require_checkpoints``, ``allow_unsafe_tools``).
The AutomationProfile controls autonomy thresholds (confidence-based gating)
while the SafetyProfile enforces hard safety constraints.

## Enforcement

Safety profile enforcement is implemented in the tool execution pipeline.
The ``resolve_safety_profile`` function selects the highest-precedence
profile, and ``ToolRuntime._enforce_capabilities`` enforces the resolved
profile's constraints at tool activation and execution time.

Based on ``docs/specification.md`` sections "Automation Profiles" and
"Guardrails".
"""

from __future__ import annotations

from enum import StrEnum
from pathlib import Path
from typing import Any

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

# ---------------------------------------------------------------------------
# Provenance enum
# ---------------------------------------------------------------------------


class SafetyProfileProvenance(StrEnum):
    """Where the resolved safety profile came from.

    Resolved at ``plan use`` time using plan > action > project > global
    precedence.  Once set, it is locked to the plan.
    """

    PLAN = "plan"
    ACTION = "action"
    PROJECT = "project"
    GLOBAL = "global"


# ---------------------------------------------------------------------------
# SafetyProfile
# ---------------------------------------------------------------------------


class SafetyProfile(BaseModel):
    """Domain model for a Safety Profile.

    Controls safety constraints for plan execution including which skill
    categories are allowed, sandbox/checkpoint requirements, unsafe-tool
    gating, human-approval flags, and cost/retry limits.

    When an Action carries both a SafetyProfile and an AutomationProfile,
    the SafetyProfile takes precedence for overlapping safety flags
    (``require_sandbox``, ``require_checkpoints``, ``allow_unsafe_tools``).

    Attributes:
        allowed_skill_categories: Skill categories permitted for execution.
            Empty list means all categories are allowed.
        require_sandbox: Whether a sandbox is required for execution.
        require_checkpoints: Whether checkpoints are required.
        require_human_approval: Whether human approval is required before
            executing each action step.
        allow_unsafe_tools: Whether tools marked as unsafe may be invoked.
        max_cost_per_plan: Maximum cost in USD allowed per plan execution.
            ``None`` means no limit.
        max_retries_per_step: Maximum retry attempts per action step.
        max_total_cost: Maximum total cost in USD across all plans.
            ``None`` means no limit.  Must be >= ``max_cost_per_plan``
            when both are set.
    """

    # -- Skill categories --------------------------------------------------

    allowed_skill_categories: list[str] = Field(
        default_factory=list,
        description=(
            "Skill categories allowed for execution. "
            "Empty list means all categories are allowed."
        ),
    )

    # -- Sandbox / checkpoint requirements ---------------------------------

    require_sandbox: bool = Field(
        True,
        description="Whether a sandbox is required for execution",
    )
    require_checkpoints: bool = Field(
        True,
        description="Whether checkpoints are required during execution",
    )

    # -- Human approval ----------------------------------------------------

    require_human_approval: bool = Field(
        False,
        description=(
            "Whether human approval is required before executing each action step"
        ),
    )

    # -- Unsafe tool gating ------------------------------------------------

    allow_unsafe_tools: bool = Field(
        False,
        description=(
            "Whether tools marked as unsafe may be invoked. "
            "When False, unsafe tools are blocked."
        ),
    )

    # -- Cost / retry limits -----------------------------------------------

    max_cost_per_plan: float | None = Field(
        default=None,
        ge=0.0,
        description="Maximum cost in USD allowed per plan. None means no limit.",
    )
    max_retries_per_step: int = Field(
        3,
        ge=0,
        le=100,
        description="Maximum retry attempts per action step",
    )
    max_total_cost: float | None = Field(
        default=None,
        ge=0.0,
        description=(
            "Maximum total cost in USD across all plans. None means no limit."
        ),
    )

    # -- Field validation --------------------------------------------------

    @field_validator("allowed_skill_categories", mode="before")
    @classmethod
    def _sanitize_categories(
        cls: type[SafetyProfile], v: list[str] | None
    ) -> list[str]:
        """Trim, drop blanks, and de-duplicate while preserving order."""
        if v is None:
            return []
        if not isinstance(v, list):
            raise ValueError("allowed_skill_categories must be a list")
        seen: set[str] = set()
        result: list[str] = []
        for item in v:
            if not isinstance(item, str):
                raise ValueError(
                    f"Each skill category must be a string, "
                    f"got {type(item).__name__}: {item!r}"
                )
            trimmed = item.strip()
            if trimmed and trimmed not in seen:
                seen.add(trimmed)
                result.append(trimmed)
        return result

    # -- Cross-field validation --------------------------------------------

    @model_validator(mode="after")
    def _validate_cost_limits(self) -> SafetyProfile:
        """Ensure max_cost_per_plan <= max_total_cost when both are set."""
        if (
            self.max_cost_per_plan is not None
            and self.max_total_cost is not None
            and self.max_cost_per_plan > self.max_total_cost
        ):
            raise ValueError(
                f"max_cost_per_plan ({self.max_cost_per_plan}) must not exceed "
                f"max_total_cost ({self.max_total_cost})"
            )
        return self

    # -- Factories ---------------------------------------------------------

    @classmethod
    def from_config(cls, config: dict[str, Any]) -> SafetyProfile:
        """Create a SafetyProfile from a configuration dict.

        Accepts the flat mapping produced by YAML loading.

        Args:
            config: Dictionary with safety profile fields.

        Returns:
            A validated ``SafetyProfile`` instance.
        """
        return cls.model_validate(config)

    @classmethod
    def from_yaml(cls, path: str | Path) -> SafetyProfile:
        """Create a SafetyProfile from a YAML file.

        Args:
            path: Path to the YAML configuration file.

        Returns:
            The loaded ``SafetyProfile``.

        Raises:
            FileNotFoundError: If the file does not exist.
            ValueError: If the YAML content is invalid.
        """
        file_path = Path(path)
        if not file_path.exists():
            raise FileNotFoundError(f"Safety profile YAML file not found: {file_path}")
        with open(file_path, encoding="utf-8") as fh:
            config = yaml.safe_load(fh)
        if not isinstance(config, dict):
            raise ValueError("Safety profile YAML must contain a mapping (dict)")
        return cls.from_config(config)

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


# ---------------------------------------------------------------------------
# SafetyProfileRef (for plan-level resolution)
# ---------------------------------------------------------------------------


class SafetyProfileRef(BaseModel):
    """Reference to the resolved safety profile for a plan.

    Resolved at ``plan use`` time using plan > action > project > global
    precedence.  Once set, it is locked to the plan.
    """

    profile_name: str = Field(
        ...,
        min_length=1,
        description="Safety profile name or identifier",
    )
    provenance: SafetyProfileProvenance = Field(
        ...,
        description="Where this safety profile was resolved from",
    )

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


# ---------------------------------------------------------------------------
# Default constant
# ---------------------------------------------------------------------------


DEFAULT_SAFETY_PROFILE: SafetyProfile = SafetyProfile(
    allowed_skill_categories=[],
    require_sandbox=True,
    require_checkpoints=True,
    require_human_approval=False,
    allow_unsafe_tools=False,
    max_cost_per_plan=None,
    max_retries_per_step=3,
    max_total_cost=None,
)


# ---------------------------------------------------------------------------
# Resolution
# ---------------------------------------------------------------------------


def resolve_safety_profile(
    *,
    plan_profile: SafetyProfile | None = None,
    action_profile: SafetyProfile | None = None,
    project_profile: SafetyProfile | None = None,
    global_profile: SafetyProfile | None = None,
) -> tuple[SafetyProfile, SafetyProfileProvenance]:
    """Resolve the effective safety profile using precedence hierarchy.

    Resolution order (highest to lowest):
    ``plan > action > project > global``.

    The effective profile is determined at ``plan use`` time.  Once
    resolved, the profile is **locked to that plan** -- subsequent
    changes to project or global profiles do not affect running plans.

    When all levels are ``None`` the ``DEFAULT_SAFETY_PROFILE`` is
    returned with ``GLOBAL`` provenance.

    Args:
        plan_profile: Profile set directly on the plan (highest).
        action_profile: Profile inherited from the action template.
        project_profile: Profile inherited from the project.
        global_profile: Global default profile (lowest).

    Returns:
        Tuple of ``(resolved_profile, provenance)`` where *provenance*
        indicates which level supplied the winning profile.
    """
    candidates: list[tuple[SafetyProfile | None, SafetyProfileProvenance]] = [
        (plan_profile, SafetyProfileProvenance.PLAN),
        (action_profile, SafetyProfileProvenance.ACTION),
        (project_profile, SafetyProfileProvenance.PROJECT),
        (global_profile, SafetyProfileProvenance.GLOBAL),
    ]

    for profile, provenance in candidates:
        if profile is not None:
            return profile, provenance

    # No explicit profile at any level -- fall back to the default.
    return DEFAULT_SAFETY_PROFILE, SafetyProfileProvenance.GLOBAL
