"""IMA Stage — Self-Learning (Threshold Auto-Tuning).

Tracks classification accuracy and adjusts thresholds over time:
  - Records whether stored memories are later recalled (true positive)
  - Records whether skipped content was asked about later (false negative)
  - Computes precision/recall/F1 and adjusts classifier confidence thresholds
  - Auto-tunes dedup similarity thresholds based on merge success rates
"""

import json
import logging
from datetime import datetime, timezone

from soagila.db.sqlite_init import get_db

logger = logging.getLogger("soagila.ima.self_learning")

# Default thresholds (will be adjusted by self-learning)
DEFAULT_TRIVIAL_CONFIDENCE_THRESHOLD = 0.6
DEFAULT_DEDUP_SIMILARITY_THRESHOLD = 0.92
DEFAULT_DEDUP_MERGE_THRESHOLD = 0.78

# Tuning bounds — prevent thresholds from going too extreme
MIN_TRIVIAL_THRESHOLD = 0.3
MAX_TRIVIAL_THRESHOLD = 0.85
MIN_DEDUP_THRESHOLD = 0.80
MAX_DEDUP_THRESHOLD = 0.98
MIN_MERGE_THRESHOLD = 0.60
MAX_MERGE_THRESHOLD = 0.90

# Minimum samples before tuning kicks in
MIN_SAMPLES_FOR_TUNING = 20
# Learning rate — how much to adjust per tuning cycle
LEARNING_RATE = 0.05


class IMAMetrics:
    """Tracks IMA pipeline decision metrics."""

    def __init__(self):
        self.total_processed = 0
        self.trivial_filtered = 0
        self.duplicates_skipped = 0
        self.duplicates_merged = 0
        self.stored_full = 0
        self.stored_summary = 0
        self.stored_key_only = 0
        self.ai_calls = 0
        self.total_cost = 0.0
        self.recalls_hit = 0       # Stored memory was later recalled
        self.recalls_miss = 0      # Nothing found for a query (potential false negative)
        self.pmp_cache_hits = 0    # PMP cache hit during recall
        self.pmp_cache_misses = 0  # PMP cache miss during recall

        # Tunable thresholds
        self.trivial_confidence_threshold = DEFAULT_TRIVIAL_CONFIDENCE_THRESHOLD
        self.dedup_similarity_threshold = DEFAULT_DEDUP_SIMILARITY_THRESHOLD
        self.dedup_merge_threshold = DEFAULT_DEDUP_MERGE_THRESHOLD

        # Tuning history
        self.tuning_count = 0
        self.last_tuned_at: str | None = None

    @property
    def total_stored(self) -> int:
        return self.stored_full + self.stored_summary + self.stored_key_only

    @property
    def precision(self) -> float:
        return self.recalls_hit / self.total_stored if self.total_stored > 0 else 0.0

    @property
    def recall(self) -> float:
        total = self.recalls_hit + self.recalls_miss
        return self.recalls_hit / total if total > 0 else 0.0

    @property
    def f1(self) -> float:
        p, r = self.precision, self.recall
        return 2 * p * r / (p + r) if (p + r) > 0 else 0.0

    @property
    def filter_rate(self) -> float:
        return self.trivial_filtered / self.total_processed if self.total_processed > 0 else 0.0

    def to_dict(self) -> dict:
        return {
            "total_processed": self.total_processed,
            "trivial_filtered": self.trivial_filtered,
            "duplicates_skipped": self.duplicates_skipped,
            "duplicates_merged": self.duplicates_merged,
            "stored": {
                "full": self.stored_full,
                "summary": self.stored_summary,
                "key_only": self.stored_key_only,
                "total": self.total_stored,
            },
            "ai_calls": self.ai_calls,
            "total_cost_usd": round(self.total_cost, 4),
            "quality": {
                "precision": round(self.precision, 3),
                "recall": round(self.recall, 3),
                "f1": round(self.f1, 3),
            },
            "pmp": {
                "cache_hits": self.pmp_cache_hits,
                "cache_misses": self.pmp_cache_misses,
                "hit_rate": round(
                    self.pmp_cache_hits / max(self.pmp_cache_hits + self.pmp_cache_misses, 1), 3
                ),
            },
            "filter_rate": round(self.filter_rate, 3),
            "thresholds": {
                "trivial_confidence": round(self.trivial_confidence_threshold, 3),
                "dedup_similarity": round(self.dedup_similarity_threshold, 3),
                "dedup_merge": round(self.dedup_merge_threshold, 3),
            },
            "tuning": {
                "count": self.tuning_count,
                "last_tuned_at": self.last_tuned_at,
            },
        }


# Singleton metrics instance
_metrics = IMAMetrics()


def get_metrics() -> IMAMetrics:
    return _metrics


