"""
cortex/core/promotion.py — Promotion Scoring Engine (Issue #376).

Computes a multi-factor promotion score for semantic claims to decide
whether they should be promoted from session → long-term memory during
the sleep pipeline's S2 stage.

Canonical formula (weights sum to 1.0):
    promotion_score =
        0.30 * evidence_strength +
        0.25 * cross_session_frequency +
        0.20 * user_explicitness +
        0.10 * retrieval_utility +
        0.10 * temporal_stability +
        0.05 * contradiction_absence

Each component is normalized to [0.0, 1.0].

Integration:
    - Called by S2Promoter during the sleep pipeline
    - Reads from StorageAdapter (claim history, session counts) and
      FeedbackSystem (retrieval utility)
    - Score is returned with per-component breakdown for logging/audit

Dependencies:
    - cortex.storage.adapter.StorageAdapter (M1) — claim queries
    - cortex.core.feedback.FeedbackSystem (M12) — retrieval utility
    - cortex.config.CortexConfig — weight configuration

Design decisions:
    - Batch scoring via score_batch() for efficiency (shared storage queries)
    - All weights configurable via CortexConfig with sane defaults
    - No LLM calls — purely data-driven (fast, deterministic, cheap)
    - Graceful degradation: missing data defaults vary per component:
        - evidence_strength: confidence defaults to 0.5, access_count to 0
        - cross_session_frequency: 1 session → 0.0
        - user_explicitness: 'inferred' → 0.3, unknown → 0.5, 'explicit' → 1.0
        - retrieval_utility: no feedback + no access → 0.5 (neutral)
        - temporal_stability: no created_at → 0.5 (neutral)
        - contradiction_absence: 0 contradictions → 1.0 (optimistic)
"""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Dict, List, Optional

if TYPE_CHECKING:
    from cortex.storage.adapter import StorageAdapter
    from cortex.config import CortexConfig
    from cortex.core.feedback import FeedbackSystem
    from cortex.types import SemanticClaim

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------

@dataclass
class PromotionScore:
    """Breakdown of a single claim's promotion score."""

    claim_id: str
    overall: float
    """Weighted sum of all components, range [0.0, 1.0]."""

    # Per-component scores, each [0.0, 1.0]
    evidence_strength: float = 0.0
    cross_session_frequency: float = 0.0
    user_explicitness: float = 0.0
    retrieval_utility: float = 0.0
    temporal_stability: float = 0.0
    contradiction_absence: float = 0.0

    # Promotion decision
    promoted: bool = False
    """True if overall >= threshold."""

    reason: str = ""
    """Human-readable explanation of the decision."""

    def to_dict(self) -> dict:
        """Serialize the score to a plain dict for JSON export."""
        return {
            "claim_id": self.claim_id,
            "overall": round(self.overall, 4),
            "components": {
                "evidence_strength": round(self.evidence_strength, 4),
                "cross_session_frequency": round(self.cross_session_frequency, 4),
                "user_explicitness": round(self.user_explicitness, 4),
                "retrieval_utility": round(self.retrieval_utility, 4),
                "temporal_stability": round(self.temporal_stability, 4),
                "contradiction_absence": round(self.contradiction_absence, 4),
            },
            "promoted": self.promoted,
            "reason": self.reason,
        }


@dataclass
class PromotionWeights:
    """Configurable weight vector for the promotion formula.

    Weights must sum to 1.0. Validated at construction time.
    """

    evidence_strength: float = 0.30
    cross_session_frequency: float = 0.25
    user_explicitness: float = 0.20
    retrieval_utility: float = 0.10
    temporal_stability: float = 0.10
    contradiction_absence: float = 0.05

    def __post_init__(self) -> None:
        total = (
            self.evidence_strength
            + self.cross_session_frequency
            + self.user_explicitness
            + self.retrieval_utility
            + self.temporal_stability
            + self.contradiction_absence
        )
        if abs(total - 1.0) > 0.01:
            raise ValueError(
                f"Promotion weights must sum to 1.0, got {total:.4f}"
            )

    def as_dict(self) -> Dict[str, float]:
        """Return weight components as a plain dict."""
        return {
            "evidence_strength": self.evidence_strength,
            "cross_session_frequency": self.cross_session_frequency,
            "user_explicitness": self.user_explicitness,
            "retrieval_utility": self.retrieval_utility,
            "temporal_stability": self.temporal_stability,
            "contradiction_absence": self.contradiction_absence,
        }


