"""
MODULE 7: DEPRECATION PIPELINE
================================
Time-based memory lifecycle management using Ebbinghaus forgetting curves.

Lifecycle tiers:
    active → cooling → deprecated → archived

Protection rules:
    - Cornerstones (is_cornerstone / memory_type='cornerstone') → NEVER deprecated
    - Immutable memories → NEVER deprecated
    - Grace period enforced before status transitions

Dream cycle integration:
    - run_decay() is the batch job called during sleep/dream cycles
    - Processes all owner memories in configurable chunks

Session review:
    - review_session() evaluates newly created session memories
    - Uses LLM judge for borderline cases (via CodingNoiseFilter fast-path)

Access reinforcement:
    - check_reinforcement() called on every retrieval use
    - Resets cooling timer, restores confidence if memory was cooling

Dependencies:
    - M1: StorageAdapter (get_claims_by_status, update_claim, log_deprecation)
    - M5: CornerstoneGuardian (get_cornerstones — to build exempt ID set)
    - M7: CodingNoiseFilter (fast pre-filter for session review)
    - CortexConfig (cooling_days, archive_days)
"""

from __future__ import annotations

import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple

from cortex.deprecation.decay import apply_decay, compute_reinforcement
from cortex.extraction.coding_noise import CodingNoiseFilter

if TYPE_CHECKING:
    from cortex.config import CortexConfig
    from cortex.storage.adapter import StorageAdapter
    from cortex.types import LLMProvider, SemanticClaim

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Status constants — lifecycle tiers
# ---------------------------------------------------------------------------

STATUS_ACTIVE = "active"
STATUS_COOLING = "cooling"      # was "stale" in some docs, using "cooling" per arch
STATUS_DEPRECATED = "deprecated"
STATUS_ARCHIVED = "archived"

# Protected memory types — these never enter the deprecation lifecycle
from cortex.types import EXEMPT_MEMORY_TYPES
_EXEMPT_MEMORY_TYPES = EXEMPT_MEMORY_TYPES

# Confidence threshold below which cooling memories become deprecated
_DEPRECATION_CONFIDENCE_THRESHOLD = 0.3

# ---------------------------------------------------------------------------
# Session review prompt
# ---------------------------------------------------------------------------

SESSION_REVIEW_PROMPT = """Review these session memories and decide which matter beyond today's session.

Session memories:
{claims_json}

For each, classify:
- PROMOTE: Important fact, preference, decision, or learning that should persist. Examples: user decisions, discovered patterns, relationship updates, stated preferences.
- KEEP_SESSION: Useful context for today but not long-term. Examples: current task state, temporary preferences, working notes.
- DISCARD: Noise. Examples: code changes, build outputs, file edits, debugging steps, temporary variables.

Output a JSON array only (no other text): [{{"claim_id": "<id>", "action": "PROMOTE|KEEP_SESSION|DISCARD", "reason": "<short reason>"}}]"""


# ---------------------------------------------------------------------------
# Public data-transfer objects
# ---------------------------------------------------------------------------

@dataclass
class DeprecationDecision:
    """A single deprecation decision for one claim."""
    claim_id: str
    action: str         # 'promote' | 'keep_session' | 'discard' | 'noise' | 'decay' | 'archive' | 'reinforce' | 'skip'
    reason: str
    classifier: str     # 'noise_filter' | 'llm_judge' | 'decay' | 'grace_period' | 'cornerstone_protection'
    old_status: Optional[str] = None
    new_status: Optional[str] = None
    old_confidence: Optional[float] = None
    new_confidence: Optional[float] = None


@dataclass
class DeprecationReport:
    """Summary report from review_session()."""
    session_id: str
    total_reviewed: int
    promoted: int
    kept_session: int
    deprecated: int
    auto_noise: int
    duration_ms: int
    decisions: List[DeprecationDecision] = field(default_factory=list)


@dataclass
class DecayReport:
    """Summary report from run_decay()."""
    owner_id: str
    total_scanned: int
    transitioned_to_cooling: int
    transitioned_to_deprecated: int
    transitioned_to_archived: int
    confidence_reduced: int
    skipped_protected: int
    duration_ms: int
    decisions: List[DeprecationDecision] = field(default_factory=list)


