"""Built-in Invariant Reconciliation Actor.

Runs at the start of the Strategize phase to reconcile invariants from
four scopes: global, project, action, and plan.

**Algorithm**:

1. Collect invariants from all scopes.
2. Group by normalised text (case-insensitive, stripped).
3. Detect conflicts: same text present at different scopes with
   contradictory intent, or different invariants that the grouping
   cannot resolve automatically.
4. Resolve conflicts using specificity: plan > action > project > global.
   Exception: ``non_overridable`` global invariants always win.
5. Record an ``invariant_enforced`` decision for each active invariant.
6. Return a reconciled ``InvariantSet``.

Based on specification §19440-19600 and issue #549.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING

import structlog

from cleveragents.domain.models.core.decision import DecisionType
from cleveragents.domain.models.core.invariant import (
    Invariant,
    InvariantScope,
    InvariantSet,
)

if TYPE_CHECKING:
    from cleveragents.application.services.decision_service import DecisionService
    from cleveragents.application.services.invariant_service import InvariantService

logger = structlog.get_logger(__name__)

# Precedence order (index 0 = highest)
_SCOPE_PRECEDENCE: dict[InvariantScope, int] = {
    InvariantScope.PLAN: 0,
    InvariantScope.ACTION: 1,
    InvariantScope.PROJECT: 2,
    InvariantScope.GLOBAL: 3,
}


@dataclass(frozen=True)
class ConflictRecord:
    """A detected conflict between invariants from different scopes.

    Attributes:
        key: Normalised invariant text used for grouping.
        winner: The invariant that prevailed after resolution.
        losers: Invariants that were overridden.
        reason: Human-readable explanation of the resolution.
    """

    key: str
    winner: Invariant
    losers: list[Invariant]
    reason: str


@dataclass(frozen=True)
class ReconciliationResult:
    """Output of the reconciliation process.

    Attributes:
        reconciled_set: The final effective ``InvariantSet``.
        conflicts: All detected conflicts with resolution details.
        enforced_decision_ids: ULIDs of ``invariant_enforced`` decisions.
    """

    reconciled_set: InvariantSet
    conflicts: list[ConflictRecord]
    enforced_decision_ids: list[str]


@dataclass(frozen=False)
class ScopeInvariants:
    """Invariants collected from each scope tier.

    Convenience container passed to the reconciliation algorithm.
    """

    global_invariants: list[Invariant] = field(default_factory=list)
    project_invariants: list[Invariant] = field(default_factory=list)
    action_invariants: list[Invariant] = field(default_factory=list)
    plan_invariants: list[Invariant] = field(default_factory=list)

    def all_invariants(self) -> list[Invariant]:
        """Return a flat list of all invariants across scopes."""
        return (
            self.global_invariants
            + self.project_invariants
            + self.action_invariants
            + self.plan_invariants
        )


def _normalise_key(text: str) -> str:
    """Return a case-insensitive, whitespace-stripped key for grouping."""
    if not text:
        return ""
    return text.strip().lower()


def _scope_rank(scope: InvariantScope) -> int:
    """Return the precedence rank for a scope (lower = higher priority)."""
    return _SCOPE_PRECEDENCE.get(scope, 99)


def _resolve_group(
    invariants: list[Invariant],
) -> tuple[Invariant, list[ConflictRecord]]:
    """Resolve a group of invariants sharing the same normalised text.

    Args:
        invariants: All invariants with the same normalised text.

    Returns:
        A tuple of (winner, conflict_records).

    Raises:
        ValueError: If *invariants* is empty.
    """
    if not invariants:
        raise ValueError("Cannot resolve an empty invariant group")

    if len(invariants) == 1:
        return invariants[0], []

    key = _normalise_key(invariants[0].text)

    # Check for non_overridable global invariants first
    non_overridable_globals = [
        inv
        for inv in invariants
        if inv.scope == InvariantScope.GLOBAL and inv.non_overridable
    ]

    if non_overridable_globals:
        winner = non_overridable_globals[0]
        losers = [inv for inv in invariants if inv is not winner]
        conflict = ConflictRecord(
            key=key,
            winner=winner,
            losers=losers,
            reason=(
                f"Global invariant is non_overridable; "
                f"overrides {len(losers)} lower-scope variant(s)"
            ),
        )
        return winner, [conflict]

    # Standard specificity resolution: highest precedence wins
    sorted_invs = sorted(invariants, key=lambda inv: _scope_rank(inv.scope))
    winner = sorted_invs[0]
    losers = sorted_invs[1:]

    conflicts: list[ConflictRecord] = []
    if losers:
        conflicts.append(
            ConflictRecord(
                key=key,
                winner=winner,
                losers=losers,
                reason=(
                    f"{winner.scope.value} scope overrides "
                    f"{', '.join(inv.scope.value for inv in losers)} scope(s)"
                ),
            )
        )

    return winner, conflicts


def reconcile_invariants(
    scope_invariants: ScopeInvariants,
) -> tuple[list[Invariant], list[ConflictRecord]]:
    """Run the reconciliation algorithm on collected invariants.

    Groups invariants by normalised text, resolves each group, and
    returns the winners along with any detected conflicts.

    Args:
        scope_invariants: Invariants from all four scopes.

    Returns:
        Tuple of (reconciled_invariants, conflict_records).
    """
    all_invs = [inv for inv in scope_invariants.all_invariants() if inv.active]

    # Group by normalised text
    groups: dict[str, list[Invariant]] = {}
    for inv in all_invs:
        key = _normalise_key(inv.text)
        groups.setdefault(key, []).append(inv)

    reconciled: list[Invariant] = []
    all_conflicts: list[ConflictRecord] = []

    for _key, group in groups.items():
        winner, conflicts = _resolve_group(group)
        reconciled.append(winner)
        all_conflicts.extend(conflicts)

    return reconciled, all_conflicts


class InvariantReconciliationActor:
    """Built-in actor that reconciles invariants at Strategize start.

    Collects invariants from global, project, action, and plan scopes,
    detects and resolves conflicts using specificity-based precedence,
    records ``invariant_enforced`` decisions, and produces a reconciled
    ``InvariantSet`` for downstream actors.

    Usage::

        actor = InvariantReconciliationActor(
            invariant_service=inv_svc,
            decision_service=dec_svc,
        )
        result = actor.run(plan_id="...", project_name="...", action_name="...")
    """

    #: Actor name used for registration / lookup.
    ACTOR_NAME: str = "builtin/invariant-reconciliation"

    def __init__(
        self,
        *,
        invariant_service: InvariantService,
        decision_service: DecisionService,
    ) -> None:
        if invariant_service is None:
            raise ValueError("invariant_service must not be None")
        if decision_service is None:
            raise ValueError("decision_service must not be None")
        self._invariant_service = invariant_service
        self._decision_service = decision_service
        self._logger = logger.bind(actor=self.ACTOR_NAME)

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def collect_invariants(
        self,
        *,
        plan_id: str | None = None,
        project_name: str | None = None,
        action_name: str | None = None,
    ) -> ScopeInvariants:
        """Collect invariants from all four scopes.

        Args:
            plan_id: Optional plan identifier.
            project_name: Optional project name.
            action_name: Optional action name.

        Returns:
            ``ScopeInvariants`` with invariants grouped by scope.
        """
        svc = self._invariant_service
        return ScopeInvariants(
            global_invariants=svc.list_invariants(scope=InvariantScope.GLOBAL),
            project_invariants=(
                svc.list_invariants(
                    scope=InvariantScope.PROJECT, source_name=project_name
                )
                if project_name
                else []
            ),
            action_invariants=(
                svc.list_invariants(
                    scope=InvariantScope.ACTION, source_name=action_name
                )
                if action_name
                else []
            ),
            plan_invariants=(
                svc.list_invariants(scope=InvariantScope.PLAN, source_name=plan_id)
                if plan_id
                else []
            ),
        )

    def run(
        self,
        *,
        plan_id: str,
        project_name: str | None = None,
        action_name: str | None = None,
        parent_decision_id: str | None = None,
    ) -> ReconciliationResult:
        """Execute the full reconciliation lifecycle.

        1. Collect invariants from all scopes.
        2. Reconcile conflicts.
        3. Record ``invariant_enforced`` decisions.
        4. Return the result.

        Args:
            plan_id: ULID of the plan entering Strategize.
            project_name: Optional project name for scoping.
            action_name: Optional action name for scoping.
            parent_decision_id: Optional parent decision for tree wiring.

        Returns:
            A ``ReconciliationResult`` with the reconciled set, conflicts,
            and decision IDs.

        Raises:
            ValueError: If *plan_id* is empty or blank.
        """
        if not plan_id or not plan_id.strip():
            raise ValueError("plan_id must not be empty")

        self._logger.info(
            "reconciliation.start",
            plan_id=plan_id,
            project_name=project_name,
            action_name=action_name,
        )

        # Step 1: Collect
        scope_invs = self.collect_invariants(
            plan_id=plan_id,
            project_name=project_name,
            action_name=action_name,
        )

        total = len(scope_invs.all_invariants())
        self._logger.info(
            "reconciliation.collected",
            plan_id=plan_id,
            total_invariants=total,
            global_count=len(scope_invs.global_invariants),
            project_count=len(scope_invs.project_invariants),
            action_count=len(scope_invs.action_invariants),
            plan_count=len(scope_invs.plan_invariants),
        )

        # Step 2: Reconcile
        reconciled, conflicts = reconcile_invariants(scope_invs)

        if conflicts:
            self._logger.info(
                "reconciliation.conflicts_detected",
                plan_id=plan_id,
                conflict_count=len(conflicts),
            )
            for conflict in conflicts:
                self._logger.info(
                    "reconciliation.conflict_resolved",
                    plan_id=plan_id,
                    key=conflict.key,
                    winner_scope=conflict.winner.scope.value,
                    reason=conflict.reason,
                )

        # Step 3: Record decisions
        decision_ids = self._record_enforcement_decisions(
            plan_id=plan_id,
            invariants=reconciled,
            parent_decision_id=parent_decision_id,
        )

        # Step 4: Build result
        reconciled_set = InvariantSet(invariants=tuple(reconciled))

        self._logger.info(
            "reconciliation.complete",
            plan_id=plan_id,
            effective_count=len(reconciled),
            conflict_count=len(conflicts),
            decision_count=len(decision_ids),
        )

        return ReconciliationResult(
            reconciled_set=reconciled_set,
            conflicts=conflicts,
            enforced_decision_ids=decision_ids,
        )

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _record_enforcement_decisions(
        self,
        *,
        plan_id: str,
        invariants: list[Invariant],
        parent_decision_id: str | None,
    ) -> list[str]:
        """Record an ``invariant_enforced`` decision per invariant.

        Args:
            plan_id: The plan ULID.
            invariants: Reconciled invariants to enforce.
            parent_decision_id: Optional parent decision for tree wiring.

        Returns:
            List of decision ULIDs created.
        """
        decision_ids: list[str] = []
        for inv in invariants:
            decision = self._decision_service.record_decision(
                plan_id=plan_id,
                decision_type=DecisionType.INVARIANT_ENFORCED,
                question=(
                    f"Should invariant be enforced? [{inv.scope.value}] {inv.text}"
                ),
                chosen_option=f"Enforce: {inv.text}",
                parent_decision_id=parent_decision_id,
                rationale=(
                    f"Invariant from {inv.scope.value} scope "
                    f"(source: {inv.source_name}) enforced during "
                    f"reconciliation"
                ),
                confidence_score=1.0,
            )
            decision_ids.append(decision.decision_id)

        # Also record enforcement in the invariant service
        self._invariant_service.enforce_invariants(
            plan_id=plan_id,
            invariants=invariants,
            actor_response="Reconciliation complete",
        )

        return decision_ids


__all__ = [
    "ConflictRecord",
    "InvariantReconciliationActor",
    "ReconciliationResult",
    "ScopeInvariants",
    "reconcile_invariants",
]
