"""Novelty scoring formula implementation.

Combines fingerprint-based duplicate detection with embedding-based
semantic novelty to produce a versioned, deterministic novelty score.

Formula v1: novelty = 1 - max_similarity (clamped to [0, 1])
"""

from __future__ import annotations

from services.intelligence_pipeline_v3.novelty.index import Match, NoveltyIndex
from services.intelligence_pipeline_v3.novelty.models import NearestMatch, NoveltyResult

# Current formula version — bump when the scoring algorithm changes
FORMULA_VERSION = "v1.0"


class NoveltyScorer:
    """Computes novelty scores from embedding similarity and fingerprints.

    The scorer queries the novelty index for nearest neighbors and applies
    a versioned formula to produce document-level, event-level, and combined
    novelty scores.

    Formula v1.0:
        document_novelty = 1 - max_document_similarity
        event_novelty = 1 - max_event_similarity
        combined_novelty = min(document_novelty, event_novelty)
        All values clamped to [0, 1].
    """

    def __init__(self, k: int = 5, formula_version: str = FORMULA_VERSION) -> None:
        """Initialize the scorer.

        Args:
            k: Number of nearest neighbors to retrieve for scoring.
            formula_version: Version string for the scoring formula.
        """
        self.k = k
        self.formula_version = formula_version

    def compute_novelty(
        self,
        document_embedding: list[float],
        event_embedding: list[float],
        index: NoveltyIndex,
        is_exact_duplicate: bool = False,
        is_near_duplicate: bool = False,
    ) -> NoveltyResult:
        """Compute novelty for a document and its primary event.

        Args:
            document_embedding: Embedding of the document content.
            event_embedding: Embedding of the canonical company-event.
            index: NoveltyIndex containing recent document/event embeddings.
            is_exact_duplicate: Whether an exact fingerprint match exists.
            is_near_duplicate: Whether a near-duplicate fingerprint match exists.

        Returns:
            NoveltyResult with document, event, and combined novelty scores.
        """
        # If exact duplicate, novelty is zero
        if is_exact_duplicate:
            doc_matches = index.search(document_embedding, k=self.k)
            return NoveltyResult(
                document_novelty=0.0,
                event_novelty=0.0,
                combined_novelty=0.0,
                nearest_matches=self._to_nearest_matches(doc_matches),
                formula_version=self.formula_version,
                is_exact_duplicate=True,
                is_near_duplicate=True,
            )

        # Search for similar documents
        doc_matches = index.search(document_embedding, k=self.k)
        event_matches = index.search(event_embedding, k=self.k)

        # Compute novelty from max similarity
        document_novelty = self._compute_novelty_from_matches(doc_matches)
        event_novelty = self._compute_novelty_from_matches(event_matches)

        # If near-duplicate detected via fingerprint, cap document novelty
        if is_near_duplicate:
            document_novelty = min(document_novelty, 0.2)

        # Combined novelty: conservative (take the minimum)
        combined_novelty = min(document_novelty, event_novelty)

        # Merge matches for explainability, deduplicated by doc_id
        all_matches = self._merge_matches(doc_matches, event_matches)

        return NoveltyResult(
            document_novelty=document_novelty,
            event_novelty=event_novelty,
            combined_novelty=combined_novelty,
            nearest_matches=all_matches,
            formula_version=self.formula_version,
            is_exact_duplicate=is_exact_duplicate,
            is_near_duplicate=is_near_duplicate,
        )

    def _compute_novelty_from_matches(self, matches: list[Match]) -> float:
        """Apply the v1 formula: novelty = 1 - max_similarity."""
        if not matches:
            return 1.0  # No history = fully novel

        max_sim = max(m.similarity_score for m in matches)
        novelty = 1.0 - max_sim
        return max(0.0, min(1.0, novelty))

    def _to_nearest_matches(self, matches: list[Match]) -> list[NearestMatch]:
        """Convert internal Match objects to NearestMatch models."""
        return [
            NearestMatch(
                doc_id=m.doc_id,
                similarity_score=m.similarity_score,
                metadata=m.metadata,
            )
            for m in matches
        ]

    def _merge_matches(
        self, doc_matches: list[Match], event_matches: list[Match]
    ) -> list[NearestMatch]:
        """Merge document and event matches, keeping highest similarity per doc_id."""
        seen: dict[str, NearestMatch] = {}

        for m in doc_matches + event_matches:
            if m.doc_id not in seen or m.similarity_score > seen[m.doc_id].similarity_score:
                seen[m.doc_id] = NearestMatch(
                    doc_id=m.doc_id,
                    similarity_score=m.similarity_score,
                    metadata=m.metadata,
                )

        return sorted(seen.values(), key=lambda x: x.similarity_score, reverse=True)
