"""
CRUD service for Criteria management with undo support.

Ported from AI_Screening_UI criteria_workspace/service.py.
Uses in-memory storage (no database dependency).
All mutating operations return (result, UndoAction) tuples.
"""

from __future__ import annotations

import logging
from datetime import datetime
from difflib import SequenceMatcher
from typing import Callable, Optional

from .models import (
    CriterionSource,
    CriterionType,
    ExclusionCriterion,
    UndoAction,
)

logger = logging.getLogger(__name__)


class CriteriaService:
    """
    In-memory CRUD and state management for eligibility criteria.

    All mutating operations return UndoAction objects for undo functionality.
    Storage is a plain list; swap with a DB-backed implementation in production.
    """

    def __init__(self, project_id: int = 0):
        self.project_id = project_id
        self._criteria: list[ExclusionCriterion] = []
        self._next_id: int = 1
        self._change_callbacks: list[Callable] = []

    # --- CRUD Operations ---

    def add_criterion(
        self,
        category: str,
        text: str,
        description: Optional[str] = None,
        source: CriterionSource = CriterionSource.human,
        is_active: bool = True,
        criterion_type: CriterionType = CriterionType.exclude,
        ai_confidence: Optional[float] = None,
        ai_rationale: Optional[str] = None,
        notes: Optional[str] = None,
    ) -> tuple[ExclusionCriterion, UndoAction]:
        """
        Add a new criterion.

        Returns:
            Tuple of (created criterion, undo action)
        """
        if isinstance(criterion_type, str):
            criterion_type = CriterionType(criterion_type)
        if isinstance(source, str):
            source = CriterionSource(source)

        criterion_id = self._next_id
        self._next_id += 1

        criterion = ExclusionCriterion(
            id=criterion_id,
            project_id=self.project_id,
            category=category,
            text=text,
            description=description,
            source=source,
            is_active=is_active,
            criterion_type=criterion_type,
            ai_confidence=ai_confidence,
            ai_rationale=ai_rationale,
            notes=notes,
        )
        self._criteria.append(criterion)

        logger.info(
            "Criterion added: id=%d, type=%s, category=%s, text=%.40s",
            criterion_id, criterion_type.value, category, text,
        )

        undo = UndoAction(
            action_type="add",
            criterion_id=criterion_id,
            previous_state={},
            description=f"Added '{text[:30]}...'",
        )

        self._notify_change()
        return criterion, undo

    def update_criterion(
        self,
        criterion_id: int,
        updates: dict,
    ) -> tuple[ExclusionCriterion, UndoAction]:
        """
        Update an existing criterion.

        Args:
            criterion_id: ID of criterion to update
            updates: Dictionary of fields to update

        Returns:
            Tuple of (updated criterion, undo action)
        """
        current = self.get_criterion(criterion_id)
        if not current:
            raise ValueError(f"Criterion {criterion_id} not found")

        previous_state = current.model_dump()

        allowed_fields = {
            "category", "text", "description", "is_active",
            "ai_confidence", "ai_rationale", "notes",
        }
        update_fields = {k: v for k, v in updates.items() if k in allowed_fields}

        if not update_fields:
            return current, UndoAction(
                action_type="update",
                criterion_id=criterion_id,
                previous_state=previous_state,
                description="No changes",
            )

        # Apply updates by replacing the criterion in the list
        idx = self._find_index(criterion_id)
        new_data = current.model_dump()
        new_data.update(update_fields)
        new_data["modified_at"] = datetime.now()
        updated = ExclusionCriterion(**new_data)
        self._criteria[idx] = updated

        logger.info(
            "Criterion updated: id=%d, fields=%s",
            criterion_id, list(update_fields.keys()),
        )

        undo = UndoAction(
            action_type="update",
            criterion_id=criterion_id,
            previous_state=previous_state,
            description=f"Updated '{current.text[:30]}...'",
        )

        self._notify_change()
        return updated, undo

    def delete_criterion(self, criterion_id: int) -> UndoAction:
        """
        Delete a criterion.

        Returns:
            Undo action
        """
        current = self.get_criterion(criterion_id)
        if not current:
            raise ValueError(f"Criterion {criterion_id} not found")

        previous_state = current.model_dump()
        idx = self._find_index(criterion_id)
        self._criteria.pop(idx)

        logger.info("Criterion deleted: id=%d, text=%.40s", criterion_id, current.text)

        undo = UndoAction(
            action_type="delete",
            criterion_id=criterion_id,
            previous_state=previous_state,
            description=f"Deleted '{current.text[:30]}...'",
        )

        self._notify_change()
        return undo

    def get_criterion(self, criterion_id: int) -> Optional[ExclusionCriterion]:
        """Get a single criterion by ID."""
        for c in self._criteria:
            if c.id == criterion_id:
                return c
        return None

    def get_all_criteria(
        self,
        criterion_type: Optional[CriterionType] = None,
    ) -> list[ExclusionCriterion]:
        """
        Get all criteria for the project, optionally filtered by type.
        """
        criteria = self._criteria
        if criterion_type:
            if isinstance(criterion_type, str):
                criterion_type = CriterionType(criterion_type)
            criteria = [c for c in criteria if c.criterion_type == criterion_type]
        return sorted(criteria, key=lambda c: (c.category, c.text))

    def get_active_criteria(
        self,
        criterion_type: Optional[CriterionType] = None,
    ) -> list[ExclusionCriterion]:
        """Get all active criteria (is_active=True)."""
        criteria = [c for c in self._criteria if c.is_active]
        if criterion_type:
            if isinstance(criterion_type, str):
                criterion_type = CriterionType(criterion_type)
            criteria = [c for c in criteria if c.criterion_type == criterion_type]
        return sorted(criteria, key=lambda c: (c.category, c.text))

    def get_inactive_criteria(
        self,
        criterion_type: Optional[CriterionType] = None,
    ) -> list[ExclusionCriterion]:
        """Get all inactive criteria (is_active=False)."""
        criteria = [c for c in self._criteria if not c.is_active]
        if criterion_type:
            if isinstance(criterion_type, str):
                criterion_type = CriterionType(criterion_type)
            criteria = [c for c in criteria if c.criterion_type == criterion_type]
        return sorted(criteria, key=lambda c: (c.category, c.text))

    def get_criteria_counts(self, criterion_type: CriterionType) -> tuple[int, int]:
        """
        Get count of active and total criteria for a given type.

        Returns:
            Tuple of (active_count, total_count)
        """
        if isinstance(criterion_type, str):
            criterion_type = CriterionType(criterion_type)
        typed = [c for c in self._criteria if c.criterion_type == criterion_type]
        active = sum(1 for c in typed if c.is_active)
        return active, len(typed)

    # --- Activation/Deactivation ---

    def activate(self, criterion_id: int) -> UndoAction:
        """Activate a criterion."""
        criterion, undo = self.update_criterion(criterion_id, {"is_active": True})
        undo.action_type = "activate"
        undo.description = f"Activated '{criterion.text[:30]}...'"
        return undo

    def deactivate(self, criterion_id: int) -> UndoAction:
        """Deactivate a criterion."""
        criterion, undo = self.update_criterion(criterion_id, {"is_active": False})
        undo.action_type = "deactivate"
        undo.description = f"Deactivated '{criterion.text[:30]}...'"
        return undo

    # --- Bulk Operations ---

    def add_ai_suggestions(
        self,
        criteria: list[ExclusionCriterion],
        dedup_threshold: float = 0.85,
    ) -> int:
        """
        Add AI-generated criteria as inactive, with deduplication.

        Returns:
            Count of criteria added (after dedup)
        """
        added_count = 0

        for criterion in criteria:
            duplicates = self.find_near_duplicates(criterion.text, threshold=dedup_threshold)
            if duplicates:
                continue

            self.add_criterion(
                category=criterion.category,
                text=criterion.text,
                description=criterion.description,
                source=CriterionSource.ai_generated,
                is_active=False,
                criterion_type=criterion.criterion_type,
                ai_confidence=criterion.ai_confidence,
                ai_rationale=criterion.ai_rationale,
            )
            added_count += 1

        if added_count > 0:
            skipped = len(criteria) - added_count
            logger.info(
                "AI suggestions added: %d criteria (skipped %d duplicates)",
                added_count, skipped,
            )

        return added_count

    # --- Grouping ---

    def get_by_category(self, active_only: bool = False) -> dict[str, list[ExclusionCriterion]]:
        """Get criteria grouped by category."""
        criteria = self.get_active_criteria() if active_only else self.get_all_criteria()
        grouped: dict[str, list[ExclusionCriterion]] = {}
        for criterion in criteria:
            grouped.setdefault(criterion.category, []).append(criterion)
        return grouped

    # --- Deduplication ---

    def find_near_duplicates(
        self,
        text: str,
        threshold: float = 0.85,
    ) -> list[ExclusionCriterion]:
        """
        Find existing criteria with similar text using fuzzy matching.

        Args:
            text: Text to check for duplicates
            threshold: Similarity threshold (0.0-1.0)

        Returns:
            List of similar criteria
        """
        duplicates = []
        for criterion in self._criteria:
            similarity = SequenceMatcher(None, text.lower(), criterion.text.lower()).ratio()
            if similarity >= threshold:
                duplicates.append(criterion)
        return duplicates

    # --- Export ---

    def export_for_screening(self) -> list[dict]:
        """Export active criteria for screening pipeline."""
        active = self.get_active_criteria()
        return [
            {
                "category": c.category,
                "text": c.text,
                "description": c.description or "",
            }
            for c in active
        ]

    # --- Observer Pattern ---

    def on_change(self, callback: Callable) -> None:
        """Register a callback for criteria changes."""
        if callback not in self._change_callbacks:
            self._change_callbacks.append(callback)

    def _notify_change(self) -> None:
        """Notify all registered observers of a change."""
        for callback in self._change_callbacks:
            try:
                callback()
            except Exception as e:
                logger.error("Error in change callback: %s", e)

    # --- Undo ---

    def undo(self, action: UndoAction) -> None:
        """
        Undo a previous action.

        Args:
            action: The UndoAction to reverse
        """
        if action.action_type == "add":
            # Delete the added criterion
            idx = self._find_index(action.criterion_id)
            if idx is not None:
                self._criteria.pop(idx)

        elif action.action_type == "delete":
            # Re-add the deleted criterion from previous_state
            state = action.previous_state
            # Convert datetime strings back if needed
            if isinstance(state.get("created_at"), str):
                state["created_at"] = datetime.fromisoformat(state["created_at"])
            if state.get("modified_at") and isinstance(state["modified_at"], str):
                state["modified_at"] = datetime.fromisoformat(state["modified_at"])
            criterion = ExclusionCriterion(**state)
            self._criteria.append(criterion)

        elif action.action_type in ("update", "activate", "deactivate"):
            # Restore previous state
            state = action.previous_state.copy()
            idx = self._find_index(action.criterion_id)
            if idx is not None:
                if isinstance(state.get("created_at"), str):
                    state["created_at"] = datetime.fromisoformat(state["created_at"])
                if state.get("modified_at") and isinstance(state["modified_at"], str):
                    state["modified_at"] = datetime.fromisoformat(state["modified_at"])
                self._criteria[idx] = ExclusionCriterion(**state)

        self._notify_change()

    # --- Internal helpers ---

    def _find_index(self, criterion_id: int) -> Optional[int]:
        """Find the index of a criterion by ID."""
        for i, c in enumerate(self._criteria):
            if c.id == criterion_id:
                return i
        return None