# ---------------------------------------------------------------------------
# PromotionScorer
# ---------------------------------------------------------------------------

class PromotionScorer:
    """
    Multi-factor promotion scoring engine.

    Computes a weighted score for each claim based on six normalized
    components. Claims scoring above the threshold are recommended
    for promotion from session → long-term memory.

    All methods are async. No LLM calls — purely data-driven.

    Args:
        config:          CortexConfig (reads promotion.* fields).
        feedback_system: FeedbackSystem for retrieval utility (optional).
        weights:         Override weight vector (default: from config or canonical).
        threshold:       Score threshold for promotion (default: from config or 0.6).
        min_sessions:    Minimum sessions before eligible (default: from config or 1).
    """

    # Stability asymptote: days after which temporal_stability ≈ 1.0
    _STABILITY_HALF_LIFE_DAYS: float = 7.0

    # Evidence strength: number of sources for full score
    _EVIDENCE_FULL_SOURCES: int = 5

    # Contradiction absence: count at which score → 0.0
    _CONTRADICTION_ZERO_COUNT: int = 5

    def __init__(
        self,
        config: Optional["CortexConfig"] = None,
        feedback_system: Optional["FeedbackSystem"] = None,
        *,
        weights: Optional[PromotionWeights] = None,
        threshold: Optional[float] = None,
        min_sessions: Optional[int] = None,
    ) -> None:
        self._config = config
        self._feedback_system = feedback_system

        # Resolve weights: explicit > config > defaults
        if weights is not None:
            self._weights = weights
        elif config is not None and hasattr(config, "promotion_weight_evidence_strength"):
            self._weights = PromotionWeights(
                evidence_strength=getattr(config, "promotion_weight_evidence_strength", 0.30),
                cross_session_frequency=getattr(config, "promotion_weight_cross_session_frequency", 0.25),
                user_explicitness=getattr(config, "promotion_weight_user_explicitness", 0.20),
                retrieval_utility=getattr(config, "promotion_weight_retrieval_utility", 0.10),
                temporal_stability=getattr(config, "promotion_weight_temporal_stability", 0.10),
                contradiction_absence=getattr(config, "promotion_weight_contradiction_absence", 0.05),
            )
        else:
            self._weights = PromotionWeights()

        # Resolve threshold
        if threshold is not None:
            self._threshold = threshold
        elif config is not None and hasattr(config, "promotion_threshold"):
            self._threshold = getattr(config, "promotion_threshold", 0.6)
        else:
            self._threshold = 0.6

        # Resolve min_sessions
        if min_sessions is not None:
            self._min_sessions = min_sessions
        elif config is not None and hasattr(config, "promotion_min_sessions"):
            self._min_sessions = getattr(config, "promotion_min_sessions", 1)
        else:
            self._min_sessions = 1

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    async def score(
        self,
        claim: "SemanticClaim",
        session_id: str,
        storage: "StorageAdapter",
    ) -> PromotionScore:
        """
        Compute the promotion score for a single claim.

        Args:
            claim:      The SemanticClaim to evaluate.
            session_id: Current session ID (for cross-session counting).
            storage:    StorageAdapter for data queries.

        Returns:
            PromotionScore with overall score and per-component breakdown.
        """
        results = await self.score_batch([claim], session_id, storage)
        return results[0]

    async def score_batch(
        self,
        claims: List["SemanticClaim"],
        session_id: str,
        storage: "StorageAdapter",
    ) -> List[PromotionScore]:
        """
        Compute promotion scores for a batch of claims.

        Batching enables shared storage queries (e.g., one call to get
        all session counts rather than N individual queries).

        Args:
            claims:     List of SemanticClaim objects to evaluate.
            session_id: Current session ID.
            storage:    StorageAdapter for data queries.

        Returns:
            List of PromotionScore objects (same order as input claims).
        """
        if not claims:
            return []

        t_start = time.monotonic()

        # Batch-fetch cross-session data
        session_counts = await self._batch_session_counts(claims, storage)
        contradiction_counts = await self._batch_contradiction_counts(claims, storage)
        feedback_scores = await self._batch_feedback_scores(claims)

        results: List[PromotionScore] = []
        for claim in claims:
            cid = claim.id

            # Compute each component
            evidence = self._compute_evidence_strength(claim)
            cross_session = self._compute_cross_session_frequency(
                session_counts.get(cid, 1)
            )
            explicitness = self._compute_user_explicitness(claim)
            utility = self._compute_retrieval_utility(
                claim, feedback_scores.get(cid)
            )
            stability = self._compute_temporal_stability(claim)
            contradiction = self._compute_contradiction_absence(
                contradiction_counts.get(cid, 0)
            )

            # Weighted sum
            w = self._weights
            overall = (
                w.evidence_strength * evidence
                + w.cross_session_frequency * cross_session
                + w.user_explicitness * explicitness
                + w.retrieval_utility * utility
                + w.temporal_stability * stability
                + w.contradiction_absence * contradiction
            )

            # Clamp to [0.0, 1.0]
            overall = max(0.0, min(1.0, overall))

            # Promotion decision
            promoted = overall >= self._threshold
            sessions = session_counts.get(cid, 1)
            if sessions < self._min_sessions:
                promoted = False

            reason = self._build_reason(overall, promoted, sessions)

            results.append(PromotionScore(
                claim_id=cid,
                overall=overall,
                evidence_strength=evidence,
                cross_session_frequency=cross_session,
                user_explicitness=explicitness,
                retrieval_utility=utility,
                temporal_stability=stability,
                contradiction_absence=contradiction,
                promoted=promoted,
                reason=reason,
            ))

        elapsed_ms = int((time.monotonic() - t_start) * 1000)
        promoted_count = sum(1 for s in results if s.promoted)
        logger.info(
            "[Promotion] scored %d claims: %d promoted, %d below threshold "
            "(threshold=%.2f, duration_ms=%d)",
            len(results), promoted_count, len(results) - promoted_count,
            self._threshold, elapsed_ms,
        )

        return results

    @property
    def threshold(self) -> float:
        """Current promotion threshold."""
        return self._threshold

    @property
    def weights(self) -> PromotionWeights:
        """Current weight vector."""
        return self._weights

    # ------------------------------------------------------------------
    # Component computations (all return [0.0, 1.0])
    # ------------------------------------------------------------------

    def _compute_evidence_strength(self, claim: "SemanticClaim") -> float:
        """Evidence strength: based on confidence and access count.

        Higher confidence + more accesses = stronger evidence.
        Normalized: confidence contributes 60%, access_count contributes 40%.
        """
        confidence = getattr(claim, "confidence", 0.5)
        access_count = getattr(claim, "access_count", 0)

        # Access contribution: diminishing returns, cap at _EVIDENCE_FULL_SOURCES
        access_norm = min(access_count / self._EVIDENCE_FULL_SOURCES, 1.0)

        return max(0.0, min(1.0, 0.6 * confidence + 0.4 * access_norm))

    def _compute_cross_session_frequency(self, session_count: int) -> float:
        """Cross-session frequency: how many distinct sessions reference this claim.

        1 session = 0.0, 2 sessions = 0.5, 3+ = diminishing returns toward 1.0.
        Uses log-like scaling: min(1.0, (count - 1) / 3).
        """
        if session_count <= 1:
            return 0.0
        return min(1.0, (session_count - 1) / 3.0)

    def _compute_user_explicitness(self, claim: "SemanticClaim") -> float:
        """User explicitness: was this stated explicitly by the user?

        Maps from the `explicitness` field on SemanticClaim/CandidateClaim.
        'explicit' = 1.0, 'inferred' = 0.3, unknown/other = 0.5.
        """
        explicitness = getattr(claim, "explicitness", "inferred")
        if explicitness == "explicit":
            return 1.0
        if explicitness == "inferred":
            return 0.3
        return 0.5

    def _compute_retrieval_utility(
        self,
        claim: "SemanticClaim",
        feedback_score: Optional[float],
    ) -> float:
        """Retrieval utility: how often this claim was retrieved and found useful.

        Uses FeedbackSystem score if available (0.0–1.0).
        Falls back to access_count-based heuristic if no feedback system.
        Neutral default: 0.5 (no data = no penalty, no bonus).
        """
        if feedback_score is not None:
            return feedback_score

        # Fallback: use access_count as a rough proxy
        access_count = getattr(claim, "access_count", 0)
        if access_count == 0:
            return 0.5  # neutral — no data
        # Some retrieval is a positive signal; diminishing returns
        return min(1.0, 0.5 + access_count / 10.0)

    def _compute_temporal_stability(self, claim: "SemanticClaim") -> float:
        """Temporal stability: how long the claim has persisted without change.

        Older stable claims score higher. Uses exponential saturation:
        score = 1 - exp(-age_days / half_life).

        If no created_at, returns neutral 0.5.
        """
        created_at = getattr(claim, "created_at", None)
        if not created_at:
            return 0.5

        try:
            import math
            if isinstance(created_at, str):
                dt = datetime.fromisoformat(created_at)
                if dt.tzinfo is None:
                    dt = dt.replace(tzinfo=timezone.utc)
            else:
                dt = created_at

            age_days = (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0
            if age_days < 0:
                return 0.0
            return 1.0 - math.exp(-age_days / self._STABILITY_HALF_LIFE_DAYS)
        except (ValueError, TypeError):
            return 0.5

    def _compute_contradiction_absence(self, contradiction_count: int) -> float:
        """Contradiction absence: 1.0 if no contradictions, decreasing with count.

        0 contradictions = 1.0
        _CONTRADICTION_ZERO_COUNT or more = 0.0
        Linear interpolation between.
        """
        if contradiction_count <= 0:
            return 1.0
        if contradiction_count >= self._CONTRADICTION_ZERO_COUNT:
            return 0.0
        return 1.0 - (contradiction_count / self._CONTRADICTION_ZERO_COUNT)

    # ------------------------------------------------------------------
    # Batch data fetchers
    # ------------------------------------------------------------------

    async def _batch_session_counts(
        self,
        claims: List["SemanticClaim"],
        storage: "StorageAdapter",
    ) -> Dict[str, int]:
        """Get distinct session counts for each claim's subject.

        Returns: {claim_id: session_count}
        """
        counts: Dict[str, int] = {}
        # Group by subject to minimize queries
        subjects_seen: Dict[str, List[str]] = {}  # subject → [claim_ids]
        for claim in claims:
            subject = getattr(claim, "subject", "")
            subjects_seen.setdefault(subject, []).append(claim.id)

        for subject, claim_ids in subjects_seen.items():
            try:
                subject_claims = await storage.get_claims_by_subject(
                    subject, owner_id=getattr(claims[0], "owner_id", "default")
                )
                # Count distinct source_sessions
                sessions = {
                    getattr(c, "source_session", None)
                    for c in subject_claims
                    if getattr(c, "source_session", None) is not None
                }
                session_count = max(1, len(sessions))
                for cid in claim_ids:
                    counts[cid] = session_count
            except Exception as exc:
                logger.warning(
                    "[Promotion] session count query failed for subject=%r: %s",
                    subject, exc,
                )
                for cid in claim_ids:
                    counts[cid] = 1
        return counts

    async def _batch_contradiction_counts(
        self,
        claims: List["SemanticClaim"],
        storage: "StorageAdapter",
    ) -> Dict[str, int]:
        """Get contradiction counts for each claim.

        Checks if any claims with the same subject+predicate have been
        superseded, which indicates contradictions occurred.

        Returns: {claim_id: contradiction_count}
        """
        counts: Dict[str, int] = {}
        subjects_seen: Dict[str, List] = {}
        for claim in claims:
            subject = getattr(claim, "subject", "")
            subjects_seen.setdefault(subject, []).append(claim)

        for subject, subject_claims_batch in subjects_seen.items():
            try:
                all_subject_claims = await storage.get_claims_by_subject(
                    subject,
                    owner_id=getattr(subject_claims_batch[0], "owner_id", "default"),
                )
                # Count superseded claims per predicate as contradiction signals
                superseded_by_predicate: Dict[str, int] = {}
                for c in all_subject_claims:
                    if getattr(c, "status", "") == "superseded":
                        pred = getattr(c, "predicate", "")
                        superseded_by_predicate[pred] = (
                            superseded_by_predicate.get(pred, 0) + 1
                        )

                for claim in subject_claims_batch:
                    pred = getattr(claim, "predicate", "")
                    counts[claim.id] = superseded_by_predicate.get(pred, 0)
            except Exception as exc:
                logger.warning(
                    "[Promotion] contradiction count query failed for subject=%r: %s",
                    subject, exc,
                )
                for claim in subject_claims_batch:
                    counts[claim.id] = 0
        return counts

    async def _batch_feedback_scores(
        self,
        claims: List["SemanticClaim"],
    ) -> Dict[str, Optional[float]]:
        """Get feedback-based retrieval utility scores for each claim.

        Uses FeedbackSystem.get_scores() for batch fetching to avoid N+1
        sequential queries. Falls back to individual get_score() calls if
        the batch method is unavailable.

        Returns: {claim_id: score_or_None}
        """
        scores: Dict[str, Optional[float]] = {}
        if self._feedback_system is None:
            for claim in claims:
                scores[claim.id] = None
            return scores

        claim_ids = [claim.id for claim in claims]

        # Prefer batch method to avoid N+1 sequential queries (audit fix C-HIGH-1)
        if hasattr(self._feedback_system, "get_scores"):
            try:
                batch_results = await self._feedback_system.get_scores(claim_ids)
                for cid in claim_ids:
                    fb = batch_results.get(cid)
                    scores[cid] = fb.score if fb is not None else None
                return scores
            except Exception as exc:
                logger.warning(
                    "[Promotion] batch feedback scores failed, falling back to "
                    "sequential: %s", exc,
                )

        # Fallback: sequential calls (legacy feedback systems without get_scores)
        for claim in claims:
            try:
                fb_score = await self._feedback_system.get_score(claim.id)
                scores[claim.id] = fb_score.score
            except Exception as exc:
                logger.warning(
                    "[Promotion] feedback score failed for claim=%s: %s",
                    claim.id, exc,
                )
                scores[claim.id] = None
        return scores

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _build_reason(
        self,
        overall: float,
        promoted: bool,
        session_count: int,
    ) -> str:
        """Build a human-readable reason string for the promotion decision."""
        if promoted:
            return f"promote:score({overall:.3f}>={self._threshold:.2f})"
        if session_count < self._min_sessions:
            return (
                f"skip:min_sessions({session_count}<{self._min_sessions})"
            )
        return f"skip:below_threshold({overall:.3f}<{self._threshold:.2f})"
