"""Proactive knowledge resurfacing based on Ebbinghaus forgetting curve.

Identifies knowledge approaching decay threshold and resurfaces it before
it's forgotten. Uses spaced repetition science:

- FSRS-6 power-law decay: C(t) = C_FLOOR + (1 - C_FLOOR) * (1 + t/(2S))^(-2)
- Review threshold: retrievability < 0.7
- Post-review stability multiplier: 2.5x (SM-2, Wozniak 1987)
- Max stability: 1825 days (5 years)

Three data sources for recall:
1. knowledge_entries — promoted knowledge (domain, title, content, created_at, updated_at)
2. observations — raw observations with FSRS-6 decay model (confidence, stability_days)
3. patterns — confirmed patterns with last_applied_at tracking
"""

from __future__ import annotations

import logging
import os
import sqlite3
from datetime import datetime, timezone
from typing import Any

logger = logging.getLogger(__name__)

# --- Decay constants (match cognitive/memory.py) ---
C_FLOOR = 0.05
REVIEW_THRESHOLD = 0.7       # Below this, schedule a review
CONFIRM_FACTOR = 2.5          # Stability multiplier on review (SM-2)
MAX_STABILITY_DAYS = 1825     # 5 years
DEFAULT_STABILITY = 45        # Days
MIN_CONFIDENCE_FOR_REVIEW = 0.3  # Don't review items already fading out

# Default DB path
GM_DB_PATH = os.environ.get("GM_DB_PATH", os.path.expanduser("~/.greymatter/greymatter.db"))


def _compute_retrievability(days_elapsed: float, stability: float) -> float:
    """FSRS-6 power-law retrievability.

    R(t) = C_FLOOR + (1 - C_FLOOR) * (1 + t/(2S))^(-2)

    Args:
        days_elapsed: Days since last review/creation.
        stability: Current stability in days.

    Returns:
        Retrievability score between C_FLOOR and 1.0.
    """
    if stability <= 0:
        stability = DEFAULT_STABILITY
    return C_FLOOR + (1 - C_FLOOR) * (1 + days_elapsed / (2 * stability)) ** (-2)


def _parse_timestamp(ts: str | None) -> datetime | None:
    """Parse an ISO timestamp string, returning None on failure."""
    if not ts:
        return None
    try:
        dt = datetime.fromisoformat(ts)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt
    except (ValueError, TypeError):
        return None


