"""
cortex/core/reconciliation/storage_helpers.py — Claim storage helpers.

Mixin class providing _supersede_stale_claims, _add_claim, and
_record_provenance methods for ReconciliationEngine.

Extracted from the monolithic reconciliation.py (#946).
"""

from __future__ import annotations

import json
import logging
import re
from typing import Optional, TYPE_CHECKING

from cortex.core.reconciliation.models import _now  # noqa: F401 — used in methods

if TYPE_CHECKING:
    from cortex.storage.adapter import SemanticClaim

logger = logging.getLogger(__name__)

# Alias for _add_claim metadata serialization fallback
_log = logger

# ---------------------------------------------------------------------------
# Subject normalization (#1430)
# ---------------------------------------------------------------------------

_MULTI_SPACE = re.compile(r"\s+")


def _normalize_subject(subject: str) -> str:
    """Normalize a claim subject for consistent matching.

    - Lowercase
    - Strip leading/trailing whitespace
    - Collapse multiple whitespace to single space
    """
    if not isinstance(subject, str):
        subject = str(subject) if subject is not None else ""
    return _MULTI_SPACE.sub(" ", subject.strip().lower())


# ---------------------------------------------------------------------------
# Generic-predicate set for predicate specificity gate (#1430)
# ---------------------------------------------------------------------------

_GENERIC_PREDICATES: frozenset[str] = frozenset({
    "prefers", "decided", "has", "plans", "uses", "learned",
    "requires", "wants", "needs", "believes", "thinks",
    "values", "likes", "dislikes", "chose", "maintains",
    "adopted", "established", "considers", "knows",
})


def _cosine_similarity(a: list[float], b: list[float]) -> float:
    """Compute cosine similarity between two embedding vectors."""
    import math
    if len(a) != len(b):
        logger.warning(
            "Embedding dimension mismatch: %d vs %d — returning 0.0",
            len(a), len(b),
        )
        return 0.0
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(x * x for x in b))
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)


