"""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 > 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 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,
                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,
        project_name: str | None = None,
    ) -> list[Invariant]:
        """Return the merged precedence chain for a plan/project context.

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

        Args:
            plan_id: Optional plan identifier to collect plan-scoped
                invariants.
            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)
        ]
        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, project_invs, global_invs)

    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
