"""Invariant Service for CleverAgents v3.

The ``InvariantService`` manages invariant constraints across scopes
(global, project, action, plan) and provides merge, enforcement, and
lifecycle operations.

## Storage

Uses in-memory storage (same pattern as ``PlanLifecycleService``) with
a dict keyed by invariant ID.

## Merge Precedence

Effective invariants are computed using plan > action > project > global
order.  See ``merge_invariants`` for de-duplication semantics.

Based on ``docs/specification.md`` and implementation plan Stage M3.5.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import structlog
from ulid import ULID

from cleveragents.application.services.prompt_sanitizer import PromptSanitizer
from cleveragents.core.exceptions import (
    InvariantViolationError,
    NotFoundError,
    ValidationError,
)
from cleveragents.domain.models.core.invariant import (
    Invariant,
    InvariantEnforcementRecord,
    InvariantScope,
    merge_invariants,
)
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType

if TYPE_CHECKING:
    from cleveragents.infrastructure.events.protocol import EventBus

logger = structlog.get_logger(__name__)


class InvariantService:
    """Service for managing invariant constraints.

    Provides add, list, remove (soft-delete), effective-set computation,
    and enforcement record creation.  All storage is in-memory.
    """

    def __init__(self, event_bus: EventBus | None = None) -> None:
        """Initialise the invariant service with empty in-memory storage.

        Args:
            event_bus: Optional EventBus for domain event emission.
        """
        self._invariants: dict[str, Invariant] = {}
        self._enforcement_records: list[InvariantEnforcementRecord] = []
        self._logger = logger.bind(service="invariant")
        self._sanitizer = PromptSanitizer()
        self._event_bus = event_bus

    def add_invariant(
        self,
        text: str,
        scope: InvariantScope,
        source_name: str,
    ) -> Invariant:
        """Add a new invariant with validation.

        Args:
            text: The natural-language constraint text.
            scope: The scope at which this invariant applies.
            source_name: Name of the owning project/action/plan.

        Returns:
            The created ``Invariant``.

        Raises:
            ValidationError: If text is empty/blank or source_name is blank.
        """
        if not text or not text.strip():
            raise ValidationError("Invariant text must not be empty")
        if not source_name or not source_name.strip():
            raise ValidationError("Source name must not be empty")

        # Sanitize invariant text before storage (mechanism 1)
        sanitized = self._sanitizer.sanitize_user_input(text.strip())
        text = sanitized.sanitized

        invariant = Invariant(
            id=str(ULID()),
            text=text,
            scope=scope,
            source_name=source_name.strip(),
        )

        self._invariants[invariant.id] = invariant
        self._logger.info(
            "Invariant added",
            invariant_id=invariant.id,
            scope=scope.value,
            source_name=source_name,
        )
        return invariant

    def list_invariants(
        self,
        scope: InvariantScope | None = None,
        source_name: str | None = None,
        effective: bool = False,
    ) -> list[Invariant]:
        """Filter and list invariants.

        Args:
            scope: Filter by scope (None = all scopes).
            source_name: Filter by source name (None = all sources).
            effective: When True, returns merged set for the given
                scope chain (requires ``scope`` and ``source_name``).

        Returns:
            List of matching ``Invariant`` objects.
        """
        if effective:
            return self.get_effective_invariants(
                plan_id=source_name if scope == InvariantScope.PLAN else None,
                action_name=source_name if scope == InvariantScope.ACTION else None,
                project_name=source_name if scope == InvariantScope.PROJECT else None,
            )

        result = [inv for inv in self._invariants.values() if inv.active]

        if scope is not None:
            result = [inv for inv in result if inv.scope == scope]

        if source_name is not None:
            result = [inv for inv in result if inv.source_name == source_name]

        return result

    def remove_invariant(self, invariant_id: str) -> Invariant:
        """Soft-delete an invariant by setting active=False.

        Args:
            invariant_id: The ULID of the invariant to remove.

        Returns:
            The updated ``Invariant``.

        Raises:
            NotFoundError: If the invariant does not exist.
        """
        if not invariant_id or not invariant_id.strip():
            raise ValidationError("Invariant ID must not be empty")

        inv = self._invariants.get(invariant_id)
        if inv is None:
            raise NotFoundError(
                resource_type="invariant",
                resource_id=invariant_id,
            )
        # Invariant is frozen (immutable); create a new instance with active=False
        deactivated = inv.model_copy(update={"active": False})
        self._invariants[invariant_id] = deactivated
        self._logger.info("Invariant removed (soft-delete)", invariant_id=invariant_id)
        return deactivated

    def get_effective_invariants(
        self,
        plan_id: str | None = None,
        action_name: str | None = None,
        project_name: str | None = None,
    ) -> list[Invariant]:
        """Return the merged precedence chain for a plan/action/project context.

        Collects active invariants from each scope tier and merges them
        using plan > action > project > global precedence.

        Args:
            plan_id: Optional plan identifier to collect plan-scoped
                invariants.
            action_name: Optional action name to collect action-scoped
                invariants (promoted to plan-level during reconciliation).
                When ``None``, the action tier is omitted for backward
                compatibility. Pass ``"*"`` to include all action-scoped
                invariants regardless of source name.
            project_name: Optional project name to collect project-scoped
                invariants.

        Returns:
            Merged, de-duplicated list of effective invariants.
        """
        active = [inv for inv in self._invariants.values() if inv.active]

        plan_invs = [
            inv
            for inv in active
            if inv.scope == InvariantScope.PLAN
            and (plan_id is None or inv.source_name == plan_id)
        ]
        action_invs = [
            inv
            for inv in active
            if inv.scope == InvariantScope.ACTION
            and action_name is not None  # Only include when explicitly requested
            and (inv.source_name == action_name or action_name == "*")
        ]
        project_invs = [
            inv
            for inv in active
            if inv.scope == InvariantScope.PROJECT
            and (project_name is None or inv.source_name == project_name)
        ]
        global_invs = [inv for inv in active if inv.scope == InvariantScope.GLOBAL]

        return merge_invariants(plan_invs, action_invs, project_invs, global_invs)

    def load_active_invariants(
        self,
        plan_id: str | None = None,
        project_name: str | None = None,
    ) -> list[Invariant]:
        """Load all active invariants for a plan/project context.

        Loads active invariants from all scopes (global, project, plan)
        and returns them merged according to precedence rules
        (plan > project > global).

        Args:
            plan_id: Optional plan identifier to load plan-scoped invariants.
            project_name: Optional project name to load project-scoped
                invariants.

        Returns:
            List of active, merged invariants for the context.
        """
        if not plan_id and not project_name:
            # Load all global invariants
            return self.get_effective_invariants()
        return self.get_effective_invariants(
            plan_id=plan_id,
            project_name=project_name,
        )

    def check_invariants(
        self,
        action_text: str,
        invariants: list[Invariant],
    ) -> None:
        """Check if an action violates any active invariants.

        Validates the proposed action text against all provided invariants.
        Raises ``InvariantViolationError`` if any invariant is violated.

        Args:
            action_text: The action or step text to validate.
            invariants: List of invariants to check against.

        Raises:
            ValidationError: If action_text is empty.
            InvariantViolationError: If any invariant is violated.
        """
        if not action_text or not action_text.strip():
            raise ValidationError("Action text must not be empty")

        for inv in invariants:
            if not inv.active:
                continue

            action_lower = action_text.lower()
            inv_text_lower = inv.text.lower()

            if self._is_violation(action_lower, inv_text_lower):
                self._logger.warning(
                    "Invariant violation detected",
                    invariant_id=inv.id,
                    invariant_text=inv.text,
                    action_text=action_text,
                )
                raise InvariantViolationError(
                    invariant_id=inv.id,
                    violated_text=inv.text,
                    action_text=action_text,
                    details={
                        "scope": inv.scope.value,
                        "source_name": inv.source_name,
                    },
                )

    @staticmethod
    def _is_violation(action_lower: str, invariant_lower: str) -> bool:
        """Check if an action violates an invariant (heuristic).

        This is a placeholder implementation using simple text patterns.
        In production, this would use semantic analysis or LLM evaluation.

        Detects negation patterns (e.g. "never", "must not", "do not") in the
        invariant text, then checks whether the action contains the primary
        verb or a distinctive keyword from the prohibited subject.

        Args:
            action_lower: Lowercase action text.
            invariant_lower: Lowercase invariant text.

        Returns:
            True if a violation is detected, False otherwise.
        """
        negation_patterns = [
            "do not",
            "don't",
            "cannot",
            "must not",
            "no ",
            "never ",
        ]
        # Common words that carry no discriminating signal on their own.
        stop_words = frozenset(
            {
                "a",
                "an",
                "the",
                "is",
                "are",
                "was",
                "were",
                "be",
                "been",
                "being",
                "have",
                "has",
                "had",
                "do",
                "does",
                "did",
                "will",
                "would",
                "could",
                "should",
                "may",
                "might",
                "shall",
                "can",
                "to",
                "of",
                "in",
                "for",
                "on",
                "with",
                "at",
                "by",
                "from",
                "as",
                "into",
                "through",
                "during",
                "all",
                "any",
                "both",
                "each",
                "few",
                "more",
                "most",
                "other",
                "some",
                "such",
                "than",
                "too",
                "very",
                "just",
                "but",
                "and",
                "or",
                "nor",
                "not",
                "so",
                "yet",
            }
        )
        action_words = set(action_lower.split())
        for pattern in negation_patterns:
            if pattern in invariant_lower:
                inv_subject = invariant_lower.replace(pattern, "").strip()
                if not inv_subject:
                    continue
                words = inv_subject.split()
                if not words:
                    continue
                verb = words[0]
                # Primary check: the prohibited verb appears in the action.
                if verb in action_words:
                    return True
                # Fallback: verb is a stop word or very short — check whether
                # any distinctive keyword from the invariant subject appears
                # anywhere in the action text.
                if verb in stop_words or len(verb) <= 3:
                    keywords = [
                        w for w in words[1:] if w not in stop_words and len(w) > 3
                    ]
                    if any(kw in action_lower for kw in keywords):
                        return True
        return False

    def enforce_invariants(
        self,
        plan_id: str,
        invariants: list[Invariant],
        actor_response: str | None = None,
        violated_invariant_ids: list[str] | None = None,
    ) -> list[InvariantEnforcementRecord]:
        """Create enforcement records for a set of invariants.

        Called by the Invariant Reconciliation Actor at the start of
        Strategize to record ``invariant_enforced`` decisions.

        Args:
            plan_id: The plan being checked.
            invariants: The invariants to enforce.
            actor_response: Optional response from the reconciliation actor.
            violated_invariant_ids: Optional list of invariant IDs that
                were violated.  When provided, those invariants are
                recorded with ``enforced=False`` and an
                ``INVARIANT_VIOLATED`` event is emitted.

        Returns:
            List of ``InvariantEnforcementRecord`` objects.

        Raises:
            ValidationError: If plan_id is empty.
        """
        if not plan_id or not plan_id.strip():
            raise ValidationError("Plan ID must not be empty")

        violated_ids = set(violated_invariant_ids or [])
        records: list[InvariantEnforcementRecord] = []
        response = actor_response or ""
        for inv in invariants:
            enforced = inv.id not in violated_ids
            record = InvariantEnforcementRecord(
                invariant_id=inv.id,
                enforced=enforced,
                actor_response=response,
                decision_id=str(ULID()),
            )
            records.append(record)
            if not enforced and self._event_bus is not None:
                try:
                    self._event_bus.emit(
                        DomainEvent(
                            event_type=EventType.INVARIANT_VIOLATED,
                            plan_id=plan_id,
                            details={
                                "invariant_id": inv.id,
                                "invariant_text": inv.text,
                                "scope": inv.scope.value,
                            },
                        )
                    )
                except Exception:
                    self._logger.warning(
                        "event_bus_emit_failed",
                        event_type="INVARIANT_VIOLATED",
                        plan_id=plan_id,
                        exc_info=True,
                    )

        self._enforcement_records.extend(records)
        self._logger.info(
            "Invariants enforced",
            plan_id=plan_id,
            count=len(records),
        )
        if self._event_bus is not None:
            for record in records:
                try:
                    self._event_bus.emit(
                        DomainEvent(
                            event_type=EventType.INVARIANT_ENFORCED,
                            plan_id=plan_id,
                            details={
                                "invariant_id": record.invariant_id,
                                "decision_id": record.decision_id,
                                "enforced": record.enforced,
                            },
                        )
                    )
                except Exception:
                    self._logger.warning(
                        "event_bus_emit_failed",
                        event_type="INVARIANT_ENFORCED",
                        plan_id=plan_id,
                        exc_info=True,
                    )
            # Emit a single INVARIANT_RECONCILED for the batch
            try:
                self._event_bus.emit(
                    DomainEvent(
                        event_type=EventType.INVARIANT_RECONCILED,
                        plan_id=plan_id,
                        details={
                            "invariant_count": len(records),
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="INVARIANT_RECONCILED",
                    plan_id=plan_id,
                    exc_info=True,
                )
        return records