class StorageHelpersMixin:
    """Mixin providing claim storage helper methods.

    Intended to be mixed into ReconciliationEngine (via ActionsMixin).
    Expects ``self.storage`` (StorageAdapter) and ``self.config`` (CortexConfig).
    """

    async def _supersede_stale_claims(
        self,
        new_claim: "SemanticClaim",
        owner_id: str,
        already_superseded_id: Optional[str] = None,
    ) -> None:
        """Deterministically supersede active claims with same owner+subject+predicate (#601).

        Called after every successful _add_claim() (unless supersede_stale=False) to
        ensure that when a new claim is stored, any older active claims with the same
        subject+predicate are marked superseded.

        Can be disabled at runtime via config.deterministic_supersession_enabled = False
        (#1576 — 96% false positive rate found in production audit).
        """
        # Kill switch (#1576): disable deterministic S+P supersession entirely.
        # When disabled, supersession only happens via LLM-mediated action=UPDATE path
        # (which has semantic context to distinguish same-topic from cross-topic).
        if not getattr(self.config, "deterministic_supersession_enabled", True):
            logger.debug(
                "Deterministic supersession disabled (#1576) — skipping for %s/%s",
                new_claim.subject, new_claim.predicate,
            )
            return

        try:
            existing_claims = await self.storage.get_claims_by_subject(
                new_claim.subject, owner_id=owner_id
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning(
                "Deterministic supersession scan failed for %s/%s: %s",
                new_claim.subject, new_claim.predicate, exc,
            )
            return

        # --- Batch-embed for semantic similarity gate (#1430) ---
        # Pre-compute all embeddings in a single API call instead of N per-candidate.
        # Fail-closed: if embeddings unavailable, block supersession rather than
        # risk destroying valid memories. Missing embeddings caught by backfill worker (#1443).
        base_threshold = getattr(
            self.config, "supersession_similarity_threshold", 0.80,
        )
        generic_threshold = getattr(
            self.config, "supersession_generic_predicate_threshold", 0.92,
        )
        # Select threshold based on predicate specificity (#1430)
        _pred_normalized = (new_claim.predicate or "").strip().lower()
        if _pred_normalized in _GENERIC_PREDICATES:
            threshold = generic_threshold
        else:
            threshold = base_threshold
        _similarity_scores: dict[str, float] = {}  # claim_id -> cosine sim
        _embed_available = False

        _new_subject_norm = _normalize_subject(new_claim.subject or "")
        candidates = [
            c for c in existing_claims
            if c.id != new_claim.id
            and c.id != already_superseded_id
            and c.predicate == new_claim.predicate
            and c.status == "active"
            and c.object_value != new_claim.object_value
            and _normalize_subject(c.subject or "") == _new_subject_norm
        ]

        _has_embed_method = (
            hasattr(self, "llm") and hasattr(self.llm, "embed")
        )
        _attempted_embed = False

        if candidates and _has_embed_method:
            new_text = new_claim.object_value or ""
            # Filter out candidates with empty object_value — can't meaningfully
            # compare embeddings of empty strings.
            embeddable = [c for c in candidates if c.object_value]
            if new_text and embeddable:
                _attempted_embed = True
                try:
                    all_texts = [new_text] + [c.object_value for c in embeddable]
                    resp = await self.llm.embed(texts=all_texts)
                    if (
                        resp
                        and resp.embeddings
                        and len(resp.embeddings) == len(all_texts)
                    ):
                        _embed_available = True
                        new_vec = resp.embeddings[0]
                        for i, cand in enumerate(embeddable):
                            _similarity_scores[cand.id] = _cosine_similarity(
                                new_vec, resp.embeddings[i + 1]
                            )
                except Exception as exc:
                    logger.error(
                        "Batch embedding for similarity gate failed — "
                        "blocking supersession (fail-closed): %s",
                        exc,
                    )
                    # _embed_available stays False → all candidates blocked below

        for claim in candidates:
            # Semantic similarity gate (#1430):
            # - If we have embeddings: only supersede if cosine > threshold
            # - If embed was attempted but failed: block (fail-closed)
            # - If no embed method exists: allow supersession (legacy behavior)
            # - If claim has empty object_value: skip gate (garbage data, supersede it)
            if _has_embed_method and _attempted_embed and claim.object_value:
                sim = _similarity_scores.get(claim.id)
                if sim is None or sim <= threshold:
                    _gate_type = (
                        "generic-predicate"
                        if _pred_normalized in _GENERIC_PREDICATES
                        else "standard"
                    )
                    reason = (
                        f"cosine={sim:.3f} <= {threshold:.2f} ({_gate_type})"
                        if sim is not None
                        else "no embedding available (fail-closed)"
                    )
                    logger.info(
                        "Supersession blocked (#1430): %s for %s/%s '%s' vs '%s'",
                        reason,
                        new_claim.subject, new_claim.predicate,
                        (claim.object_value or "")[:50],
                        (new_claim.object_value or "")[:50],
                    )
                    continue

            try:
                await self.storage.update_claim(
                    claim.id,
                    status="superseded",
                    superseded_by=new_claim.id,
                    updated_at=_now(),
                )
            except Exception as exc:  # noqa: BLE001
                logger.warning(
                    "Deterministic supersession update failed for claim %s: %s",
                    claim.id, exc,
                )
                continue

            logger.info(
                "Deterministic supersession (#601): claim %s superseded by %s "
                "(subject=%r predicate=%r '%s' -> '%s')",
                claim.id[:8], new_claim.id[:8],
                new_claim.subject, new_claim.predicate,
                claim.object_value, new_claim.object_value,
            )
            try:
                history = await self.storage.get_changelog(claim.id)
                next_ver = len(history) + 1
                await self.storage.log_changelog(
                    claim_id=claim.id,
                    version=next_ver,
                    content_snapshot=claim.object_value,
                    action="superseded",
                    reason=(
                        "deterministic supersession: same subject+predicate, "
                        "different value (#601)"
                    ),
                    changed_by="reconciliation",
                    diff_summary=f"'{ claim.object_value}' -> '{new_claim.object_value}'",
                )
            except Exception as exc:  # noqa: BLE001
                logger.warning(
                    "Failed to log changelog for deterministic supersession %s: %s",
                    claim.id, exc,
                )

    async def _add_claim(
        self,
        candidate: "CandidateClaim",
        session_id: str,
        owner_id: str,
        subject_entity_id: Optional[str] = None,
        supersedes_id: Optional[str] = None,
        relationship_metadata: Optional[dict] = None,
        supersede_stale: bool = True,
        valid_from: Optional[str] = None,
    ) -> "SemanticClaim":
        """Create a new SemanticClaim for this candidate and record provenance."""
        # Build structured notes string
        notes_parts: list[str] = []
        if supersedes_id:
            notes_parts.append(f"supersedes:{supersedes_id}")
        if relationship_metadata:
            notes_parts.append(f"relationship_metadata:{json.dumps(relationship_metadata, separators=(',', ':'))}")

        effective_confidence = candidate.confidence
        if effective_confidence <= 0.0:
            effective_confidence = getattr(
                self.config, "session_confidence_default", 0.5
            )

        kwargs: dict = {
            "owner_id": owner_id,
            "memory_type": "session",
            "confidence": effective_confidence,
            "sensitivity": getattr(candidate, "sensitivity", "normal"),
            "category": getattr(candidate, "category", "misc"),
            "source_session": session_id,
            "created_at": _now(),
            "updated_at": _now(),
        }
        # v8 envelope fields (#355/#357)
        _intrinsic = getattr(candidate, "intrinsic_type", None)
        if _intrinsic:
            kwargs["intrinsic_type"] = _intrinsic
        _human_family = getattr(candidate, "human_family", None)
        if _human_family:
            kwargs["human_family"] = _human_family
        # LoCoMo fields (v10, #516)
        _memory_class = getattr(candidate, "memory_class", None)
        if _memory_class:
            kwargs["memory_class"] = _memory_class
        _event_date = getattr(candidate, "event_date", None)
        if _event_date:
            kwargs["event_date"] = _event_date
        _sequence_order = getattr(candidate, "sequence_order", None)
        if _sequence_order is not None:
            kwargs["sequence_order"] = _sequence_order
        _salience = getattr(candidate, "salience", None)
        if _salience:
            kwargs["salience"] = _salience
        # Status override
        _status = getattr(candidate, "status", None)
        if _status and _status != "active":
            kwargs["status"] = _status
        # Causal linking (#518)
        _cause = getattr(candidate, "cause", None)
        if _cause:
            kwargs["cause"] = _cause
        _effect = getattr(candidate, "effect", None)
        if _effect:
            kwargs["effect"] = _effect
        # State transitions (#519)
        _state_before = getattr(candidate, "state_before", None)
        if _state_before:
            kwargs["state_before"] = _state_before
        _state_after = getattr(candidate, "state_after", None)
        if _state_after:
            kwargs["state_after"] = _state_after
        # Temporal fields (#573, v12)
        _temporal = getattr(candidate, "temporal", None)
        if _temporal:
            kwargs["temporal"] = _temporal
        _temporal_marker = getattr(candidate, "temporal_marker", None)
        if _temporal_marker:
            kwargs["temporal_marker"] = _temporal_marker
        # Explicitness + stability (#609, v13)
        _explicitness = getattr(candidate, "explicitness", None)
        if _explicitness:
            kwargs["explicitness"] = _explicitness
        _stability = getattr(candidate, "stability", None)
        if _stability:
            kwargs["stability"] = _stability
        if subject_entity_id:
            kwargs["subject_entity_id"] = subject_entity_id
        # Entity scoping (#923)
        _scope_entity_id = getattr(candidate, "scope_entity_id", None)
        if _scope_entity_id:
            kwargs["entity_id"] = _scope_entity_id
        _scope_entity_type = getattr(candidate, "scope_entity_type", None)
        if _scope_entity_type and _scope_entity_type != "agent":
            kwargs["entity_type"] = _scope_entity_type
        # Source agent (#1396)
        _source_agent = getattr(candidate, "source_agent", None)
        if _source_agent:
            kwargs["source_agent"] = _source_agent
        # Temporal validity (#925)
        if valid_from is not None:
            kwargs["valid_from"] = valid_from
        if notes_parts:
            kwargs["notes"] = "; ".join(notes_parts)
        # Provenance metadata (issue #748)
        _metadata = getattr(candidate, "metadata", None)
        if _metadata and isinstance(_metadata, dict):
            import json as _json_meta

            class _SafeEncoder(_json_meta.JSONEncoder):
                """Stringify non-serializable values (datetime, UUID, etc.)."""
                def default(self, o: object) -> str:  # noqa: ANN401
                    return str(o)

            try:
                kwargs["metadata_json"] = _json_meta.dumps(
                    _metadata, separators=(",", ":"), cls=_SafeEncoder,
                )
            except (TypeError, ValueError) as exc:
                _log.warning(
                    "metadata serialization fell back to str(): %s", exc,
                )
                kwargs["metadata_json"] = _json_meta.dumps(
                    str(_metadata), separators=(",", ":"),
                )

        claim = await self.storage.create_claim(
            subject=candidate.subject,
            predicate=candidate.predicate,
            object_value=candidate.object_value,
            **kwargs,
        )
        await self._record_provenance(claim.id, session_id, candidate)

        # Mentions counter (#924)
        _subj_eid = kwargs.get("subject_entity_id")
        _obj_eid = getattr(candidate, "object_entity_id", None)
        for _eid in filter(None, [_subj_eid, _obj_eid]):
            try:
                await self.storage.increment_entity_mentions(_eid)
            except Exception as exc:  # noqa: BLE001
                logger.debug("Failed to increment mention_count for entity %s: %s", _eid, exc)

        # Changelog: every new claim starts at version 1
        try:
            await self.storage.log_changelog(
                claim_id=claim.id,
                version=1,
                content_snapshot=candidate.object_value,
                action="created",
                reason=f"{candidate.subject} {candidate.predicate}",
                changed_by="extraction",
                diff_summary=None,
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning("Failed to log changelog for new claim %s: %s", claim.id, exc)

        # Deterministic supersession (#601)
        if supersede_stale:
            await self._supersede_stale_claims(
                new_claim=claim,
                owner_id=owner_id,
                already_superseded_id=supersedes_id,
            )

        return claim

    async def _record_provenance(
        self,
        claim_id: str,
        session_id: str,
        candidate: "CandidateClaim",
    ) -> None:
        """Write a claim_provenance row linking this claim to its source episode."""
        source_span = getattr(candidate, "source_span", None)
        span_start = source_span[0] if source_span else None
        span_end = source_span[1] if source_span else None

        episode_id = getattr(candidate, "episode_event_id", None)
        if not episode_id:
            logger.debug(
                "Skipping provenance for claim %s — no episode_event_id", claim_id[:8]
            )
            return

        try:
            await self.storage.add_provenance(
                claim_id=claim_id,
                episode_event_id=episode_id,
                span_start=span_start,
                span_end=span_end,
                extraction_method="llm_extract",
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning("Failed to record provenance for claim %s: %s", claim_id, exc)
