"""The LeadPilot rule engine.

Pure, deterministic, dependency-free evaluation of rules against a lead context.
Given the same lead and the same rules the engine always produces the same score,
the same reasons and the same template selection. There is no model, no API call
and no randomness anywhere in this module.
"""

from __future__ import annotations

from dataclasses import dataclass, field as dataclass_field
from typing import Any

from app.models.enums import RuleAction, RuleMatchType, RuleOperator
from app.rules.fields import lead_to_context, normalize_field
from app.rules.operators import OPERATOR_LABELS, RuleEvaluationError, apply_operator

SCORE_MIN = 0
SCORE_MAX = 100


@dataclass(slots=True)
class RuleSpec:
    """A single condition plus the action taken when it matches."""

    field: str
    operator: str
    value: str | None = None
    value2: str | None = None
    case_sensitive: bool = False
    action: str = RuleAction.SCORE
    points: int = 0
    action_value: str | None = None
    name: str = ""
    id: int | None = None
    group_id: int | None = None
    priority: int = 100
    is_active: bool = True
    source: str = "custom"

    def __post_init__(self) -> None:
        self.field = normalize_field(self.field)
        if not self.name:
            self.name = self.describe()

    def describe(self) -> str:
        label = OPERATOR_LABELS.get(self.operator, self.operator)
        if self.operator == RuleOperator.BETWEEN:
            return f"{self.field} {label} {self.value} and {self.value2}"
        if self.operator in (RuleOperator.IS_EMPTY, RuleOperator.IS_NOT_EMPTY):
            return f"{self.field} {label}"
        return f"{self.field} {label} \"{self.value}\""

    @classmethod
    def from_orm(cls, rule: Any, source: str = "custom") -> "RuleSpec":
        return cls(
            id=getattr(rule, "id", None),
            name=getattr(rule, "name", "") or "",
            field=rule.field,
            operator=rule.operator,
            value=rule.value,
            value2=rule.value2,
            case_sensitive=bool(getattr(rule, "case_sensitive", False)),
            action=getattr(rule, "action", RuleAction.SCORE),
            points=int(getattr(rule, "points", 0) or 0),
            action_value=getattr(rule, "action_value", None),
            group_id=getattr(rule, "group_id", None),
            priority=int(getattr(rule, "priority", 100) or 100),
            is_active=bool(getattr(rule, "is_active", True)),
            source=source,
        )

    @classmethod
    def from_dict(cls, data: dict[str, Any], source: str = "custom") -> "RuleSpec":
        return cls(
            id=data.get("id"),
            name=data.get("name", "") or "",
            field=data.get("field", ""),
            operator=data.get("operator", RuleOperator.CONTAINS),
            value=data.get("value"),
            value2=data.get("value2"),
            case_sensitive=bool(data.get("case_sensitive", data.get("caseSensitive", False))),
            action=data.get("action", RuleAction.SCORE),
            points=int(data.get("points", 0) or 0),
            action_value=data.get("action_value", data.get("actionValue")),
            group_id=data.get("group_id", data.get("groupId")),
            priority=int(data.get("priority", 100) or 100),
            is_active=bool(data.get("is_active", data.get("isActive", True))),
            source=source,
        )


@dataclass(slots=True)
class RuleResult:
    """Outcome of evaluating one rule, kept for the 'why did it score that?' panel."""

    rule_id: int | None
    name: str
    field: str
    operator: str
    value: str | None
    action: str
    points: int
    matched: bool
    source: str = "custom"
    error: str | None = None

    def as_dict(self) -> dict[str, Any]:
        return {
            "ruleId": self.rule_id,
            "name": self.name,
            "field": self.field,
            "operator": self.operator,
            "value": self.value,
            "action": self.action,
            "points": self.points,
            "matched": self.matched,
            "source": self.source,
            "error": self.error,
        }


@dataclass(slots=True)
class EvaluationResult:
    """Everything the engine derived for one lead."""

    raw_score: int = 0
    score: int = 0
    rejected: bool = False
    rejection_reason: str | None = None
    reasons: list[dict[str, Any]] = dataclass_field(default_factory=list)
    rule_results: list[RuleResult] = dataclass_field(default_factory=list)
    tags: list[str] = dataclass_field(default_factory=list)
    template_keys: list[str] = dataclass_field(default_factory=list)
    phrases: list[str] = dataclass_field(default_factory=list)
    errors: list[str] = dataclass_field(default_factory=list)

    def rule_results_as_dicts(self) -> list[dict[str, Any]]:
        return [result.as_dict() for result in self.rule_results]


@dataclass(slots=True)
class RuleGroupSpec:
    """A set of rules that only fires when all/any of its members match."""

    id: int | None
    name: str
    match_type: str = RuleMatchType.ALL
    rules: list[RuleSpec] = dataclass_field(default_factory=list)
    is_active: bool = True
    priority: int = 100


