"""Action YAML configuration schema, validation, and loading.

Provides :class:`ActionConfigSchema`, a Pydantic model that:

* Loads raw YAML (string or file) and validates it against the action schema.
* Normalizes camelCase keys to snake_case before validation.
* Interpolates ``${ENV_VAR}`` placeholders from environment variables.
* Trims, de-duplicates, and drops blank invariant strings.
* Produces clear, actionable error messages for every validation failure.

Schema definition lives in ``docs/schema/action.schema.yaml``.
Example configs live under ``examples/actions/``.

Based on:
- docs/specification.md — Action Configuration Files + JSON Schema
- implementation_plan.md — Task A2b.gamma
"""

from __future__ import annotations

import logging
import os
import re
from pathlib import Path
from typing import Any

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

logger = logging.getLogger(__name__)

# ────────────────────────────────────────────────────────────
# Constants
# ────────────────────────────────────────────────────────────

#: Pattern for ``<namespace>/<name>`` with hyphens, underscores, lowercase alphanum.
NAMESPACED_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*/[a-z0-9][a-z0-9_-]*$")

#: Pattern for ``${VAR}`` environment variable references.
_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")

#: Supported action states (draft excluded from YAML configs).
_VALID_STATES = frozenset({"available", "archived"})

#: Supported argument types.
_VALID_ARG_TYPES = frozenset({"string", "integer", "float", "boolean", "list"})

#: camelCase → snake_case mapping for top-level and nested keys.
_CAMEL_TO_SNAKE: dict[str, str] = {
    "strategyActor": "strategy_actor",
    "executionActor": "execution_actor",
    "definitionOfDone": "definition_of_done",
    "longDescription": "long_description",
    "estimationActor": "estimation_actor",
    "reviewActor": "review_actor",
    "applyActor": "apply_actor",
    "invariantActor": "invariant_actor",
    "readOnly": "read_only",
    "automationProfile": "automation_profile",
    "inputsSchema": "inputs_schema",
    "validationPattern": "validation_pattern",
    "minValue": "min_value",
    "maxValue": "max_value",
    "schemaVersion": "schema_version",
}


# ────────────────────────────────────────────────────────────
# Nested models
# ────────────────────────────────────────────────────────────


