"""Definition-of-Done evaluation domain models.

Provides structured evaluation of plan completion criteria
(``definition_of_done``) before apply is permitted.

Core types:

- **DoDCriterion**: A single testable criterion parsed from the DoD text.
- **DoDResult**: Pass/fail result for a single criterion with reasoning.
- **DoDSummary**: Aggregated evaluation results for all criteria.
- **DoDEvaluator**: Interface for evaluating DoD criteria.
- **TextMatchEvaluator**: Default evaluator using regex/substring matching.
- **render_dod_template**: Renders a DoD template with plan arguments.
"""

from __future__ import annotations

import logging
import re
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from enum import StrEnum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field

logger = logging.getLogger(__name__)

#: Maximum length for a DoD template string (prevent DoS).
MAX_DOD_TEMPLATE_LENGTH: int = 10_000

#: Maximum length for a rendered DoD output string (prevent DoS).
MAX_DOD_OUTPUT_LENGTH: int = 50_000

#: Timeout (in effective characters) for regex evaluation to
#: limit catastrophic backtracking.  Patterns exceeding this
#: length are rejected.
MAX_REGEX_PATTERN_LENGTH: int = 500

#: Safe placeholder regex: ``{simple_name}`` only.
_DOD_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")

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


class DoDStatus(StrEnum):
    """Evaluation status for a DoD criterion."""

    PASSED = "passed"
    FAILED = "failed"
    SKIPPED = "skipped"


# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------


class DoDCriterion(BaseModel):
    """A single testable criterion parsed from definition_of_done text.

    Each line or bullet point in the DoD becomes a separate criterion.
    """

    index: int = Field(..., ge=0, description="Zero-based criterion index")
    text: str = Field(..., min_length=1, description="Criterion text")
    pattern: str | None = Field(
        default=None,
        description=(
            "Optional regex pattern for evaluation. "
            "If None, substring matching is used."
        ),
    )

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


class DoDResult(BaseModel):
    """Pass/fail result for a single DoD criterion."""

    criterion: DoDCriterion = Field(..., description="The criterion that was evaluated")
    status: DoDStatus = Field(..., description="Whether the criterion passed or failed")
    reasoning: str = Field(
        default="", description="Explanation for the evaluation result"
    )

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


class DoDSummary(BaseModel):
    """Aggregated evaluation results for all DoD criteria."""

    plan_id: str = Field(..., description="Plan ULID")
    definition_of_done: str = Field(..., description="The original DoD text")
    results: list[DoDResult] = Field(
        default_factory=list,
        description="Per-criterion evaluation results",
    )
    evaluated_at: datetime = Field(
        default_factory=lambda: datetime.now(tz=timezone.utc),  # noqa: UP017
        description="When the evaluation was performed (UTC)",
    )

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

    def _counts(self) -> tuple[int, int, int]:
        """Return (passed, failed, skipped) counts in a single pass."""
        p = f = s = 0
        for r in self.results:
            if r.status == DoDStatus.PASSED:
                p += 1
            elif r.status == DoDStatus.FAILED:
                f += 1
            else:
                s += 1
        return p, f, s

    @property
    def total(self) -> int:
        """Total number of criteria evaluated."""
        return len(self.results)

    @property
    def passed(self) -> int:
        """Number of criteria that passed."""
        return self._counts()[0]

    @property
    def failed(self) -> int:
        """Number of criteria that failed."""
        return self._counts()[1]

    @property
    def skipped(self) -> int:
        """Number of criteria that were skipped."""
        return self._counts()[2]

    @property
    def all_passed(self) -> bool:
        """Whether every criterion passed.

        Returns ``False`` when any criterion failed, was skipped, or
        when the results list is empty.  Only returns ``True`` when
        *every* criterion has status ``PASSED``.
        """
        p, f, s = self._counts()
        return p > 0 and f == 0 and s == 0

    @property
    def is_empty(self) -> bool:
        """Whether no criteria were evaluated."""
        return self.total == 0

    def to_cli_dict(self) -> dict[str, Any]:
        """Convert to dictionary for CLI display."""
        return {
            "plan_id": self.plan_id,
            "total": self.total,
            "passed": self.passed,
            "failed": self.failed,
            "skipped": self.skipped,
            "all_passed": self.all_passed,
            "evaluated_at": self.evaluated_at.isoformat(),
            "criteria": [
                {
                    "index": r.criterion.index,
                    "text": r.criterion.text,
                    "status": r.status.value,
                    "reasoning": r.reasoning,
                }
                for r in self.results
            ],
        }

    def to_validation_summary(self) -> dict[str, Any]:
        """Convert to a validation_summary dict for plan metadata.

        Compatible with the plan's ``validation_summary`` field format.
        """
        return {
            "total": self.total,
            "required_passed": self.passed,
            "required_failed": self.failed,
            "informational_passed": 0,
            "informational_failed": 0,
            "dod_evaluated": True,
            "dod_all_passed": self.all_passed,
        }


