"""Action domain model for CleverAgents v3.

An **Action** is a YAML-defined, reusable plan template specifying a
description, definition of done, strategy/execution actors, typed arguments,
and optional invariants. Project-agnostic until bound to one or more
projects via ``agents plan use``, which instantiates a Plan.

## State Lifecycle

Actions have two states:

| State        | Description                                         |
|-------------|-----------------------------------------------------|
| ``available`` | Can be used to create plans                       |
| ``archived``  | Retired; cannot create new plans                  |

There is no draft state -- actions are ``available`` immediately on creation.

## Arguments System

Actions define typed arguments via ``ActionArgument``:

- Supported types: ``string``, ``integer``, ``float``, ``boolean``, ``path``
- Arguments may be ``required`` or have ``default`` values
- Values are coerced and validated at plan creation time
- Arguments map to ``--<name>`` CLI flags in ``agents plan use``

## YAML-First Configuration

Actions are created from YAML config files:

```bash
agents action create --config ./actions/code-coverage.yaml
```

Required config keys: ``name``, ``description``

Optional keys: ``definition_of_done``, ``strategy_actor``,
``execution_actor``, ``arguments``, ``invariants``, ``reusable``,
``automation_profile``, ``metadata``

## Templating

Action descriptions and definitions of done support ``${placeholder}``
syntax that is resolved against argument values at plan creation time.

Based on ``docs/specification.md`` and ADR-004 (Pydantic Validation).
"""

from __future__ import annotations

import re
import string
from datetime import datetime
from enum import StrEnum
from typing import Any

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

from cleveragents.domain.models.core.plan import NamespacedName
from cleveragents.domain.models.core.safety_profile import SafetyProfile

# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------


class ActionState(StrEnum):
    """States for actions.

    Per spec, actions have only ``available`` / ``archived`` states.
    Default is ``available`` -- there is no draft state.
    """

    AVAILABLE = "available"
    ARCHIVED = "archived"


class ArgumentType(StrEnum):
    """Supported types for action arguments.

    Values align with the specification JSON schema:
    ``string | integer | float | boolean | list``.
    """

    STRING = "string"
    INTEGER = "integer"
    FLOAT = "float"
    BOOLEAN = "boolean"
    LIST = "list"

    @classmethod
    def _missing_(cls, value: object) -> ArgumentType | None:
        """Allow legacy short names ('str', 'int', 'bool') to resolve."""
        if isinstance(value, str):
            mapped = _ARGUMENT_TYPE_LEGACY_MAP.get(value.lower())
            if mapped is not None:
                return cls(mapped)
        return None


# Mapping from legacy short type names to spec-aligned full names
_ARGUMENT_TYPE_LEGACY_MAP: dict[str, str] = {
    "str": "string",
    "int": "integer",
    "bool": "boolean",
}


class ArgumentRequirement(StrEnum):
    """Whether an argument is required or optional."""

    REQUIRED = "required"
    OPTIONAL = "optional"


# ---------------------------------------------------------------------------
# ActionArgument
# ---------------------------------------------------------------------------

_COERCE_MAP: dict[ArgumentType, type | tuple[type, ...]] = {
    ArgumentType.STRING: str,
    ArgumentType.INTEGER: int,
    ArgumentType.FLOAT: (int, float),
    ArgumentType.BOOLEAN: bool,
    ArgumentType.LIST: list,
}

_BOOL_TRUTHY = frozenset({"true", "1", "yes", "on"})
_BOOL_FALSY = frozenset({"false", "0", "no", "off"})