def get_thresholds() -> dict:
    """Get current tuned thresholds for use by classifier and dedup."""
    return {
        "trivial_confidence": _metrics.trivial_confidence_threshold,
        "dedup_similarity": _metrics.dedup_similarity_threshold,
        "dedup_merge": _metrics.dedup_merge_threshold,
    }


def record_store(detail_level: str, ai_called: bool, cost: float):
    """Record a successful store event."""
    _metrics.total_processed += 1
    if detail_level == "full":
        _metrics.stored_full += 1
    elif detail_level == "summary":
        _metrics.stored_summary += 1
    elif detail_level == "key_only":
        _metrics.stored_key_only += 1
    if ai_called:
        _metrics.ai_calls += 1
        _metrics.total_cost += cost


def record_trivial_filter():
    """Record a trivial content filter event."""
    _metrics.total_processed += 1
    _metrics.trivial_filtered += 1


def record_duplicate_skip():
    """Record a duplicate skip event."""
    _metrics.total_processed += 1
    _metrics.duplicates_skipped += 1


def record_duplicate_merge():
    """Record a duplicate merge event."""
    _metrics.total_processed += 1
    _metrics.duplicates_merged += 1


def record_recall_hit():
    """Record that a stored memory was successfully recalled."""
    _metrics.recalls_hit += 1


def record_recall_miss():
    """Record that a recall query returned no results."""
    _metrics.recalls_miss += 1


def record_pmp_hit():
    """Record a PMP cache hit during recall."""
    _metrics.pmp_cache_hits += 1


def record_pmp_miss():
    """Record a PMP cache miss during recall."""
    _metrics.pmp_cache_misses += 1


def auto_tune_thresholds() -> dict | None:
    """Auto-tune thresholds based on accumulated metrics.

    Returns dict of adjustments made, or None if not enough data.

    Tuning logic:
    - Low precision (storing too much junk) → increase trivial threshold (filter more)
    - Low recall (missing important content) → decrease trivial threshold (filter less)
    - High duplicate rate → decrease dedup similarity (merge more aggressively)
    - Low duplicate rate with many near-misses → increase dedup similarity (less aggressive)
    """
    if _metrics.total_processed < MIN_SAMPLES_FOR_TUNING:
        return None

    adjustments = {}
    now = datetime.now(timezone.utc).isoformat()

    # ── Trivial threshold tuning ──
    # Based on precision/recall balance
    f1 = _metrics.f1
    precision = _metrics.precision
    recall = _metrics.recall

    if _metrics.total_stored > 10:
        if precision < 0.5 and recall > 0.6:
            # Too much junk stored, not enough quality → be more selective
            delta = LEARNING_RATE
            new_val = min(_metrics.trivial_confidence_threshold + delta, MAX_TRIVIAL_THRESHOLD)
            if new_val != _metrics.trivial_confidence_threshold:
                adjustments["trivial_confidence"] = {
                    "old": _metrics.trivial_confidence_threshold,
                    "new": new_val,
                    "reason": f"low precision ({precision:.2f}), increasing filter threshold"
                }
                _metrics.trivial_confidence_threshold = new_val

        elif recall < 0.3 and precision > 0.5:
            # Missing too much important content → be less selective
            delta = LEARNING_RATE
            new_val = max(_metrics.trivial_confidence_threshold - delta, MIN_TRIVIAL_THRESHOLD)
            if new_val != _metrics.trivial_confidence_threshold:
                adjustments["trivial_confidence"] = {
                    "old": _metrics.trivial_confidence_threshold,
                    "new": new_val,
                    "reason": f"low recall ({recall:.2f}), decreasing filter threshold"
                }
                _metrics.trivial_confidence_threshold = new_val

    # ── Dedup threshold tuning ──
    if _metrics.total_processed > 30:
        dup_rate = (_metrics.duplicates_skipped + _metrics.duplicates_merged) / _metrics.total_processed

        if dup_rate > 0.4:
            # Too many duplicates getting through classification but caught by dedup
            # → Dedup is working hard, maybe too aggressive? Or classifier needs to catch more?
            # Slightly relax dedup to allow more near-duplicates through as merges
            delta = LEARNING_RATE * 0.5
            new_val = max(_metrics.dedup_merge_threshold - delta, MIN_MERGE_THRESHOLD)
            if new_val != _metrics.dedup_merge_threshold:
                adjustments["dedup_merge"] = {
                    "old": _metrics.dedup_merge_threshold,
                    "new": new_val,
                    "reason": f"high dup rate ({dup_rate:.2f}), relaxing merge threshold"
                }
                _metrics.dedup_merge_threshold = new_val

        elif dup_rate < 0.05 and _metrics.total_stored > 50:
            # Almost no duplicates found — maybe threshold is too high
            delta = LEARNING_RATE * 0.3
            new_val = min(_metrics.dedup_similarity_threshold - delta, MAX_DEDUP_THRESHOLD)
            if new_val != _metrics.dedup_similarity_threshold:
                adjustments["dedup_similarity"] = {
                    "old": _metrics.dedup_similarity_threshold,
                    "new": new_val,
                    "reason": f"very low dup rate ({dup_rate:.2f}), lowering similarity threshold"
                }
                _metrics.dedup_similarity_threshold = new_val

    # ── PMP utilization feedback ──
    if _metrics.pmp_cache_hits + _metrics.pmp_cache_misses >= 10:
        try:
            from soagila.core.pmp.primer import get_prime_utilization
            utilization = get_prime_utilization()
            if utilization < 0.1:
                # Very low utilization — PMP is priming the wrong things
                adjustments["pmp_note"] = {
                    "utilization": round(utilization, 3),
                    "reason": "low PMP utilization — predictions may need improvement",
                }
        except Exception:
            pass

    if adjustments:
        _metrics.tuning_count += 1
        _metrics.last_tuned_at = now
        logger.info(f"IMA self-learning: tuned {len(adjustments)} thresholds (cycle #{_metrics.tuning_count})")
        for key, adj in adjustments.items():
            if "old" in adj and "new" in adj:
                logger.info(f"  {key}: {adj['old']:.3f} → {adj['new']:.3f} ({adj['reason']})")
            else:
                logger.info(f"  {key}: {adj.get('reason', '')}")

    return adjustments if adjustments else None