class ProactiveRecall:
    """Identifies knowledge approaching decay threshold and resurfaces it.

    Based on Ebbinghaus forgetting curve:
    - Retention = e^(-t/S) where t=time, S=stability
    - Generalized to FSRS-6: R(t) = C_FLOOR + (1-C_FLOOR) * (1+t/(2S))^(-2)
    - When retrievability drops below 0.7, schedule a review
    - After review, stability increases by 2.5x (spaced repetition)

    Works across three tables:
    - knowledge_entries: promoted knowledge with applied_count tracking
    - observations: raw memories with full FSRS-6 decay model
    - patterns: confirmed patterns with last_applied_at
    """

    def __init__(self, db_path: str | None = None):
        self._db_path = db_path or GM_DB_PATH
        self._conn: sqlite3.Connection | None = None

    def _get_conn(self) -> sqlite3.Connection | None:
        """Get a read-only connection to the GM database."""
        if not os.path.exists(self._db_path):
            return None
        if self._conn is None:
            try:
                self._conn = sqlite3.connect(self._db_path, timeout=2)
                self._conn.row_factory = sqlite3.Row
            except (sqlite3.Error, OSError):
                return None
        return self._conn

    def close(self) -> None:
        """Close the database connection."""
        if self._conn:
            self._conn.close()
            self._conn = None

    def get_due_for_review(self, limit: int = 5) -> list[dict[str, Any]]:
        """Find knowledge entries due for review based on decay curve.

        Queries across knowledge_entries, observations, and patterns.
        Returns items sorted by urgency (lowest retrievability first).

        Args:
            limit: Maximum number of items to return.

        Returns:
            List of dicts with keys: id, source_table, entry_type, title,
            domain, retrievability, days_since_review, stability.
        """
        conn = self._get_conn()
        if not conn:
            return []

        now = datetime.now(timezone.utc)
        candidates: list[dict[str, Any]] = []

        # --- 1. Knowledge entries ---
        try:
            rows = conn.execute("""
                SELECT id, entry_type, title, domain, content,
                       created_at, updated_at, applied_count, confidence
                FROM knowledge_entries
                WHERE pruned_at IS NULL
                ORDER BY updated_at ASC
                LIMIT 100
            """).fetchall()

            for row in rows:
                row_dict = dict(row)
                # Use updated_at as proxy for "last reviewed" (applied_count bump updates it)
                ref_ts = _parse_timestamp(row_dict.get("updated_at")) or \
                         _parse_timestamp(row_dict.get("created_at"))
                if not ref_ts:
                    continue

                days_elapsed = max((now - ref_ts).total_seconds() / 86400, 0)

                # Knowledge entries don't have stability_days — derive from applied_count
                # More applications = more stable. Base: 30 days, +15 per application
                applied = row_dict.get("applied_count", 0) or 0
                stability = 30 + (applied * 15)
                stability = min(stability, MAX_STABILITY_DAYS)

                retrievability = _compute_retrievability(days_elapsed, stability)

                if retrievability < REVIEW_THRESHOLD:
                    candidates.append({
                        "id": row_dict["id"],
                        "source_table": "knowledge_entries",
                        "entry_type": row_dict.get("entry_type", "reference"),
                        "title": row_dict.get("title", "untitled"),
                        "domain": row_dict.get("domain", "general"),
                        "content_preview": (row_dict.get("content") or "")[:200],
                        "retrievability": round(retrievability, 3),
                        "days_since_review": round(days_elapsed, 1),
                        "stability": stability,
                    })
        except sqlite3.Error as e:
            logger.debug("Knowledge query failed: %s", e)

        # --- 2. Observations (high-value only: confidence >= MIN_CONFIDENCE_FOR_REVIEW) ---
        try:
            rows = conn.execute("""
                SELECT id, category, title, domains, content,
                       confidence, stability_days, last_confirmed_at, created_at
                FROM observations
                WHERE pruned = 0
                  AND confidence >= ?
                ORDER BY last_confirmed_at ASC, created_at ASC
                LIMIT 100
            """, (MIN_CONFIDENCE_FOR_REVIEW,)).fetchall()

            for row in rows:
                row_dict = dict(row)
                ref_ts = _parse_timestamp(row_dict.get("last_confirmed_at")) or \
                         _parse_timestamp(row_dict.get("created_at"))
                if not ref_ts:
                    continue

                days_elapsed = max((now - ref_ts).total_seconds() / 86400, 0)
                stability = row_dict.get("stability_days") or DEFAULT_STABILITY

                retrievability = _compute_retrievability(days_elapsed, stability)

                if retrievability < REVIEW_THRESHOLD:
                    title = row_dict.get("title", "untitled")
                    # Strip category prefix from auto-titles
                    if title.startswith("[") and "]" in title:
                        title = title[title.index("]") + 1:].strip()

                    candidates.append({
                        "id": row_dict["id"],
                        "source_table": "observations",
                        "entry_type": row_dict.get("category", "observation"),
                        "title": title,
                        "domain": row_dict.get("domains", "general"),
                        "content_preview": (row_dict.get("content") or "")[:200],
                        "retrievability": round(retrievability, 3),
                        "days_since_review": round(days_elapsed, 1),
                        "stability": stability,
                    })
        except sqlite3.Error as e:
            logger.debug("Observations query failed: %s", e)

        # --- 3. Patterns ---
        try:
            rows = conn.execute("""
                SELECT id, type, title, domain, observation,
                       confidence, last_applied_at, created_at, confirmations
                FROM patterns
                ORDER BY last_applied_at ASC, created_at ASC
                LIMIT 50
            """).fetchall()

            for row in rows:
                row_dict = dict(row)
                ref_ts = _parse_timestamp(row_dict.get("last_applied_at")) or \
                         _parse_timestamp(row_dict.get("created_at"))
                if not ref_ts:
                    continue

                days_elapsed = max((now - ref_ts).total_seconds() / 86400, 0)

                # Pattern stability: base 60 + 20 per confirmation
                confirmations = row_dict.get("confirmations", 0) or 0
                stability = 60 + (confirmations * 20)
                stability = min(stability, MAX_STABILITY_DAYS)

                retrievability = _compute_retrievability(days_elapsed, stability)

                if retrievability < REVIEW_THRESHOLD:
                    candidates.append({
                        "id": row_dict["id"],
                        "source_table": "patterns",
                        "entry_type": row_dict.get("type", "pattern"),
                        "title": row_dict.get("title", "untitled"),
                        "domain": row_dict.get("domain", "general"),
                        "content_preview": (row_dict.get("observation") or "")[:200],
                        "retrievability": round(retrievability, 3),
                        "days_since_review": round(days_elapsed, 1),
                        "stability": stability,
                    })
        except sqlite3.Error as e:
            logger.debug("Patterns query failed: %s", e)

        # Sort by urgency: lowest retrievability first (closest to forgetting)
        candidates.sort(key=lambda x: x["retrievability"])

        return candidates[:limit]

    def record_review(
        self,
        entry_id: str,
        source_table: str | None = None,
        stability_factor: float | None = None,
        review_source: str = "manual",
    ) -> bool:
        """Record that an entry was reviewed, increasing its stability.

        Applies spaced repetition: stability *= factor, capped at MAX_STABILITY_DAYS.
        Updates the appropriate timestamp to reset the decay clock.

        Args:
            entry_id: ID of the entry that was reviewed.
            source_table: One of 'knowledge_entries', 'observations', 'patterns'.
                          If None, auto-detects from ID prefix.
            stability_factor: Multiplier for stability increase. Defaults to
                CONFIRM_FACTOR (2.5). Use a lower value (e.g. 1.5) for passive
                reviews like session recall injection.
            review_source: Tag indicating how the review occurred. One of
                'manual', 'session_recall', 'cli', etc. Logged for audit.

        Returns:
            True if the review was recorded, False otherwise.
        """
        factor = stability_factor if stability_factor is not None else CONFIRM_FACTOR
        conn = self._get_conn()
        if not conn:
            return False

        # Auto-detect source table from ID prefix
        if source_table is None:
            if entry_id.startswith("kn-") or entry_id.startswith("ke-"):
                source_table = "knowledge_entries"
            elif entry_id.startswith("obs-"):
                source_table = "observations"
            elif entry_id.startswith("pat-"):
                source_table = "patterns"
            else:
                logger.warning("Cannot auto-detect source table for ID: %s", entry_id)
                return False

        is_passive = factor < CONFIRM_FACTOR

        logger.debug(
            "Recording review: entry=%s table=%s factor=%.1f source=%s passive=%s",
            entry_id, source_table, factor, review_source, is_passive,
        )

        try:
            if source_table == "knowledge_entries":
                if is_passive:
                    # Passive review: reset decay clock only, don't bump applied_count
                    conn.execute(
                        """UPDATE knowledge_entries
                           SET updated_at = datetime('now')
                           WHERE id = ?""",
                        (entry_id,),
                    )
                else:
                    # Active review: bump applied_count (increases derived stability)
                    conn.execute(
                        """UPDATE knowledge_entries
                           SET applied_count = applied_count + 1,
                               updated_at = datetime('now')
                           WHERE id = ?""",
                        (entry_id,),
                    )
                conn.commit()
                return True

            elif source_table == "observations":
                # Get current stability, multiply by factor
                row = conn.execute(
                    "SELECT stability_days, confidence, review_count FROM observations WHERE id = ?",
                    (entry_id,),
                ).fetchone()
                if not row:
                    return False

                row_dict = dict(row)
                current_stability = row_dict.get("stability_days") or DEFAULT_STABILITY
                new_stability = min(current_stability * factor, MAX_STABILITY_DAYS)
                current_conf = row_dict.get("confidence", 0.5)
                new_conf = min(current_conf + 0.05, 1.0)
                new_review_count = (row_dict.get("review_count") or 0) + 1

                conn.execute(
                    """UPDATE observations
                       SET stability_days = ?,
                           confidence = ?,
                           last_confirmed_at = datetime('now'),
                           review_count = ?,
                           last_reviewed_at = datetime('now'),
                           review_source = ?,
                           updated_at = datetime('now')
                       WHERE id = ?""",
                    (new_stability, round(new_conf, 4), new_review_count, review_source, entry_id),
                )
                conn.commit()

                # Direct promotion: high-confidence, well-reviewed observations
                # can promote individually without clustering
                if new_conf > 0.8 and new_review_count >= 3:
                    self._try_direct_promotion(conn, entry_id)
                # Cluster-based consolidation for moderately reviewed observations
                elif new_conf > 0.7 and new_review_count >= 2:
                    self._try_incremental_consolidation(conn, entry_id)

                return True

            elif source_table == "patterns":
                if is_passive:
                    # Passive review: reset decay clock only
                    conn.execute(
                        """UPDATE patterns
                           SET last_applied_at = datetime('now'),
                               updated_at = datetime('now')
                           WHERE id = ?""",
                        (entry_id,),
                    )
                else:
                    # Active review: bump confirmations (increases derived stability)
                    conn.execute(
                        """UPDATE patterns
                           SET confirmations = confirmations + 1,
                               last_applied_at = datetime('now'),
                               updated_at = datetime('now')
                           WHERE id = ?""",
                        (entry_id,),
                    )
                conn.commit()
                return True

        except sqlite3.Error as e:
            logger.warning("Failed to record review for %s: %s", entry_id, e)

        return False

    def _try_incremental_consolidation(
        self, conn: sqlite3.Connection, entry_id: str
    ) -> None:
        """Attempt to promote an observation to knowledge after review.

        Finds similar observations (same title prefix, same category) and
        promotes the cluster if it meets the threshold (3+ observations
        from 2+ sessions). This is an incremental fast-path — full
        consolidation runs on a 30-minute daemon schedule.

        Args:
            conn: Active database connection.
            entry_id: The observation that triggered consolidation.
        """
        try:
            row = conn.execute(
                """SELECT id, title, category, content, domains, confidence,
                          session_id, knowledge_id
                   FROM observations WHERE id = ? AND pruned = 0""",
                (entry_id,),
            ).fetchone()
            if not row:
                return
            trigger = dict(row)

            # Already promoted — nothing to do
            if trigger.get("knowledge_id"):
                return

            # Find similar unpromoted observations in the same category
            candidates = conn.execute(
                """SELECT id, title, category, content, domains, confidence,
                          session_id
                   FROM observations
                   WHERE pruned = 0
                     AND knowledge_id IS NULL
                     AND pattern_id IS NULL
                     AND category = ?
                     AND confidence >= 0.5
                     AND id != ?""",
                (trigger["category"], entry_id),
            ).fetchall()

            # Jaccard similarity clustering
            trigger_words = set(trigger["title"].lower().split())
            cluster = [trigger]
            for cand in candidates:
                cand_dict = dict(cand)
                cand_words = set(cand_dict["title"].lower().split())
                union = trigger_words | cand_words
                if not union:
                    continue
                sim = len(trigger_words & cand_words) / len(union)
                # Domain boost
                t_domains = {
                    d.strip() for d in (trigger.get("domains") or "").split(",") if d.strip()
                }
                c_domains = {
                    d.strip() for d in (cand_dict.get("domains") or "").split(",") if d.strip()
                }
                if t_domains and c_domains and t_domains & c_domains:
                    sim = min(sim + 0.2, 1.0)
                if sim >= 0.4:
                    cluster.append(cand_dict)

            # Need 3+ observations from 2+ sessions to promote
            if len(cluster) < 3:
                return
            session_ids = {obs["session_id"] for obs in cluster}
            if len(session_ids) < 2:
                return

            # Promote — create knowledge entry
            best = max(cluster, key=lambda o: o.get("confidence", 0.0))
            title = best["title"]
            if title.startswith("[") and "]" in title:
                title = title[title.index("]") + 1:].strip()

            # Quality gate
            if not title or len(title) < 5:
                return
            if title.startswith("- ") or title.startswith("Let me ") or title.startswith("Now "):
                return

            content_parts = []
            seen_content: set[str] = set()
            for obs in cluster:
                c = (obs.get("content") or "").strip()
                if c and c not in seen_content:
                    seen_content.add(c)
                    content_parts.append(c)
            content = "\n\n".join(content_parts)

            domains_list: list[str] = []
            for obs in cluster:
                for d in (obs.get("domains") or "").split(","):
                    d = d.strip()
                    if d and d not in domains_list:
                        domains_list.append(d)
            domain = domains_list[0] if domains_list else "general"

            kn_id = f"kn-{entry_id[4:]}"  # Derive from obs ID for traceability

            # Try knowledge_entries first, fall back to knowledge table
            try:
                conn.execute(
                    """INSERT INTO knowledge_entries
                       (id, scope, type, valence, domain, title, content, confidence)
                       VALUES (?, 'cross-cutting', 'reference', 'neutral', ?, ?, ?, 'medium')""",
                    (kn_id, domain, title, content),
                )
            except sqlite3.Error:
                conn.execute(
                    """INSERT INTO knowledge (id, type, title, content, domain)
                       VALUES (?, 'reference', ?, ?, ?)""",
                    (kn_id, title, content, domain),
                )

            # Link observations to the new knowledge entry
            for obs in cluster:
                conn.execute(
                    """UPDATE observations
                       SET knowledge_id = ?, promoted_at = datetime('now')
                       WHERE id = ?""",
                    (kn_id, obs["id"]),
                )
            conn.commit()
            logger.info(
                "CONSOLIDATION: Review-triggered promotion of %d observations → %s (%s)",
                len(cluster), kn_id, title[:60],
            )
        except sqlite3.Error as e:
            logger.debug("Incremental consolidation failed for %s: %s", entry_id, e)

    def _try_direct_promotion(
        self, conn: sqlite3.Connection, entry_id: str
    ) -> bool:
        """Promote a single high-confidence observation directly to knowledge.

        Unlike cluster-based consolidation, this promotes an individual
        observation that has been reviewed enough times (>=3) and has
        high confidence (>0.8). No clustering or multi-session requirement.

        Args:
            conn: Active database connection.
            entry_id: The observation to promote.

        Returns:
            True if the observation was promoted, False otherwise.
        """
        try:
            row = conn.execute(
                """SELECT id, title, category, content, domains, confidence,
                          review_count, knowledge_id
                   FROM observations WHERE id = ? AND pruned = 0""",
                (entry_id,),
            ).fetchone()
            if not row:
                return False
            obs = dict(row)

            # Already promoted
            if obs.get("knowledge_id"):
                return False

            # Threshold check
            if (obs.get("confidence") or 0) <= 0.8:
                return False
            if (obs.get("review_count") or 0) < 3:
                return False

            title = obs["title"]
            if title.startswith("[") and "]" in title:
                title = title[title.index("]") + 1:].strip()

            # Quality gate
            if not title or len(title) < 5:
                return False
            if title.startswith("- ") or title.startswith("Let me ") or title.startswith("Now "):
                return False

            content = (obs.get("content") or "").strip()
            if not content:
                return False

            domains_list: list[str] = []
            for d in (obs.get("domains") or "").split(","):
                d = d.strip()
                if d:
                    domains_list.append(d)
            domain = domains_list[0] if domains_list else "general"

            kn_id = f"kn-{entry_id[4:]}"

            # Map observation category to knowledge entry_type
            category_to_entry_type = {
                "correction": "correction",
                "gotcha": "gotcha",
                "pattern": "pattern",
                "decision": "expertise",
                "reference": "reference",
                "insight": "observation",
            }
            entry_type = category_to_entry_type.get(obs.get("category", ""), "observation")

            try:
                conn.execute(
                    """INSERT INTO knowledge_entries
                       (id, entry_type, scope, domain, title, content,
                        confidence, valence)
                       VALUES (?, ?, 'cross-cutting', ?, ?, ?, ?, 'neutral')""",
                    (kn_id, entry_type, domain, title, content,
                     obs["confidence"]),
                )
            except sqlite3.Error:
                # Legacy schema fallback
                try:
                    conn.execute(
                        """INSERT INTO knowledge_entries
                           (id, scope, type, valence, domain, title, content, confidence)
                           VALUES (?, 'cross-cutting', ?, 'neutral', ?, ?, ?, 'medium')""",
                        (kn_id, entry_type, domain, title, content),
                    )
                except sqlite3.Error:
                    conn.execute(
                        """INSERT INTO knowledge (id, type, title, content, domain)
                           VALUES (?, ?, ?, ?, ?)""",
                        (kn_id, entry_type, title, content, domain),
                    )

            conn.execute(
                """UPDATE observations
                   SET knowledge_id = ?, promoted_at = datetime('now')
                   WHERE id = ?""",
                (kn_id, entry_id),
            )
            conn.commit()
            logger.info(
                "DIRECT PROMOTION: observation %s → %s (confidence=%.2f, reviews=%d, title=%s)",
                entry_id, kn_id, obs["confidence"], obs["review_count"],
                title[:60],
            )
            return True
        except sqlite3.Error as e:
            logger.debug("Direct promotion failed for %s: %s", entry_id, e)
            return False

    def batch_promote_high_confidence(self, limit: int = 500) -> dict[str, Any]:
        """Batch-promote observations with confidence >0.8 and review_count >=3.

        Scans for unpromoted, high-confidence observations and promotes each
        directly to a knowledge entry. Designed to reduce large observation
        backlogs incrementally.

        Args:
            limit: Maximum observations to process per batch.

        Returns:
            Dict with promoted, skipped, and scanned counts.
        """
        conn = self._get_conn()
        if not conn:
            return {"promoted": 0, "skipped": 0, "scanned": 0}

        try:
            rows = conn.execute(
                """SELECT id FROM observations
                   WHERE pruned = 0
                     AND knowledge_id IS NULL
                     AND pattern_id IS NULL
                     AND confidence > 0.8
                     AND review_count >= 3
                   ORDER BY confidence DESC, review_count DESC
                   LIMIT ?""",
                (limit,),
            ).fetchall()
        except sqlite3.Error as e:
            logger.warning("Batch promote query failed: %s", e)
            return {"promoted": 0, "skipped": 0, "scanned": 0}

        promoted = 0
        skipped = 0
        for row in rows:
            if self._try_direct_promotion(conn, dict(row)["id"]):
                promoted += 1
            else:
                skipped += 1

        logger.info(
            "BATCH PROMOTE: scanned=%d, promoted=%d, skipped=%d",
            len(rows), promoted, skipped,
        )
        return {"promoted": promoted, "skipped": skipped, "scanned": len(rows)}

    def get_due_count(self) -> int:
        """Fast count of items due for review. Used by status line.

        Returns the count without fetching full details.
        """
        # Use get_due_for_review with a reasonable limit and count
        # This is fast enough for the status line (< 100ms with 250 candidates)
        return len(self.get_due_for_review(limit=99))

    def format_recall_prompt(self, entries: list[dict[str, Any]]) -> str:
        """Format entries for injection into session context.

        Args:
            entries: List of dicts from get_due_for_review().

        Returns:
            Formatted string for additionalContext injection.
        """
        if not entries:
            return ""

        lines = [
            f"### GreyMatter Recall",
            f"_{len(entries)} item(s) approaching decay threshold — review to strengthen retention._\n",
        ]

        for entry in entries:
            entry_type = entry.get("entry_type", "unknown")
            title = entry.get("title", "untitled")
            domain = entry.get("domain", "")
            days = entry.get("days_since_review", 0)
            retrievability = entry.get("retrievability", 0)
            source = entry.get("source_table", "").replace("_", " ")
            entry_id = entry.get("id", "")
            preview = entry.get("content_preview", "")

            # Urgency indicator
            if retrievability < 0.3:
                urgency = "CRITICAL"
            elif retrievability < 0.5:
                urgency = "HIGH"
            else:
                urgency = "DUE"

            days_str = f"{int(days)}d" if days >= 1 else f"{int(days * 24)}h"
            lines.append(
                f"- **[{urgency}]** [{entry_type}] {title} "
                f"(domain: {domain}, last reviewed: {days_str} ago, "
                f"retention: {int(retrievability * 100)}%)"
            )
            if preview:
                # Indent preview under the item
                lines.append(f"  > {preview[:150]}...")

        lines.append("")
        lines.append(
            "_To review: use `gm-knowledge search` or `gm-observe search` to read full entries. "
            "Reviewing strengthens retention via spaced repetition._"
        )

        return "\n".join(lines)