class ActionArgument(BaseModel):
    """Definition of an action argument.

    Arguments are values that will be:
    - Injected into the description and/or definition of done (via templating)
    - Passed into the context of the actors
    - Required when using the action on projects

    Parse format: ``name:type:required|optional[:description]``
    """

    name: str = Field(
        ...,
        min_length=1,
        max_length=64,
        description="Argument name (used in templates as {{ name }})",
    )
    arg_type: ArgumentType = Field(
        ArgumentType.STRING,
        description="Type of the argument",
    )
    requirement: ArgumentRequirement = Field(
        ArgumentRequirement.REQUIRED,
        description="Whether the argument is required or optional",
    )
    description: str = Field(
        "",
        max_length=500,
        description="Human-readable description of the argument",
    )
    default_value: str | int | float | bool | list[str] | None = Field(
        default=None,
        description="Default value for optional arguments",
    )
    validation_pattern: str | None = Field(
        default=None,
        description="Regex pattern for string validation",
    )
    min_value: float | None = Field(
        default=None,
        description="Minimum value for numeric arguments",
    )
    max_value: float | None = Field(
        default=None,
        description="Maximum value for numeric arguments",
    )

    @field_validator("name")
    @classmethod
    def validate_name(cls: type[ActionArgument], v: str) -> str:
        """Validate argument name is a valid Python identifier."""
        if not v.isidentifier():
            raise ValueError(
                "Argument name must be a valid Python identifier "
                "(alphanumeric and underscores, not starting with a digit)"
            )
        return v

    # -- Factory: parse from CLI colon-delimited string --------------------

    @classmethod
    def parse(cls, arg_string: str) -> ActionArgument:
        """Parse an argument definition string.

        Format: ``name:type:required|optional[:description]``

        Examples::

            "target_coverage:integer:required:Target coverage percentage"
            "test_framework:string:optional:Test framework to use"
        """
        parts = arg_string.split(":", 3)
        if len(parts) < 3:
            raise ValueError(
                f"Invalid argument format: {arg_string}. "
                "Expected: name:type:required|optional[:description]"
            )

        name = parts[0]
        type_str = parts[1].lower()
        req_str = parts[2].lower()
        desc = parts[3] if len(parts) > 3 else ""

        # Parse type -- ArgumentType._missing_ handles legacy short names
        try:
            arg_type = ArgumentType(type_str)
        except ValueError as e:
            valid_types = ", ".join(t.value for t in ArgumentType)
            raise ValueError(
                f"Invalid argument type: {type_str}. Valid types: {valid_types}"
            ) from e

        # Parse requirement
        try:
            requirement = ArgumentRequirement(req_str)
        except ValueError as e:
            raise ValueError(
                f"Invalid requirement: {req_str}. Use 'required' or 'optional'"
            ) from e

        return cls(
            name=name,
            arg_type=arg_type,
            requirement=requirement,
            description=desc,
        )

    # -- Factory: build from YAML mapping ----------------------------------

    @classmethod
    def from_mapping(cls, data: dict[str, Any]) -> ActionArgument:
        """Build an ``ActionArgument`` from a YAML mapping.

        The mapping keys follow the specification schema::

            name: target_coverage
            type: integer
            required: true
            description: Target coverage percentage
            default: 80
            min_value: 0
            max_value: 100
        """
        if "name" not in data:
            raise ValueError("ActionArgument mapping must include 'name'")
        if "type" not in data:
            raise ValueError("ActionArgument mapping must include 'type'")

        arg_type = ArgumentType(str(data["type"]).lower())
        req_raw = data.get("required", False)
        requirement = (
            ArgumentRequirement.REQUIRED if req_raw else ArgumentRequirement.OPTIONAL
        )

        return cls(
            name=data["name"],
            arg_type=arg_type,
            requirement=requirement,
            description=data.get("description", ""),
            default_value=data.get("default"),
            validation_pattern=data.get("validation_pattern"),
            min_value=data.get("min_value"),
            max_value=data.get("max_value"),
        )

    # -- Coerce a CLI ``--arg name=value`` string to the declared type ------

    def coerce_value(self, raw: str) -> str | int | float | bool | list[str]:
        """Coerce a raw CLI string value to the declared argument type.

        Raises ``ValueError`` on failed coercion.
        """
        if self.arg_type == ArgumentType.STRING:
            return raw
        if self.arg_type == ArgumentType.INTEGER:
            try:
                return int(raw)
            except ValueError as e:
                raise ValueError(
                    f"Cannot coerce '{raw}' to integer for argument '{self.name}'"
                ) from e
        if self.arg_type == ArgumentType.FLOAT:
            try:
                return float(raw)
            except ValueError as e:
                raise ValueError(
                    f"Cannot coerce '{raw}' to float for argument '{self.name}'"
                ) from e
        if self.arg_type == ArgumentType.BOOLEAN:
            lower = raw.lower()
            if lower in _BOOL_TRUTHY:
                return True
            if lower in _BOOL_FALSY:
                return False
            raise ValueError(
                f"Cannot coerce '{raw}' to boolean for argument '{self.name}'. "
                "Use true/false, 1/0, yes/no, or on/off."
            )
        if self.arg_type == ArgumentType.LIST:
            return [item.strip() for item in raw.split(",") if item.strip()]
        raise ValueError(f"Unknown argument type: {self.arg_type}")  # pragma: no cover

    def __str__(self) -> str:
        """Return the argument definition string."""
        return (
            f"{self.name}:{self.arg_type.value}:"
            f"{self.requirement.value}:{self.description}"
        )

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