class ActionArgumentSchema(BaseModel):
    """Schema for a single typed action argument.

    Arguments are values supplied by the user at plan-use time
    (``agents plan use --arg name=value``).
    """

    name: str = Field(
        ...,
        min_length=1,
        max_length=64,
        description="Argument name (used as key in --arg name=value).",
    )
    type: str = Field(
        "string",
        description="Data type: string | integer | float | boolean | list.",
    )
    required: bool = Field(
        False,
        description="Whether the argument must be provided.",
    )
    description: str = Field(
        "",
        max_length=500,
        description="Human-readable description of the argument.",
    )
    default: str | int | float | bool | list[str] | None = Field(
        default=None,
        description="Default value when argument is not provided.",
    )
    validation_pattern: str | None = Field(
        default=None,
        description="Regex pattern for string argument 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, v: str) -> str:
        """Ensure argument name is a valid identifier."""
        if not v.replace("-", "_").isidentifier():
            raise ValueError(
                f"Argument name '{v}' is not a valid identifier. "
                "Use alphanumeric characters, underscores, or hyphens."
            )
        return v

    @field_validator("type")
    @classmethod
    def validate_type(cls, v: str) -> str:
        """Ensure argument type is one of the allowed types."""
        v_lower = v.lower()
        if v_lower not in _VALID_ARG_TYPES:
            valid = ", ".join(sorted(_VALID_ARG_TYPES))
            raise ValueError(f"Invalid argument type '{v}'. Allowed types: {valid}.")
        return v_lower

    model_config = ConfigDict(
        str_strip_whitespace=True,
        extra="forbid",
    )


# ────────────────────────────────────────────────────────────
# Main schema model
# ────────────────────────────────────────────────────────────


class ActionConfigSchema(BaseModel):
    """Pydantic model for an action YAML configuration file.

    Validates all fields described in ``docs/schema/action.schema.yaml``.

    Create instances via the factory class methods:
    - :meth:`from_yaml` — parse a YAML string
    - :meth:`from_yaml_file` — parse a YAML file from disk
    """

    # ─── Identity ───────────────────────────────────────────
    schema_version: str = Field(
        "1",
        description="Schema version for forward compatibility.",
    )
    name: str = Field(
        ...,
        description=("Namespaced action name in namespace/name format."),
    )
    description: str = Field(
        ...,
        description="Short description of the action.",
    )
    long_description: str | None = Field(
        default=None,
        description="Detailed multi-line description.",
    )

    # ─── Lifecycle Actors ───────────────────────────────────
    strategy_actor: str = Field(
        ...,
        description="Actor for the Strategize phase (namespaced).",
    )
    execution_actor: str = Field(
        ...,
        description="Actor for the Execute phase (namespaced).",
    )
    estimation_actor: str | None = Field(
        default=None,
        description="Optional actor for cost/risk estimation.",
    )
    review_actor: str | None = Field(
        default=None,
        description="Optional actor for code review / QA.",
    )
    apply_actor: str | None = Field(
        default=None,
        description="Optional actor for the Apply phase.",
    )
    invariant_actor: str | None = Field(
        default=None,
        description="Optional actor for invariant reconciliation.",
    )

    # ─── Completion Criteria ────────────────────────────────
    definition_of_done: str = Field(
        ...,
        min_length=1,
        description="Testable criteria defining task completion.",
    )

    # ─── Behaviour ──────────────────────────────────────────
    reusable: bool = Field(
        True,
        description="Whether the action persists after use.",
    )
    read_only: bool = Field(
        False,
        description="Restrict the action to read-only operations.",
    )
    state: str = Field(
        "available",
        description="Action state: available | archived.",
    )

    # ─── Arguments ──────────────────────────────────────────
    arguments: list[ActionArgumentSchema] = Field(
        default_factory=list,
        description="Typed arguments accepted by the action.",
    )

    # ─── Automation ─────────────────────────────────────────
    automation_profile: str | None = Field(
        default=None,
        description="Default automation profile for derived plans.",
    )

    # ─── Invariants ─────────────────────────────────────────
    invariants: list[str] = Field(
        default_factory=list,
        description="Constraints carried as plan-level invariants.",
    )

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

    # ─── Pydantic Config ───────────────────────────────────
    model_config = ConfigDict(
        str_strip_whitespace=True,
        extra="forbid",
    )

    # ────────────────────────────────────────────────────────
    # Field validators
    # ────────────────────────────────────────────────────────

    @field_validator("name")
    @classmethod
    def validate_namespaced_name(cls, v: str) -> str:
        """Validate that name follows namespace/name pattern."""
        if not NAMESPACED_NAME_RE.match(v):
            raise ValueError(
                f"Invalid action name '{v}'. "
                "Names must follow the namespace/name format "
                "(e.g. 'local/my-action'). "
                "Only alphanumeric, hyphens, and underscores are allowed."
            )
        return v

    @field_validator(
        "strategy_actor",
        "execution_actor",
    )
    @classmethod
    def validate_required_actor_name(cls, v: str) -> str:
        """Validate required actor names follow namespace/name."""
        if not NAMESPACED_NAME_RE.match(v):
            raise ValueError(
                f"Invalid actor name '{v}'. "
                "Actor names must follow the namespace/name format "
                "(e.g. 'openai/gpt-4'). "
                "Only alphanumeric, hyphens, and underscores are allowed."
            )
        return v

    @field_validator(
        "estimation_actor",
        "review_actor",
        "apply_actor",
        "invariant_actor",
    )
    @classmethod
    def validate_optional_actor_name(cls, v: str | None) -> str | None:
        """Validate optional actor names if provided."""
        if v is not None and not NAMESPACED_NAME_RE.match(v):
            raise ValueError(
                f"Invalid actor name '{v}'. "
                "Actor names must follow the namespace/name format "
                "(e.g. 'local/estimator'). "
                "Only alphanumeric, hyphens, and underscores are allowed."
            )
        return v

    @field_validator("state")
    @classmethod
    def validate_state(cls, v: str) -> str:
        """Validate state is one of the allowed values."""
        v_lower = v.lower()
        if v_lower not in _VALID_STATES:
            valid = ", ".join(sorted(_VALID_STATES))
            raise ValueError(
                f"Invalid state '{v}'. "
                f"Allowed states: {valid}. "
                "Note: 'draft' is only used internally, "
                "not in YAML configs."
            )
        return v_lower

    # ────────────────────────────────────────────────────────
    # Model-level validators
    # ────────────────────────────────────────────────────────

    @model_validator(mode="after")
    def normalize_invariants(self) -> ActionConfigSchema:
        """Trim, drop blanks, and de-duplicate invariants."""
        seen: set[str] = set()
        cleaned: list[str] = []
        for inv in self.invariants:
            trimmed = inv.strip()
            if trimmed and trimmed not in seen:
                seen.add(trimmed)
                cleaned.append(trimmed)
        self.invariants = cleaned
        return self

    # ────────────────────────────────────────────────────────
    # Factory class methods
    # ────────────────────────────────────────────────────────

    @classmethod
    def from_yaml(cls, yaml_string: str) -> ActionConfigSchema:
        """Parse and validate an action YAML string.

        Args:
            yaml_string: Raw YAML content.

        Returns:
            Validated ``ActionConfigSchema`` instance.

        Raises:
            ValueError: If the YAML is not a mapping or is empty.
            pydantic.ValidationError: If schema validation fails.
        """
        if yaml_string is None:
            raise ValueError("YAML string cannot be None. Provide a valid YAML string.")
        if not yaml_string.strip():
            raise ValueError(
                "YAML string is empty. Provide a valid action YAML configuration."
            )

        raw = yaml.safe_load(yaml_string)
        if not isinstance(raw, dict):
            raise ValueError(
                f"Action YAML must be a mapping (key: value), got {type(raw).__name__}."
            )

        normalized = _normalize_keys(raw)
        interpolated = _interpolate_env_vars(normalized)
        return cls.model_validate(interpolated)

    @classmethod
    def from_yaml_file(cls, path: str | Path) -> ActionConfigSchema:
        """Load and validate an action YAML file from disk.

        Args:
            path: Path to the YAML file.

        Returns:
            Validated ``ActionConfigSchema`` instance.

        Raises:
            FileNotFoundError: If the file does not exist.
            ValueError: If file content is invalid.
            pydantic.ValidationError: If schema validation fails.
        """
        if path is None:
            raise ValueError(
                "File path cannot be None. Provide a valid path to an action YAML file."
            )

        filepath = Path(path)
        if not filepath.exists():
            raise FileNotFoundError(
                f"Action YAML file not found: {filepath}. "
                "Check the file path and try again."
            )
        if not filepath.is_file():
            raise ValueError(
                f"Path is not a file: {filepath}. "
                "Provide a path to a YAML file, not a directory."
            )

        content = filepath.read_text(encoding="utf-8")
        return cls.from_yaml(content)


# ────────────────────────────────────────────────────────────
# Internal helpers
# ────────────────────────────────────────────────────────────


def _normalize_keys(data: dict[str, Any]) -> dict[str, Any]:
    """Normalize camelCase keys to snake_case recursively.

    Logs a warning for every camelCase key that is converted.
    """
    result: dict[str, Any] = {}
    for key, value in data.items():
        snake_key = _CAMEL_TO_SNAKE.get(key, key)
        if snake_key != key:
            logger.warning(
                "Deprecated camelCase key '%s' normalized to '%s'. "
                "Please update your YAML to use snake_case.",
                key,
                snake_key,
            )

        if isinstance(value, dict):
            result[snake_key] = _normalize_keys(value)
        elif isinstance(value, list):
            result[snake_key] = [
                _normalize_keys(item) if isinstance(item, dict) else item
                for item in value
            ]
        else:
            result[snake_key] = value
    return result


def _interpolate_env_vars(data: dict[str, Any]) -> dict[str, Any]:
    """Replace ``${VAR}`` references with environment variable values.

    Only string values are interpolated. Missing environment variables
    are left as-is (no error) to allow deferred resolution.
    """
    result: dict[str, Any] = {}
    for key, value in data.items():
        if isinstance(value, str):
            result[key] = _ENV_VAR_RE.sub(_env_replacer, value)
        elif isinstance(value, dict):
            result[key] = _interpolate_env_vars(value)
        elif isinstance(value, list):
            result[key] = [
                (
                    _interpolate_env_vars(item)
                    if isinstance(item, dict)
                    else (
                        _ENV_VAR_RE.sub(_env_replacer, item)
                        if isinstance(item, str)
                        else item
                    )
                )
                for item in value
            ]
        else:
            result[key] = value
    return result


def _env_replacer(match: re.Match[str]) -> str:
    """Replace a single ``${VAR}`` match with its env value."""
    var_name = match.group(1)
    return os.environ.get(var_name, match.group(0))