async def persist_metrics():
    """Persist current metrics to SQLite for cross-session learning."""
    db = await get_db()

    now = datetime.now(timezone.utc).isoformat()
    metrics_json = json.dumps(_metrics.to_dict())

    await db.execute(
        """
        INSERT OR REPLACE INTO semantic_memories (id, user_id, org_id, pattern_description, confidence_score, source_episodic_count, source_episodic_ids, created_at, updated_at)
        VALUES ('ima_metrics', 'system', 'system', ?, 1.0, 0, '[]', ?, ?)
        """,
        (metrics_json, now, now),
    )
    await db.commit()
    logger.debug("IMA metrics persisted")


async def load_metrics():
    """Load persisted metrics from SQLite."""
    global _metrics

    db = await get_db()
    cursor = await db.execute(
        "SELECT pattern_description FROM semantic_memories WHERE id = 'ima_metrics'"
    )
    row = await cursor.fetchone()

    if row:
        try:
            data = json.loads(row[0])
            _metrics.total_processed = data.get("total_processed", 0)
            _metrics.trivial_filtered = data.get("trivial_filtered", 0)
            _metrics.duplicates_skipped = data.get("duplicates_skipped", 0)
            _metrics.duplicates_merged = data.get("duplicates_merged", 0)
            stored = data.get("stored", {})
            _metrics.stored_full = stored.get("full", 0)
            _metrics.stored_summary = stored.get("summary", 0)
            _metrics.stored_key_only = stored.get("key_only", 0)
            _metrics.ai_calls = data.get("ai_calls", 0)
            _metrics.total_cost = data.get("total_cost_usd", 0.0)
            # Restore quality metrics
            quality = data.get("quality", {})
            _metrics.recalls_hit = int(quality.get("precision", 0) * (stored.get("total", 0) or 1))
            _metrics.recalls_miss = 0
            # Restore thresholds
            thresholds = data.get("thresholds", {})
            _metrics.trivial_confidence_threshold = thresholds.get("trivial_confidence", DEFAULT_TRIVIAL_CONFIDENCE_THRESHOLD)
            _metrics.dedup_similarity_threshold = thresholds.get("dedup_similarity", DEFAULT_DEDUP_SIMILARITY_THRESHOLD)
            _metrics.dedup_merge_threshold = thresholds.get("dedup_merge", DEFAULT_DEDUP_MERGE_THRESHOLD)
            # Restore tuning state
            tuning = data.get("tuning", {})
            _metrics.tuning_count = tuning.get("count", 0)
            _metrics.last_tuned_at = tuning.get("last_tuned_at")

            logger.info(
                f"IMA metrics loaded: {_metrics.total_processed} processed, "
                f"F1={quality.get('f1', 0):.3f}, "
                f"thresholds: trivial={_metrics.trivial_confidence_threshold:.3f}, "
                f"dedup={_metrics.dedup_similarity_threshold:.3f}, "
                f"merge={_metrics.dedup_merge_threshold:.3f}, "
                f"tuning cycles={_metrics.tuning_count}"
            )
        except Exception as e:
            logger.warning(f"Failed to load IMA metrics: {e}")