# ---------------------------------------------------------------------------
# Template rendering
# ---------------------------------------------------------------------------


def render_dod_template(
    template: str,
    arguments: dict[str, Any],
) -> str:
    """Render a DoD template with plan arguments.

    Substitutes ``{arg_name}`` placeholders in the template with
    values from the arguments dict using **single-pass** regex
    substitution (preventing injection through argument values).

    Unmatched placeholders are preserved as-is.

    Args:
        template: The DoD template text with ``{placeholder}`` markers.
        arguments: Plan argument values to substitute.

    Returns:
        The rendered DoD text.

    Raises:
        ValueError: If the template or rendered output exceeds size
            limits.
    """
    if len(template) > MAX_DOD_TEMPLATE_LENGTH:
        raise ValueError(
            f"DoD template length {len(template)} exceeds maximum "
            f"{MAX_DOD_TEMPLATE_LENGTH}"
        )

    def _replace(match: re.Match[str]) -> str:
        key = match.group(1)
        if key in arguments:
            return str(arguments[key])
        return match.group(0)

    result = _DOD_PLACEHOLDER_RE.sub(_replace, template)

    if len(result) > MAX_DOD_OUTPUT_LENGTH:
        raise ValueError(
            f"Rendered DoD length {len(result)} exceeds maximum {MAX_DOD_OUTPUT_LENGTH}"
        )
    return result


def parse_dod_criteria(
    definition_of_done: str,
) -> list[DoDCriterion]:
    """Parse a definition_of_done text into individual criteria.

    Each non-empty line (after stripping leading bullet markers like
    ``-``, ``*``, ``1.``) becomes a separate criterion.

    Args:
        definition_of_done: The DoD text to parse.

    Returns:
        List of ``DoDCriterion`` objects.
    """
    if not definition_of_done or not definition_of_done.strip():
        return []

    criteria: list[DoDCriterion] = []
    for line in definition_of_done.strip().splitlines():
        # Strip leading whitespace and bullet markers
        text = line.strip()
        if not text:
            continue
        # Remove common bullet prefixes: "- ", "* ", "1. ", "2. ", etc.
        text = re.sub(r"^[-*]\s*", "", text)
        text = re.sub(r"^\d+\.\s*", "", text)
        text = text.strip()
        if not text:
            continue
        criteria.append(DoDCriterion(index=len(criteria), text=text))
    return criteria


# ---------------------------------------------------------------------------
# Evaluator interface
# ---------------------------------------------------------------------------


class DoDEvaluator(ABC):
    """Abstract interface for evaluating DoD criteria.

    Implementations check whether each criterion in a DoD is met,
    returning a ``DoDResult`` for each.
    """

    @abstractmethod
    def evaluate(
        self,
        criteria: list[DoDCriterion],
        context: dict[str, Any],
    ) -> list[DoDResult]:
        """Evaluate a list of criteria against a context.

        Args:
            criteria: Parsed DoD criteria.
            context: Evaluation context (e.g., changeset summary,
                validation results, file list).

        Returns:
            A ``DoDResult`` for each criterion.
        """
        ...