def clamp_score(value: int | float) -> int:
    return int(max(SCORE_MIN, min(SCORE_MAX, round(value))))


def match_rule(rule: RuleSpec, context: dict[str, Any]) -> tuple[bool, str | None]:
    """Evaluate one rule, returning (matched, error)."""
    field_value = context.get(rule.field)
    try:
        matched = apply_operator(
            rule.operator,
            field_value,
            rule.value,
            rule.value2,
            rule.case_sensitive,
        )
    except RuleEvaluationError as exc:
        return False, str(exc)
    return matched, None


def evaluate_rules(
    rules: list[RuleSpec],
    lead: Any,
    groups: list[RuleGroupSpec] | None = None,
) -> EvaluationResult:
    """Score a lead against a flat rule list plus optional all/any rule groups.

    Rules run in priority order (lower number first). A matching ``reject`` rule
    stops scoring immediately and marks the lead rejected.
    """
    context = lead if isinstance(lead, dict) and "jobTitle" in lead else lead_to_context(lead)
    result = EvaluationResult()

    ordered = sorted(
        (rule for rule in rules if rule.is_active),
        key=lambda rule: (rule.priority, rule.id or 0),
    )

    for rule in ordered:
        matched, error = match_rule(rule, context)
        rule_result = RuleResult(
            rule_id=rule.id,
            name=rule.name,
            field=rule.field,
            operator=rule.operator,
            value=rule.value,
            action=rule.action,
            points=rule.points if matched else 0,
            matched=matched,
            source=rule.source,
            error=error,
        )
        result.rule_results.append(rule_result)
        if error:
            result.errors.append(f"{rule.name}: {error}")
            continue
        if not matched:
            continue

        _apply_action(rule, result, context)
        if result.rejected:
            break

    if not result.rejected and groups:
        _apply_groups(groups, context, result)

    result.score = clamp_score(result.raw_score)
    if result.rejected:
        result.score = 0
    return result


def _apply_action(rule: RuleSpec, result: EvaluationResult, context: dict[str, Any]) -> None:
    if rule.action == RuleAction.REJECT:
        result.rejected = True
        result.rejection_reason = rule.action_value or rule.name or rule.describe()
        result.reasons.append(
            {
                "ruleId": rule.id,
                "name": rule.name,
                "points": 0,
                "action": RuleAction.REJECT.value,
                "detail": rule.describe(),
                "source": rule.source,
            }
        )
        return

    if rule.action == RuleAction.SCORE:
        result.raw_score += rule.points
        result.reasons.append(
            {
                "ruleId": rule.id,
                "name": rule.name,
                "points": rule.points,
                "action": RuleAction.SCORE.value,
                "detail": rule.describe(),
                "source": rule.source,
            }
        )
        return

    if rule.action == RuleAction.TAG and rule.action_value:
        for tag in _split_values(rule.action_value):
            if tag not in result.tags:
                result.tags.append(tag)
        return

    if rule.action == RuleAction.SELECT_TEMPLATE and rule.action_value:
        if rule.action_value not in result.template_keys:
            result.template_keys.append(rule.action_value)
        return

    if rule.action == RuleAction.ADD_PHRASE and rule.action_value:
        phrase = _interpolate(rule.action_value, context)
        if phrase not in result.phrases:
            result.phrases.append(phrase)


def _apply_groups(
    groups: list[RuleGroupSpec], context: dict[str, Any], result: EvaluationResult
) -> None:
    for group in sorted(groups, key=lambda g: (g.priority, g.id or 0)):
        if not group.is_active or not group.rules:
            continue
        outcomes = []
        for rule in group.rules:
            if not rule.is_active:
                continue
            matched, error = match_rule(rule, context)
            if error:
                # A member we cannot evaluate counts as "did not match", so an
                # ALL group never fires on the strength of its surviving members.
                result.errors.append(f"{group.name}/{rule.name}: {error}")
                outcomes.append((rule, False))
                continue
            outcomes.append((rule, matched))
        if not outcomes:
            continue
        satisfied = (
            all(matched for _, matched in outcomes)
            if group.match_type == RuleMatchType.ALL
            else any(matched for _, matched in outcomes)
        )
        if not satisfied:
            continue
        for rule, matched in outcomes:
            if group.match_type == RuleMatchType.ANY and not matched:
                continue
            _apply_action(rule, result, context)
            if result.rejected:
                return


def _split_values(raw: str) -> list[str]:
    return [item.strip() for item in raw.split(",") if item.strip()]


def _interpolate(text: str, context: dict[str, Any]) -> str:
    """Fill {field} placeholders inside rule phrases using the lead context."""
    from app.services.template_engine import render_placeholders

    return render_placeholders(text, context)
