"""AI-assisted insight extraction for Valence privacy.

Implements Issue #95: Extract shareable insights from private content using AI.
Supports multiple extraction levels with human review before sharing.
"""

import hashlib
import os
import uuid
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import Enum
from typing import Any


class ExtractionLevel(Enum):
    """Level of detail for insight extraction.

    Controls how much of the original content is preserved vs abstracted.
    Higher abstraction levels are safer for wider sharing.
    """

    THEMES = "themes"  # High-level themes only (most abstract)
    KEY_POINTS = "key_points"  # Main points without detail
    SUMMARY = "summary"  # Condensed summary preserving key info
    ANONYMIZED = "anonymized"  # Full content with PII/identifiers removed


class ExtractionStatus(Enum):
    """Status of an extraction in the review workflow."""

    PENDING_REVIEW = "pending_review"  # Awaiting human approval
    APPROVED = "approved"  # Approved for sharing
    REJECTED = "rejected"  # Rejected, not shareable
    MODIFIED = "modified"  # Approved with human modifications


class ExtractionError(Exception):
    """Base exception for extraction operations."""

    pass


class ExtractorNotAvailableError(ExtractionError):
    """Raised when no AI extractor is configured."""

    pass


class ExtractionNotFoundError(ExtractionError):
    """Raised when an extraction record is not found."""

    pass


class ExtractionAlreadyReviewedError(ExtractionError):
    """Raised when trying to review an already-reviewed extraction."""

    pass