class TextMatchEvaluator(DoDEvaluator):
    """Default DoD evaluator using text matching.

    For each criterion, checks whether the criterion text (or its
    regex pattern) matches any value in the context dict. This is
    a simple but effective default for DoD checks like:

    - "All tests pass" → checks if context has "tests_passed" = True
    - "Coverage >= 90%" → checks if context matches regex
    - "No lint errors" → checks context for "lint_errors" = 0

    The context dict should contain string keys with string or boolean
    values representing the current state of the plan/changeset.
    """

    def evaluate(
        self,
        criteria: list[DoDCriterion],
        context: dict[str, Any],
    ) -> list[DoDResult]:
        """Evaluate criteria via text/regex matching.

        For each criterion:
        - If ``pattern`` is set, use regex matching against each
          individual context key and value (same granularity as
          substring mode).
        - Otherwise, check if any context key/value contains the
          criterion text (case-insensitive substring match).
        - If the context is empty, all criteria are marked SKIPPED.

        Regex patterns longer than ``MAX_REGEX_PATTERN_LENGTH`` are
        rejected to mitigate ReDoS attacks.

        Args:
            criteria: Parsed DoD criteria.
            context: Key-value pairs representing plan state.

        Returns:
            A ``DoDResult`` for each criterion.
        """
        results: list[DoDResult] = []

        for criterion in criteria:
            if not context:
                results.append(
                    DoDResult(
                        criterion=criterion,
                        status=DoDStatus.SKIPPED,
                        reasoning="No evaluation context provided.",
                    )
                )
                continue

            if criterion.pattern:
                results.append(self._evaluate_regex(criterion, context))
            else:
                results.append(self._evaluate_substring(criterion, context))

        return results

    # -- private helpers ---------------------------------------------------

    @staticmethod
    def _evaluate_regex(
        criterion: DoDCriterion,
        context: dict[str, Any],
    ) -> DoDResult:
        """Evaluate a single criterion using its regex pattern."""
        pattern = criterion.pattern or ""

        if len(pattern) > MAX_REGEX_PATTERN_LENGTH:
            return DoDResult(
                criterion=criterion,
                status=DoDStatus.FAILED,
                reasoning=(
                    f"Regex pattern length {len(pattern)} exceeds maximum "
                    f"{MAX_REGEX_PATTERN_LENGTH}"
                ),
            )

        try:
            compiled = re.compile(pattern, re.IGNORECASE)
        except re.error as exc:
            return DoDResult(
                criterion=criterion,
                status=DoDStatus.FAILED,
                reasoning=f"Invalid regex pattern: {exc}",
            )

        for k, v in context.items():
            if compiled.search(str(k)) or compiled.search(str(v)):
                return DoDResult(
                    criterion=criterion,
                    status=DoDStatus.PASSED,
                    reasoning=f"Pattern '{pattern}' matched in context.",
                )

        return DoDResult(
            criterion=criterion,
            status=DoDStatus.FAILED,
            reasoning=(f"Pattern '{pattern}' did not match any context value."),
        )

    @staticmethod
    def _evaluate_substring(
        criterion: DoDCriterion,
        context: dict[str, Any],
    ) -> DoDResult:
        """Evaluate a single criterion using substring matching."""
        lower_text = criterion.text.lower()
        found = any(
            lower_text in str(k).lower() or lower_text in str(v).lower()
            for k, v in context.items()
        )
        if found:
            return DoDResult(
                criterion=criterion,
                status=DoDStatus.PASSED,
                reasoning="Criterion text found in context.",
            )
        return DoDResult(
            criterion=criterion,
            status=DoDStatus.FAILED,
            reasoning=("Criterion text not found in any context key or value."),
        )