@dataclass
class ReinforcementReport:
    """Report from check_reinforcement()."""
    claim_id: str
    action: str         # 'reinforced' | 'not_found' | 'protected' | 'skipped'
    old_confidence: float
    new_confidence: float
    old_access_count: int
    new_access_count: int


# ---------------------------------------------------------------------------
# Main class
# ---------------------------------------------------------------------------

class DeprecationPipeline:
    """
    Manages memory lifecycle: session review, time-based decay, and archival.

    Usage (dream cycle):
        pipeline = DeprecationPipeline(storage, config, llm)
        report = await pipeline.run_decay(owner_id="default")

    Usage (session completion):
        report = await pipeline.review_session(session_id="sess_abc")

    Usage (on retrieval):
        await pipeline.check_reinforcement(claim_id="claim_xyz")
    """

    def __init__(
        self,
        storage: "StorageAdapter",
        config: "CortexConfig",
        llm: Optional["LLMProvider"] = None,
    ) -> None:
        self.storage = storage
        self.config = config
        self.llm = llm
        self.noise_filter = CodingNoiseFilter()

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

    async def review_session(self, session_id: str) -> DeprecationReport:
        """
        Review all session memories from a completed session.

        Pipeline per claim:
        1. Noise filter (fast regex) → 'noise' | 'keep' | 'maybe'
        2. LLM judge for 'maybe' claims (if llm available, else default keep)
        3. Apply decisions: promote, keep_session, deprecate
        4. Log all decisions to deprecation_log
        """
        t_start = time.monotonic()
        claims = await self.storage.get_session_claims(session_id)

        # Build exempt IDs from cornerstones
        exempt_ids = await self._get_exempt_ids()

        decisions: List[DeprecationDecision] = []
        promoted = kept = deprecated = auto_noise = 0

        # Split by noise filter
        noise_claims: List["SemanticClaim"] = []
        keep_claims: List["SemanticClaim"] = []
        maybe_claims: List["SemanticClaim"] = []

        for claim in claims:
            if claim.id in exempt_ids or claim.memory_type in _EXEMPT_MEMORY_TYPES:
                decision = DeprecationDecision(
                    claim_id=claim.id,
                    action="skip",
                    reason="Protected memory — cornerstone or immutable",
                    classifier="cornerstone_protection",
                    old_status=claim.status,
                    new_status=claim.status,
                )
                decisions.append(decision)
                continue

            text = self._claim_to_text(claim)
            verdict = self._classify_noise(text)

            if verdict == "noise":
                noise_claims.append(claim)
            elif verdict == "keep":
                keep_claims.append(claim)
            else:
                maybe_claims.append(claim)

        # Apply noise → deprecate immediately
        for claim in noise_claims:
            decision = await self._apply_deprecate(
                claim,
                reason="Coding noise detected by regex filter",
                classifier="noise_filter",
                session_id=session_id,
            )
            decisions.append(decision)
            auto_noise += 1
            deprecated += 1

        # Apply keep → keep as session memory
        for claim in keep_claims:
            decision = DeprecationDecision(
                claim_id=claim.id,
                action="keep_session",
                reason="Noise filter: clear signal content, retained as session memory",
                classifier="noise_filter",
                old_status=claim.status,
                new_status=STATUS_ACTIVE,
            )
            decisions.append(decision)
            kept += 1

        # LLM judge for maybe claims
        llm_decisions = await self._llm_judge_session(maybe_claims, session_id)
        for llm_dec in llm_decisions:
            decisions.append(llm_dec)
            if llm_dec.action == "promote":
                promoted += 1
            elif llm_dec.action == "keep_session":
                kept += 1
            elif llm_dec.action == "discard":
                deprecated += 1

        # Apply all decisions to storage and log
        for dec in decisions:
            if dec.action == "skip":
                continue
            # Always apply promote (memory_type change) and discard (status change).
            # For keep_session the status stays active — no update needed unless changed.
            needs_update = (
                dec.action == "promote"
                or dec.action == "discard"
                or (dec.new_status is not None and dec.new_status != dec.old_status)
            )
            if needs_update:
                try:
                    kwargs: Dict[str, Any] = {}
                    if dec.new_status is not None:
                        kwargs["status"] = dec.new_status
                    if dec.new_confidence is not None:
                        kwargs["confidence"] = dec.new_confidence
                    if dec.action == "promote":
                        kwargs["memory_type"] = "semantic"
                        kwargs["confidence"] = self.config.promoted_confidence_default
                    if kwargs:
                        await self.storage.update_claim(dec.claim_id, **kwargs)
                except Exception:
                    logger.exception("Failed to apply decision for claim %s", dec.claim_id)

                # Changelog: record promote / deprecate lifecycle transitions
                if dec.action in ("promote", "discard"):
                    changelog_action = "promoted" if dec.action == "promote" else "deprecated"
                    try:
                        history = await self.storage.get_changelog(dec.claim_id)
                        next_ver = len(history) + 1
                        await self.storage.log_changelog(
                            claim_id=dec.claim_id,
                            version=next_ver,
                            content_snapshot=None,
                            action=changelog_action,
                            reason=dec.reason,
                            changed_by="deprecation",
                            diff_summary=None,
                        )
                    except Exception:
                        logger.exception(
                            "Failed to log changelog for %s claim %s",
                            changelog_action, dec.claim_id,
                        )

            await self._log_deprecation(
                claim_id=dec.claim_id,
                session_id=session_id,
                action=dec.action,
                reason=dec.reason,
                classifier=dec.classifier,
            )

        duration_ms = int((time.monotonic() - t_start) * 1000)
        return DeprecationReport(
            session_id=session_id,
            total_reviewed=len(claims),
            promoted=promoted,
            kept_session=kept,
            deprecated=deprecated,
            auto_noise=auto_noise,
            duration_ms=duration_ms,
            decisions=decisions,
        )

    async def run_decay(self, owner_id: str = "default") -> DecayReport:
        """
        Time-based confidence reduction (dream cycle job).

        Transitions:
        - active claims not accessed in > cooling_days → status='cooling'
        - cooling claims: apply Ebbinghaus decay; confidence < threshold → 'deprecated'
        - deprecated claims older than archive_days → 'archived', removed from indexes
        - Cornerstones and immutables → always skipped
        """
        t_start = time.monotonic()

        exempt_ids = await self._get_exempt_ids()
        now = datetime.now(timezone.utc)

        cooling_days = self.config.deprecation_cooling_days
        archive_days = self.config.deprecation_archive_days

        decisions: List[DeprecationDecision] = []
        scanned = to_cooling = to_deprecated = to_archived = reduced = skipped = 0

        # Process active claims — check if they should enter cooling
        active_claims = await self.storage.get_claims_by_status(STATUS_ACTIVE, owner_id)
        for claim in active_claims:
            scanned += 1

            if self._is_exempt(claim, exempt_ids):
                skipped += 1
                continue

            days_inactive = self._days_since_access(claim.last_accessed, now)
            if days_inactive is None or days_inactive > cooling_days:
                if days_inactive is None:
                    access_desc = "never accessed"
                else:
                    access_desc = f"no access for {days_inactive:.1f}d"
                dec = await self._transition(
                    claim,
                    new_status=STATUS_COOLING,
                    reason=f"Entering cooling: {access_desc} (threshold: {cooling_days}d)",
                    classifier="decay",
                    action="cooling",
                )
                decisions.append(dec)
                to_cooling += 1

        # Process cooling claims — apply decay, check for deprecation
        cooling_claims = await self.storage.get_claims_by_status(STATUS_COOLING, owner_id)
        for claim in cooling_claims:
            scanned += 1

            if self._is_exempt(claim, exempt_ids):
                skipped += 1
                continue

            decay_result = apply_decay(
                claim_id=claim.id,
                current_confidence=claim.confidence,
                access_count=claim.access_count,
                last_accessed=claim.last_accessed,
                now=now,
            )

            if decay_result.new_confidence < _DEPRECATION_CONFIDENCE_THRESHOLD:
                # Too decayed — deprecate
                dec = await self._transition(
                    claim,
                    new_status=STATUS_DEPRECATED,
                    reason=f"Confidence decayed to {decay_result.new_confidence:.3f} (threshold: {_DEPRECATION_CONFIDENCE_THRESHOLD})",
                    classifier="decay",
                    action="deprecated",
                    new_confidence=decay_result.new_confidence,
                )
                decisions.append(dec)
                to_deprecated += 1
            elif decay_result.decayed:
                # Reduce confidence but stay cooling
                try:
                    await self.storage.update_claim(
                        claim.id, confidence=decay_result.new_confidence
                    )
                    reduced += 1
                    await self._log_deprecation(
                        claim_id=claim.id,
                        action="confidence_reduced",
                        reason=f"Ebbinghaus decay: {claim.confidence:.3f} → {decay_result.new_confidence:.3f} (retention={decay_result.retention:.3f})",
                        classifier="decay",
                    )
                except Exception:
                    logger.exception("Failed to update confidence for claim %s", claim.id)

        # Process deprecated claims — archive if old enough
        deprecated_claims = await self.storage.get_claims_by_status(STATUS_DEPRECATED, owner_id)
        for claim in deprecated_claims:
            scanned += 1

            if self._is_exempt(claim, exempt_ids):
                skipped += 1
                continue

            days_deprecated = self._days_since_update(claim.updated_at, now)
            if days_deprecated is not None and days_deprecated > archive_days:
                dec = await self._transition(
                    claim,
                    new_status=STATUS_ARCHIVED,
                    reason=f"Deprecated for {days_deprecated:.1f}d (archive threshold: {archive_days}d)",
                    classifier="decay",
                    action="archived",
                )
                decisions.append(dec)
                to_archived += 1

        duration_ms = int((time.monotonic() - t_start) * 1000)
        return DecayReport(
            owner_id=owner_id,
            total_scanned=scanned,
            transitioned_to_cooling=to_cooling,
            transitioned_to_deprecated=to_deprecated,
            transitioned_to_archived=to_archived,
            confidence_reduced=reduced,
            skipped_protected=skipped,
            duration_ms=duration_ms,
            decisions=decisions,
        )

    async def check_reinforcement(self, claim_id: str) -> ReinforcementReport:
        """
        Called when a claim is retrieved and used.

        Effects:
        - Increments access_count
        - Updates last_accessed timestamp
        - Restores confidence if the claim was cooling
        - If cooling → revert to active (memory reinforced)
        - Implements spaced repetition (Ebbinghaus reinforcement)
        """
        claim = await self.storage.get_claim(claim_id)
        if claim is None:
            return ReinforcementReport(
                claim_id=claim_id,
                action="not_found",
                old_confidence=0.0,
                new_confidence=0.0,
                old_access_count=0,
                new_access_count=0,
            )

        if self._is_exempt_type(claim):
            return ReinforcementReport(
                claim_id=claim_id,
                action="protected",
                old_confidence=claim.confidence,
                new_confidence=claim.confidence,
                old_access_count=claim.access_count,
                new_access_count=claim.access_count,
            )

        reinforcement = compute_reinforcement(
            claim_id=claim_id,
            current_confidence=claim.confidence,
            access_count=claim.access_count,
            promoted_confidence=self.config.promoted_confidence_default,
        )

        now_iso = datetime.now(timezone.utc).isoformat()
        update_kwargs: Dict[str, Any] = {
            "access_count": reinforcement.new_access_count,
            "last_accessed": now_iso,
            "confidence": reinforcement.new_confidence,
        }

        # Reactivate cooling memories
        if claim.status == STATUS_COOLING:
            update_kwargs["status"] = STATUS_ACTIVE
            logger.debug(
                "Claim %s reinforced: cooling → active (confidence %.3f → %.3f)",
                claim_id,
                claim.confidence,
                reinforcement.new_confidence,
            )

        # Capture before mutation (storage mock may modify claim in-place)
        old_confidence = reinforcement.original_confidence
        old_access_count = claim.access_count

        try:
            await self.storage.update_claim(claim_id, **update_kwargs)
            await self._log_deprecation(
                claim_id=claim_id,
                action="reinforced",
                reason=f"Access event: confidence {old_confidence:.3f} → {reinforcement.new_confidence:.3f}, access_count {old_access_count} → {reinforcement.new_access_count}",
                classifier="reinforcement",
            )
        except Exception:
            logger.exception("Failed to reinforce claim %s", claim_id)

        return ReinforcementReport(
            claim_id=claim_id,
            action="reinforced",
            old_confidence=old_confidence,
            new_confidence=reinforcement.new_confidence,
            old_access_count=old_access_count,
            new_access_count=reinforcement.new_access_count,
        )

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _classify_noise(self, text: str) -> str:
        """
        Classify text using CodingNoiseFilter as a 3-way classifier.

        Returns:
            'noise'  — definitely noise, discard immediately
            'keep'   — clear signal, keep without LLM
            'maybe'  — borderline, send to LLM judge
        """
        if not text or not text.strip():
            return "noise"

        is_noise = self.noise_filter.is_noise(text)
        if is_noise:
            return "noise"

        # Short texts with real signal — let LLM decide
        word_count = len(text.split())
        if word_count < 10:
            return "maybe"

        return "keep"

    async def _get_exempt_ids(self) -> frozenset:
        """Return set of claim IDs that must never be deprecated (cornerstones)."""
        try:
            cornerstones = await self.storage.get_cornerstones()
            # Cornerstones are identified by memory_type, not by cross-table ID,
            # but some implementations may link claim_id to cornerstone. We build
            # an exempt set from their IDs and rely on memory_type check as fallback.
            return frozenset(cs.id for cs in cornerstones)
        except Exception:
            logger.exception("Failed to load cornerstones for exempt set — defaulting to empty")
            return frozenset()

    def _is_exempt(self, claim: "SemanticClaim", exempt_ids: frozenset) -> bool:
        """True if claim should be protected from deprecation."""
        if claim.id in exempt_ids:
            return True
        return self._is_exempt_type(claim)

    def _is_exempt_type(self, claim: "SemanticClaim") -> bool:
        """True if claim's memory_type is protected."""
        return claim.memory_type in _EXEMPT_MEMORY_TYPES

    def _claim_to_text(self, claim: "SemanticClaim") -> str:
        """Convert claim to text for noise filter."""
        parts = [claim.predicate, claim.object_value]
        if claim.subject:
            parts.insert(0, claim.subject)
        return " ".join(filter(None, parts))

    def _days_since_access(
        self,
        last_accessed: Optional[str],
        now: datetime,
    ) -> Optional[float]:
        """Return days since last_accessed, or None if never accessed."""
        if last_accessed is None:
            return None
        try:
            dt = datetime.fromisoformat(last_accessed)
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            delta = now - dt
            return max(0.0, delta.total_seconds() / 86400.0)
        except (ValueError, TypeError):
            return None

    def _days_since_update(
        self,
        updated_at: Optional[str],
        now: datetime,
    ) -> Optional[float]:
        """Return days since updated_at."""
        if updated_at is None:
            return None
        try:
            dt = datetime.fromisoformat(updated_at)
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            delta = now - dt
            return max(0.0, delta.total_seconds() / 86400.0)
        except (ValueError, TypeError):
            return None

    async def _transition(
        self,
        claim: "SemanticClaim",
        new_status: str,
        reason: str,
        classifier: str,
        action: str,
        new_confidence: Optional[float] = None,
    ) -> DeprecationDecision:
        """Apply a status transition to a claim and return the decision record."""
        update_kwargs: Dict[str, Any] = {"status": new_status}
        if new_confidence is not None:
            update_kwargs["confidence"] = new_confidence

        try:
            await self.storage.update_claim(claim.id, **update_kwargs)
        except Exception:
            logger.exception(
                "Failed to transition claim %s to %s", claim.id, new_status
            )

        await self._log_deprecation(
            claim_id=claim.id,
            action=action,
            reason=reason,
            classifier=classifier,
        )

        return DeprecationDecision(
            claim_id=claim.id,
            action=action,
            reason=reason,
            classifier=classifier,
            old_status=claim.status,
            new_status=new_status,
            old_confidence=claim.confidence,
            new_confidence=new_confidence if new_confidence is not None else claim.confidence,
        )

    async def _apply_deprecate(
        self,
        claim: "SemanticClaim",
        reason: str,
        classifier: str,
        session_id: Optional[str] = None,
    ) -> DeprecationDecision:
        """Deprecate a single claim (helper for review_session)."""
        return await self._transition(
            claim,
            new_status=STATUS_DEPRECATED,
            reason=reason,
            classifier=classifier,
            action="discard",
        )

    async def _log_deprecation(
        self,
        claim_id: str,
        action: str,
        reason: str,
        classifier: str,
        session_id: Optional[str] = None,
    ) -> None:
        """Write a row to the deprecation_log table."""
        try:
            await self.storage.log_deprecation(
                claim_id=claim_id,
                session_id=session_id,
                action=action,
                reason=reason,
                classifier=classifier,
            )
        except Exception:
            logger.exception("Failed to log deprecation for claim %s", claim_id)

    async def _llm_judge_session(
        self,
        claims: List["SemanticClaim"],
        session_id: str,
    ) -> List[DeprecationDecision]:
        """
        Use LLM to judge 'maybe' claims from session review.

        Returns a list of DeprecationDecision objects. Falls back to
        keep_session if LLM is unavailable.
        """
        if not claims:
            return []

        decisions: List[DeprecationDecision] = []

        if self.llm is None:
            logger.warning(
                "LLM not available for session review — defaulting 'maybe' claims to keep_session"
            )
            for claim in claims:
                decisions.append(
                    DeprecationDecision(
                        claim_id=claim.id,
                        action="keep_session",
                        reason="LLM judge unavailable — defaulted to keep_session",
                        classifier="llm_judge",
                        old_status=claim.status,
                        new_status=STATUS_ACTIVE,
                    )
                )
            return decisions

        # Build claim summary for LLM
        claims_payload = [
            {
                "claim_id": c.id,
                "subject": c.subject,
                "predicate": c.predicate,
                "object": c.object_value,
                "memory_type": c.memory_type,
                "confidence": c.confidence,
            }
            for c in claims
        ]
        claims_json = json.dumps(claims_payload, indent=2)
        prompt = SESSION_REVIEW_PROMPT.format(claims_json=claims_json)

        # Map from claim_id → claim for fast lookup
        claim_map = {c.id: c for c in claims}

        try:
            from cortex.llm.router import resolve_routed_model
            _routed_model = resolve_routed_model(self.llm, "deprecation_review")
            response_text = await self.llm.complete(
                system="You are a memory lifecycle judge. Output valid JSON only.",
                user=prompt,
                response_format="json",
                **({"model": _routed_model} if _routed_model else {}),
            )
            llm_decisions = json.loads(response_text)

            for item in llm_decisions:
                claim_id = item.get("claim_id")
                llm_action = (item.get("action") or "KEEP_SESSION").upper()
                reason = item.get("reason", "LLM judge decision")
                claim = claim_map.get(claim_id)
                if claim is None:
                    continue

                if llm_action == "PROMOTE":
                    action = "promote"
                    new_status = STATUS_ACTIVE
                elif llm_action == "DISCARD":
                    action = "discard"
                    new_status = STATUS_DEPRECATED
                else:
                    action = "keep_session"
                    new_status = STATUS_ACTIVE

                decisions.append(
                    DeprecationDecision(
                        claim_id=claim_id,
                        action=action,
                        reason=reason,
                        classifier="llm_judge",
                        old_status=claim.status,
                        new_status=new_status,
                    )
                )

            # Handle any claims the LLM didn't return decisions for
            returned_ids = {d.claim_id for d in decisions}
            for claim in claims:
                if claim.id not in returned_ids:
                    decisions.append(
                        DeprecationDecision(
                            claim_id=claim.id,
                            action="keep_session",
                            reason="LLM did not return a decision — defaulted to keep_session",
                            classifier="llm_judge",
                            old_status=claim.status,
                            new_status=STATUS_ACTIVE,
                        )
                    )

        except Exception:
            logger.exception("LLM session review failed — falling back to keep_session for all")
            for claim in claims:
                decisions.append(
                    DeprecationDecision(
                        claim_id=claim.id,
                        action="keep_session",
                        reason="LLM error — fallback to keep_session",
                        classifier="llm_judge",
                        old_status=claim.status,
                        new_status=STATUS_ACTIVE,
                    )
                )

        return decisions