@dataclass
class ExtractionProvenance:
    """Tracks the relationship between extracted content and its source.

    Records that the extracted content was "derived from" the source,
    allowing provenance chains to indicate transformation.
    """

    source_id: str  # ID of source belief/content
    source_hash: str  # Hash of source content for integrity
    extracted_at: datetime
    extraction_level: ExtractionLevel
    extractor_id: str  # Identifier for the extractor used
    extraction_metadata: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        """Serialize to dictionary."""
        return {
            "source_id": self.source_id,
            "source_hash": self.source_hash,
            "extracted_at": self.extracted_at.isoformat(),
            "extraction_level": self.extraction_level.value,
            "extractor_id": self.extractor_id,
            "extraction_metadata": self.extraction_metadata,
            "relationship": "extracted_from",
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "ExtractionProvenance":
        """Deserialize from dictionary."""
        return cls(
            source_id=data["source_id"],
            source_hash=data["source_hash"],
            extracted_at=datetime.fromisoformat(data["extracted_at"]),
            extraction_level=ExtractionLevel(data["extraction_level"]),
            extractor_id=data["extractor_id"],
            extraction_metadata=data.get("extraction_metadata", {}),
        )


@dataclass
class ExtractedInsight:
    """Result of an insight extraction operation.

    Contains the extracted content along with provenance and review status.
    """

    extraction_id: str
    content: str  # The extracted insight text
    level: ExtractionLevel
    provenance: ExtractionProvenance
    status: ExtractionStatus = ExtractionStatus.PENDING_REVIEW

    # Review tracking
    reviewed_at: datetime | None = None
    reviewed_by: str | None = None
    review_notes: str | None = None

    # If modified during review
    original_extraction: str | None = None  # Original AI output if modified

    # Metadata
    created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
    metadata: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        """Serialize to dictionary."""
        return {
            "extraction_id": self.extraction_id,
            "content": self.content,
            "level": self.level.value,
            "provenance": self.provenance.to_dict(),
            "status": self.status.value,
            "reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None,
            "reviewed_by": self.reviewed_by,
            "review_notes": self.review_notes,
            "original_extraction": self.original_extraction,
            "created_at": self.created_at.isoformat(),
            "metadata": self.metadata,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "ExtractedInsight":
        """Deserialize from dictionary."""
        return cls(
            extraction_id=data["extraction_id"],
            content=data["content"],
            level=ExtractionLevel(data["level"]),
            provenance=ExtractionProvenance.from_dict(data["provenance"]),
            status=ExtractionStatus(data["status"]),
            reviewed_at=(datetime.fromisoformat(data["reviewed_at"]) if data.get("reviewed_at") else None),
            reviewed_by=data.get("reviewed_by"),
            review_notes=data.get("review_notes"),
            original_extraction=data.get("original_extraction"),
            created_at=datetime.fromisoformat(data["created_at"]),
            metadata=data.get("metadata", {}),
        )

    @property
    def is_pending(self) -> bool:
        """Check if extraction is pending review."""
        return self.status == ExtractionStatus.PENDING_REVIEW

    @property
    def is_approved(self) -> bool:
        """Check if extraction is approved for sharing."""
        return self.status in (ExtractionStatus.APPROVED, ExtractionStatus.MODIFIED)

    @property
    def is_rejected(self) -> bool:
        """Check if extraction was rejected."""
        return self.status == ExtractionStatus.REJECTED

    @property
    def was_modified(self) -> bool:
        """Check if extraction was modified during review."""
        return self.status == ExtractionStatus.MODIFIED


class InsightExtractor(ABC):
    """Abstract base class for AI insight extractors.

    Implementations should extract insights from content at various
    abstraction levels. The mock implementation is provided for testing.
    """

    @property
    @abstractmethod
    def extractor_id(self) -> str:
        """Unique identifier for this extractor."""
        pass

    @abstractmethod
    def extract(
        self,
        content: str,
        level: ExtractionLevel,
        context: dict[str, Any] | None = None,
    ) -> str:
        """Extract insights from content at the specified level.

        Args:
            content: The source content to extract from
            level: The desired abstraction level
            context: Optional context for the extraction (domain, intent, etc.)

        Returns:
            Extracted insight text
        """
        pass

    def supports_level(self, level: ExtractionLevel) -> bool:
        """Check if this extractor supports a given level."""
        return True  # Default: support all levels


class MockInsightExtractor(InsightExtractor):
    """Mock AI extractor for testing. **TEST ONLY - DO NOT USE IN PRODUCTION.**

    ⚠️  WARNING: This class is intended ONLY for testing purposes.
    It provides deterministic, template-based outputs that simulate AI extraction
    without making real AI calls. Using this in production would bypass actual
    insight extraction and could expose private content.

    This extractor will raise RuntimeError if instantiated when VALENCE_ENV
    is set to 'production'.

    For production use, implement a real InsightExtractor subclass that calls
    an actual AI service.
    """

    def __init__(
        self,
        extractor_id: str = "mock-extractor-v1",
        custom_extractors: dict[ExtractionLevel, Callable[[str], str]] | None = None,
        _allow_in_production: bool = False,
    ):
        """Initialize the mock extractor.

        Args:
            extractor_id: Identifier for this extractor instance
            custom_extractors: Optional custom extraction functions per level
            _allow_in_production: Internal flag for testing the guard itself.
                DO NOT use this in application code.

        Raises:
            RuntimeError: If VALENCE_ENV is set to 'production' and
                _allow_in_production is False.
        """
        # Production guard: prevent accidental use in production
        env = os.environ.get("VALENCE_ENV", "").lower()
        if env == "production" and not _allow_in_production:
            raise RuntimeError(
                "MockInsightExtractor cannot be used in production. "
                "VALENCE_ENV is set to 'production'. "
                "Please use a real InsightExtractor implementation for production workloads."
            )

        self._extractor_id = extractor_id
        self._custom_extractors = custom_extractors or {}

    @property
    def extractor_id(self) -> str:
        return self._extractor_id

    def extract(
        self,
        content: str,
        level: ExtractionLevel,
        context: dict[str, Any] | None = None,
    ) -> str:
        """Extract insights using mock/template logic."""
        # Use custom extractor if provided
        if level in self._custom_extractors:
            return self._custom_extractors[level](content)

        # Default mock extraction behavior
        word_count = len(content.split())
        content[:100] + "..." if len(content) > 100 else content

        if level == ExtractionLevel.THEMES:
            # Extract mock themes (very abstract)
            return self._extract_themes(content)

        elif level == ExtractionLevel.KEY_POINTS:
            # Extract key points
            return self._extract_key_points(content)

        elif level == ExtractionLevel.SUMMARY:
            # Create summary
            return self._extract_summary(content)

        elif level == ExtractionLevel.ANONYMIZED:
            # Remove identifying information
            return self._extract_anonymized(content)

        return f"[Extracted at {level.value} level from {word_count} word content]"

    def _extract_themes(self, content: str) -> str:
        """Mock theme extraction."""
        words = content.lower().split()
        # Simulate finding themes based on word frequency
        word_freq: dict[str, int] = {}
        stopwords = {
            "the",
            "a",
            "an",
            "is",
            "are",
            "was",
            "were",
            "be",
            "been",
            "to",
            "of",
            "and",
            "in",
            "that",
            "it",
            "for",
            "on",
            "with",
        }
        for word in words:
            word = word.strip(".,!?;:'\"")
            if word and len(word) > 3 and word not in stopwords:
                word_freq[word] = word_freq.get(word, 0) + 1

        # Get top themes
        themes = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:3]
        if themes:
            theme_list = ", ".join(t[0] for t in themes)
            return f"Themes: {theme_list}"
        return "Themes: general content"

    def _extract_key_points(self, content: str) -> str:
        """Mock key points extraction."""
        sentences = content.replace("!", ".").replace("?", ".").split(".")
        sentences = [s.strip() for s in sentences if s.strip()]

        if not sentences:
            return "Key points: No clear points identified"

        # Take first few sentences as "key points"
        key_sentences = sentences[: min(3, len(sentences))]
        points = "\n".join(f"• {s}" for s in key_sentences)
        return f"Key points:\n{points}"

    def _extract_summary(self, content: str) -> str:
        """Mock summary extraction."""
        words = content.split()
        if len(words) <= 20:
            return content

        # Simple truncation-based summary
        summary_words = words[: max(20, len(words) // 3)]
        return " ".join(summary_words) + "..."

    def _extract_anonymized(self, content: str) -> str:
        """Mock anonymization (remove potential PII patterns)."""
        import re

        result = content

        # Replace email-like patterns
        result = re.sub(r"\b[\w.-]+@[\w.-]+\.\w+\b", "[EMAIL]", result)

        # Replace phone-like patterns
        result = re.sub(r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b", "[PHONE]", result)

        # Replace potential names (capitalized words not at sentence start)
        # This is intentionally simplistic for the mock
        result = re.sub(r"(?<!^)(?<![.!?]\s)\b[A-Z][a-z]+\b", "[NAME]", result)

        # Replace SSN-like patterns
        result = re.sub(r"\b\d{3}-\d{2}-\d{4}\b", "[SSN]", result)

        return result


def _compute_content_hash(content: str) -> str:
    """Compute a hash of content for provenance integrity."""
    return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]


def extract_insights(
    content: str,
    level: ExtractionLevel,
    source_id: str,
    extractor: InsightExtractor | None = None,
    context: dict[str, Any] | None = None,
) -> ExtractedInsight:
    """Extract shareable insights from content using AI.

    The extracted insight is created in PENDING_REVIEW status and must
    be approved by a human before sharing.

    Args:
        content: The source content to extract from
        level: The desired abstraction level
        source_id: ID of the source belief/content for provenance
        extractor: The AI extractor to use (defaults to MockInsightExtractor)
        context: Optional context for the extraction

    Returns:
        ExtractedInsight in PENDING_REVIEW status

    Raises:
        ExtractorNotAvailableError: If no extractor is available
    """
    if extractor is None:
        extractor = MockInsightExtractor()

    # Perform extraction
    extracted_content = extractor.extract(content, level, context)

    # Create provenance record
    provenance = ExtractionProvenance(
        source_id=source_id,
        source_hash=_compute_content_hash(content),
        extracted_at=datetime.now(UTC),
        extraction_level=level,
        extractor_id=extractor.extractor_id,
        extraction_metadata=context or {},
    )

    # Create insight record
    return ExtractedInsight(
        extraction_id=str(uuid.uuid4()),
        content=extracted_content,
        level=level,
        provenance=provenance,
        status=ExtractionStatus.PENDING_REVIEW,
        metadata={"source_length": len(content)},
    )


def approve_extraction(
    insight: ExtractedInsight,
    reviewer: str,
    notes: str | None = None,
) -> ExtractedInsight:
    """Approve an extracted insight for sharing.

    Args:
        insight: The insight to approve
        reviewer: DID of the reviewer approving
        notes: Optional review notes

    Returns:
        Updated insight with APPROVED status

    Raises:
        ExtractionAlreadyReviewedError: If already reviewed
    """
    if not insight.is_pending:
        raise ExtractionAlreadyReviewedError(f"Extraction {insight.extraction_id} is already {insight.status.value}")

    return ExtractedInsight(
        extraction_id=insight.extraction_id,
        content=insight.content,
        level=insight.level,
        provenance=insight.provenance,
        status=ExtractionStatus.APPROVED,
        reviewed_at=datetime.now(UTC),
        reviewed_by=reviewer,
        review_notes=notes,
        original_extraction=None,
        created_at=insight.created_at,
        metadata=insight.metadata,
    )


def reject_extraction(
    insight: ExtractedInsight,
    reviewer: str,
    reason: str | None = None,
) -> ExtractedInsight:
    """Reject an extracted insight (not suitable for sharing).

    Args:
        insight: The insight to reject
        reviewer: DID of the reviewer rejecting
        reason: Optional rejection reason

    Returns:
        Updated insight with REJECTED status

    Raises:
        ExtractionAlreadyReviewedError: If already reviewed
    """
    if not insight.is_pending:
        raise ExtractionAlreadyReviewedError(f"Extraction {insight.extraction_id} is already {insight.status.value}")

    return ExtractedInsight(
        extraction_id=insight.extraction_id,
        content=insight.content,
        level=insight.level,
        provenance=insight.provenance,
        status=ExtractionStatus.REJECTED,
        reviewed_at=datetime.now(UTC),
        reviewed_by=reviewer,
        review_notes=reason,
        original_extraction=None,
        created_at=insight.created_at,
        metadata=insight.metadata,
    )


def modify_extraction(
    insight: ExtractedInsight,
    reviewer: str,
    modified_content: str,
    notes: str | None = None,
) -> ExtractedInsight:
    """Approve with modifications (human edits the AI extraction).

    Args:
        insight: The insight to modify and approve
        reviewer: DID of the reviewer
        modified_content: The human-edited content
        notes: Optional notes about the modifications

    Returns:
        Updated insight with MODIFIED status and new content

    Raises:
        ExtractionAlreadyReviewedError: If already reviewed
    """
    if not insight.is_pending:
        raise ExtractionAlreadyReviewedError(f"Extraction {insight.extraction_id} is already {insight.status.value}")

    return ExtractedInsight(
        extraction_id=insight.extraction_id,
        content=modified_content,
        level=insight.level,
        provenance=insight.provenance,
        status=ExtractionStatus.MODIFIED,
        reviewed_at=datetime.now(UTC),
        reviewed_by=reviewer,
        review_notes=notes,
        original_extraction=insight.content,  # Preserve original AI output
        created_at=insight.created_at,
        metadata=insight.metadata,
    )


class RateLimitExceededError(ExtractionError):
    """Raised when extraction rate limit is exceeded for a DID."""

    def __init__(self, did: str, limit: int, window_seconds: int):
        self.did = did
        self.limit = limit
        self.window_seconds = window_seconds
        super().__init__(f"Rate limit exceeded for {did}: {limit} extractions per {window_seconds}s")


@dataclass
class RateLimitConfig:
    """Configuration for extraction rate limiting.

    Issue #177: Rate limiting prevents abuse of extraction service by
    limiting how many extractions a single DID can request.
    """

    max_extractions_per_window: int = 10  # Max extractions per DID per window
    window_seconds: int = 3600  # 1 hour window
    enabled: bool = True


class ExtractionService:
    """Service for managing the extraction workflow.

    Provides storage and retrieval of extractions with support for
    the human review process.

    Issue #177: Includes per-DID rate limiting to prevent abuse.
    """

    def __init__(
        self,
        extractor: InsightExtractor | None = None,
        rate_limit_config: RateLimitConfig | None = None,
    ):
        """Initialize the extraction service.

        Args:
            extractor: The AI extractor to use (defaults to MockInsightExtractor)
            rate_limit_config: Rate limiting configuration (defaults to 10/hour per DID)
        """
        self._extractor = extractor or MockInsightExtractor()
        self._rate_limit = rate_limit_config or RateLimitConfig()
        self._extractions: dict[str, ExtractedInsight] = {}
        self._by_source: dict[str, list[str]] = {}  # source_id -> [extraction_ids]
        self._by_reviewer: dict[str, list[str]] = {}  # reviewer -> [extraction_ids]
        # Rate limit tracking: DID -> list of extraction timestamps
        self._extraction_times: dict[str, list[datetime]] = {}

    @property
    def extractor(self) -> InsightExtractor:
        """Get the configured extractor."""
        return self._extractor

    def set_extractor(self, extractor: InsightExtractor) -> None:
        """Set a new extractor."""
        self._extractor = extractor

    def _check_rate_limit(self, requester_did: str) -> None:
        """Check if a DID has exceeded the rate limit.

        Args:
            requester_did: The DID requesting extraction

        Raises:
            RateLimitExceededError: If rate limit is exceeded
        """
        if not self._rate_limit.enabled:
            return

        now = datetime.now(UTC)
        window_start = now - timedelta(seconds=self._rate_limit.window_seconds)

        # Get or create extraction times for this DID
        if requester_did not in self._extraction_times:
            self._extraction_times[requester_did] = []

        # Clean up old entries outside the window
        self._extraction_times[requester_did] = [t for t in self._extraction_times[requester_did] if t > window_start]

        # Check if limit exceeded
        if len(self._extraction_times[requester_did]) >= self._rate_limit.max_extractions_per_window:
            raise RateLimitExceededError(
                requester_did,
                self._rate_limit.max_extractions_per_window,
                self._rate_limit.window_seconds,
            )

    def _record_extraction(self, requester_did: str) -> None:
        """Record an extraction for rate limiting."""
        if requester_did not in self._extraction_times:
            self._extraction_times[requester_did] = []
        self._extraction_times[requester_did].append(datetime.now(UTC))

    def extract(
        self,
        content: str,
        level: ExtractionLevel,
        source_id: str,
        requester_did: str | None = None,
        context: dict[str, Any] | None = None,
    ) -> ExtractedInsight:
        """Extract insights and store for review.

        Args:
            content: The source content to extract from
            level: The desired abstraction level
            source_id: ID of the source belief/content for provenance
            requester_did: Optional DID of requester (for rate limiting)
            context: Optional context for the extraction

        Returns:
            ExtractedInsight in PENDING_REVIEW status

        Raises:
            RateLimitExceededError: If requester has exceeded rate limit
        """
        # Check rate limit if requester provided
        if requester_did:
            self._check_rate_limit(requester_did)

        insight = extract_insights(
            content=content,
            level=level,
            source_id=source_id,
            extractor=self._extractor,
            context=context,
        )

        self._store_extraction(insight)

        # Record for rate limiting
        if requester_did:
            self._record_extraction(requester_did)

        return insight

    def get_rate_limit_status(self, requester_did: str) -> dict[str, Any]:
        """Get rate limit status for a DID.

        Args:
            requester_did: The DID to check

        Returns:
            Dict with remaining extractions and window reset time
        """
        now = datetime.now(UTC)
        window_start = now - timedelta(seconds=self._rate_limit.window_seconds)

        times = self._extraction_times.get(requester_did, [])
        recent = [t for t in times if t > window_start]

        remaining = max(0, self._rate_limit.max_extractions_per_window - len(recent))

        # Calculate when the oldest extraction in window will expire
        reset_at = None
        if recent:
            oldest = min(recent)
            reset_at = oldest + timedelta(seconds=self._rate_limit.window_seconds)

        return {
            "remaining": remaining,
            "limit": self._rate_limit.max_extractions_per_window,
            "window_seconds": self._rate_limit.window_seconds,
            "reset_at": reset_at.isoformat() if reset_at else None,
            "used": len(recent),
        }

    def approve(
        self,
        extraction_id: str,
        reviewer: str,
        notes: str | None = None,
    ) -> ExtractedInsight:
        """Approve an extraction by ID.

        Raises:
            ExtractionNotFoundError: If extraction doesn't exist
            ExtractionAlreadyReviewedError: If already reviewed
        """
        insight = self.get(extraction_id)
        if insight is None:
            raise ExtractionNotFoundError(f"Extraction {extraction_id} not found")

        updated = approve_extraction(insight, reviewer, notes)
        self._extractions[extraction_id] = updated
        self._index_reviewer(updated)
        return updated

    def reject(
        self,
        extraction_id: str,
        reviewer: str,
        reason: str | None = None,
    ) -> ExtractedInsight:
        """Reject an extraction by ID.

        Raises:
            ExtractionNotFoundError: If extraction doesn't exist
            ExtractionAlreadyReviewedError: If already reviewed
        """
        insight = self.get(extraction_id)
        if insight is None:
            raise ExtractionNotFoundError(f"Extraction {extraction_id} not found")

        updated = reject_extraction(insight, reviewer, reason)
        self._extractions[extraction_id] = updated
        self._index_reviewer(updated)
        return updated

    def modify(
        self,
        extraction_id: str,
        reviewer: str,
        modified_content: str,
        notes: str | None = None,
    ) -> ExtractedInsight:
        """Modify and approve an extraction by ID.

        Raises:
            ExtractionNotFoundError: If extraction doesn't exist
            ExtractionAlreadyReviewedError: If already reviewed
        """
        insight = self.get(extraction_id)
        if insight is None:
            raise ExtractionNotFoundError(f"Extraction {extraction_id} not found")

        updated = modify_extraction(insight, reviewer, modified_content, notes)
        self._extractions[extraction_id] = updated
        self._index_reviewer(updated)
        return updated

    def get(self, extraction_id: str) -> ExtractedInsight | None:
        """Get an extraction by ID."""
        return self._extractions.get(extraction_id)

    def get_for_source(self, source_id: str) -> list[ExtractedInsight]:
        """Get all extractions from a specific source."""
        extraction_ids = self._by_source.get(source_id, [])
        return [self._extractions[eid] for eid in extraction_ids if eid in self._extractions]

    def get_by_reviewer(self, reviewer: str) -> list[ExtractedInsight]:
        """Get all extractions reviewed by a specific reviewer."""
        extraction_ids = self._by_reviewer.get(reviewer, [])
        return [self._extractions[eid] for eid in extraction_ids if eid in self._extractions]

    def get_pending(self) -> list[ExtractedInsight]:
        """Get all extractions pending review."""
        return [e for e in self._extractions.values() if e.is_pending]

    def get_approved(self) -> list[ExtractedInsight]:
        """Get all approved extractions (including modified)."""
        return [e for e in self._extractions.values() if e.is_approved]

    def _store_extraction(self, insight: ExtractedInsight) -> None:
        """Store extraction and update indices."""
        self._extractions[insight.extraction_id] = insight

        source_id = insight.provenance.source_id
        if source_id not in self._by_source:
            self._by_source[source_id] = []
        self._by_source[source_id].append(insight.extraction_id)

    def _index_reviewer(self, insight: ExtractedInsight) -> None:
        """Index extraction by reviewer after review."""
        if insight.reviewed_by:
            if insight.reviewed_by not in self._by_reviewer:
                self._by_reviewer[insight.reviewed_by] = []
            if insight.extraction_id not in self._by_reviewer[insight.reviewed_by]:
                self._by_reviewer[insight.reviewed_by].append(insight.extraction_id)


# Module-level singleton for convenient access
_extraction_service: ExtractionService | None = None


def get_extraction_service() -> ExtractionService:
    """Get the global extraction service singleton."""
    global _extraction_service
    if _extraction_service is None:
        _extraction_service = ExtractionService()
    return _extraction_service


def set_extraction_service(service: ExtractionService) -> None:
    """Set the global extraction service singleton."""
    global _extraction_service
    _extraction_service = service