# ---------------------------------------------------------------------------
# Action
# ---------------------------------------------------------------------------


class Action(BaseModel):
    """Domain model for an Action.

    An Action is a YAML-defined, reusable plan template. The **namespaced name**
    (``[server:][namespace/]<name>``) is the unique identifier -- there is no
    separate ULID for actions.

    Actions are created via ``agents action create --config <file>`` and define:
    - What actors to use for strategy, execution, review, apply, estimation
    - What arguments the action accepts
    - Completion criteria (definition of done)
    - Invariants carried forward when the action is used

    When an action is "used" on projects, it creates a Plan in Strategize phase.
    """

    # Identity -- namespaced name IS the unique identifier (no action_id)
    namespaced_name: NamespacedName = Field(
        ..., description="Unique namespaced name of the action"
    )

    # Descriptions -- ``description`` is REQUIRED per spec
    description: str = Field(
        ...,
        min_length=1,
        description="Short description of the action (required)",
    )
    long_description: str | None = Field(
        None,
        description="Detailed description for reusable actions",
    )

    # Definition of Done -- REQUIRED
    definition_of_done: str = Field(
        ...,
        min_length=1,
        description="Explicit, testable completion criteria",
    )

    # Actors -- strategy + execution required; others optional
    strategy_actor: str = Field(
        ...,
        description="Actor for the Strategize phase (namespaced name)",
    )
    execution_actor: str = Field(
        ...,
        description="Actor for the Execute phase (namespaced name)",
    )
    review_actor: str | None = Field(
        default=None,
        description="Optional actor for reviewing results",
    )
    apply_actor: str | None = Field(
        default=None,
        description="Optional actor for the Apply phase",
    )
    estimation_actor: str | None = Field(
        default=None,
        description="Optional actor for effort/cost estimation",
    )
    invariant_actor: str | None = Field(
        default=None,
        description="Optional Invariant Reconciliation Actor",
    )

    # Arguments (typed inputs schema)
    arguments: list[ActionArgument] = Field(
        default_factory=list,
        description="Typed arguments users supply via --arg name=value",
    )

    # inputs_schema -- optional JSON Schema dict for advanced validation
    inputs_schema: dict[str, Any] | None = Field(
        default=None,
        description="Optional JSON Schema dict for input validation",
    )

    # Behavior flags
    reusable: bool = Field(
        True,
        description="If true, action remains available after use",
    )
    read_only: bool = Field(
        False,
        description="If true, action only performs read operations",
    )

    # State -- default is available (no draft state per spec)
    state: ActionState = Field(
        ActionState.AVAILABLE,
        description="Current state of the action",
    )

    # Automation profile -- resolved at plan-use time
    automation_profile: str | None = Field(
        default=None,
        description="Default automation profile for plans from this action",
    )

    # Safety profile -- optional safety constraints for plans from this action
    safety_profile: SafetyProfile | None = Field(
        default=None,
        description="Optional safety profile with skill/cost/sandbox constraints",
    )

    # Invariants -- carried forward as plan-level invariants
    invariants: list[str] = Field(
        default_factory=list,
        description="Constraints carried forward when the action is used",
    )

    # Timestamps
    created_at: datetime = Field(default_factory=datetime.now)
    updated_at: datetime = Field(default_factory=datetime.now)

    # Metadata
    created_by: str | None = Field(None, description="User/session that created action")
    tags: list[str] = Field(default_factory=list, description="Tags for organization")

    # -- Invariant list sanitization ----------------------------------------

    @field_validator("invariants", mode="before")
    @classmethod
    def _sanitize_invariants(cls: type[Action], v: list[str] | None) -> list[str]:
        """Trim, drop blanks, and de-duplicate while preserving order."""
        if v is None:
            return []
        seen: set[str] = set()
        result: list[str] = []
        for item in v:
            trimmed = item.strip()
            if trimmed and trimmed not in seen:
                seen.add(trimmed)
                result.append(trimmed)
        return result

    # -- inputs_schema must be JSON-serializable ----------------------------

    @field_validator("inputs_schema", mode="before")
    @classmethod
    def _validate_inputs_schema(
        cls: type[Action], v: dict[str, Any] | None
    ) -> dict[str, Any] | None:
        """Validate that inputs_schema is a JSON-serializable dict."""
        if v is None:
            return None
        if not isinstance(v, dict):
            raise ValueError("inputs_schema must be a dict")
        return v

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

    @model_validator(mode="after")
    def _validate_model(self) -> Action:
        """Cross-field validation: automation_profile trimming."""
        if self.automation_profile is not None:
            trimmed = self.automation_profile.strip()
            if not trimmed:
                object.__setattr__(self, "automation_profile", None)
            else:
                object.__setattr__(self, "automation_profile", trimmed)
        return self

    # -- Properties ---------------------------------------------------------

    @property
    def required_arguments(self) -> list[ActionArgument]:
        """Get list of required arguments."""
        return [
            arg
            for arg in self.arguments
            if arg.requirement == ArgumentRequirement.REQUIRED
        ]

    @property
    def optional_arguments(self) -> list[ActionArgument]:
        """Get list of optional arguments."""
        return [
            arg
            for arg in self.arguments
            if arg.requirement == ArgumentRequirement.OPTIONAL
        ]

    # -- Argument validation ------------------------------------------------

    def validate_arguments(
        self, provided_args: dict[str, str | int | float | bool | list[str]]
    ) -> list[str]:
        """Validate provided arguments against the action's argument schema.

        Returns a list of validation error messages (empty if valid).
        """
        errors: list[str] = []

        # Check for missing required arguments
        for arg in self.required_arguments:
            if arg.name not in provided_args:
                errors.append(f"Missing required argument: {arg.name}")

        # Check for unknown arguments
        known_names = {arg.name for arg in self.arguments}
        for name in provided_args:
            if name not in known_names:
                errors.append(f"Unknown argument: {name}")

        # Type validation
        for arg in self.arguments:
            if arg.name in provided_args:
                value = provided_args[arg.name]
                type_name = type(value).__name__
                if arg.arg_type == ArgumentType.INTEGER:
                    if not isinstance(value, int):
                        errors.append(
                            f"Argument {arg.name} must be an integer, got {type_name}"
                        )
                    elif arg.min_value is not None and value < arg.min_value:
                        errors.append(f"Argument {arg.name} must be >= {arg.min_value}")
                    elif arg.max_value is not None and value > arg.max_value:
                        errors.append(f"Argument {arg.name} must be <= {arg.max_value}")
                elif arg.arg_type == ArgumentType.FLOAT:
                    if not isinstance(value, int | float):
                        errors.append(
                            f"Argument {arg.name} must be a number, got {type_name}"
                        )
                elif arg.arg_type == ArgumentType.BOOLEAN:
                    if not isinstance(value, bool):
                        errors.append(
                            f"Argument {arg.name} must be a boolean, got {type_name}"
                        )
                elif arg.arg_type == ArgumentType.LIST and not isinstance(value, list):
                    errors.append(
                        f"Argument {arg.name} must be a list, got {type_name}"
                    )

        return errors

    # -- Templating ---------------------------------------------------------

    def render_template(self, template_text: str, args: dict[str, Any]) -> str:
        """Render a template string with argument values.

        Uses Python ``string.Template`` with ``${name}`` placeholders.
        Raises ``ValueError`` if required placeholders are missing.

        Note: Callers in the application layer should sanitize argument
        values via ``PromptSanitizer.sanitize_user_input()`` before
        passing them here (mechanism 1 — input sanitization).
        """
        # Find all placeholders in the template
        pattern = re.compile(r"\$\{(\w+)\}|\$(\w+)")
        placeholders: set[str] = set()
        for match in pattern.finditer(template_text):
            placeholders.add(match.group(1) or match.group(2))

        # Check for missing values
        missing = placeholders - set(args.keys())
        if missing:
            raise ValueError(
                f"Missing template placeholder values: {', '.join(sorted(missing))}"
            )

        tmpl = string.Template(template_text)
        return tmpl.safe_substitute(args)

    def render_description(self, args: dict[str, Any]) -> str:
        """Render the description template with argument values."""
        return self.render_template(self.description, args)

    def render_definition_of_done(self, args: dict[str, Any]) -> str:
        """Render the definition of done template with argument values."""
        return self.render_template(self.definition_of_done, args)

    # -- YAML-first factories -----------------------------------------------

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

        The config follows the spec schema (see ``docs/specification.md``
        Action Configuration Files section).
        """
        if "name" not in config:
            raise ValueError("Action config must include 'name'")
        if "description" not in config:
            raise ValueError("Action config must include 'description'")
        if "strategy_actor" not in config:
            raise ValueError("Action config must include 'strategy_actor'")
        if "execution_actor" not in config:
            raise ValueError("Action config must include 'execution_actor'")
        if "definition_of_done" not in config:
            raise ValueError("Action config must include 'definition_of_done'")

        # Parse arguments from list of mappings
        arguments: list[ActionArgument] = []
        for arg_data in config.get("arguments", []):
            arguments.append(ActionArgument.from_mapping(arg_data))

        # Parse optional safety_profile mapping
        safety_profile_raw = config.get("safety_profile")
        safety_profile: SafetyProfile | None = None
        if isinstance(safety_profile_raw, dict):
            safety_profile = SafetyProfile.from_config(safety_profile_raw)
        elif isinstance(safety_profile_raw, SafetyProfile):
            safety_profile = safety_profile_raw

        return cls(
            namespaced_name=NamespacedName.parse(config["name"]),
            description=config["description"],
            long_description=config.get("long_description"),
            definition_of_done=config["definition_of_done"],
            strategy_actor=config["strategy_actor"],
            execution_actor=config["execution_actor"],
            review_actor=config.get("review_actor"),
            apply_actor=config.get("apply_actor"),
            estimation_actor=config.get("estimation_actor"),
            invariant_actor=config.get("invariant_actor"),
            arguments=arguments,
            inputs_schema=config.get("inputs_schema"),
            reusable=config.get("reusable", True),
            read_only=config.get("read_only", False),
            state=ActionState(config.get("state", "available")),
            automation_profile=config.get("automation_profile"),
            safety_profile=safety_profile,
            invariants=config.get("invariants", []),
            tags=config.get("tags", []),
            created_by=config.get("created_by"),
        )

    # -- Stable CLI rendering ------------------------------------------------

    def as_cli_dict(self) -> dict[str, Any]:
        """Return a stable dict for CLI output rendering.

        Keys are ordered for deterministic display.
        """
        result: dict[str, Any] = {
            "name": str(self.namespaced_name),
            "description": self.description,
            "state": self.state.value,
            "strategy_actor": self.strategy_actor,
            "execution_actor": self.execution_actor,
        }

        if self.long_description:
            result["long_description"] = self.long_description
        result["definition_of_done"] = self.definition_of_done

        # Optional actors
        if self.review_actor:
            result["review_actor"] = self.review_actor
        if self.apply_actor:
            result["apply_actor"] = self.apply_actor
        if self.estimation_actor:
            result["estimation_actor"] = self.estimation_actor
        if self.invariant_actor:
            result["invariant_actor"] = self.invariant_actor

        # Arguments
        if self.arguments:
            result["arguments"] = [
                {
                    "name": arg.name,
                    "type": arg.arg_type.value,
                    "required": arg.requirement == ArgumentRequirement.REQUIRED,
                    "description": arg.description,
                }
                for arg in self.arguments
            ]

        # Behavior
        result["reusable"] = self.reusable
        result["read_only"] = self.read_only

        # Profile & invariants
        if self.automation_profile:
            result["automation_profile"] = self.automation_profile
        if self.safety_profile is not None:
            result["safety_profile"] = self.safety_profile.model_dump(
                mode="json",
            )
        if self.invariants:
            result["invariants"] = self.invariants

        # Metadata
        result["created_at"] = self.created_at.isoformat()
        if self.tags:
            result["tags"] = self.tags

        return result

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