"""
cortex/core/hotness.py — Hotness scoring engine for memory lifecycle.

Issue #1343: Computes a hotness score for each semantic claim based on
access frequency and recency. The score drives the memory lifecycle:
    active (>= cooling_threshold) → cooling → archived (< archive_threshold)

Formula:
    hotness = sigmoid(log1p(access_count)) × exp(-ln2 × days_elapsed / half_life_days)

Where:
    - sigmoid(x) = 2 / (1 + exp(-x)) - 1  (maps [0,∞) → [0,1))
    - exp decay uses a configurable half-life (default 45 days)
    - Result is clamped to [0.0, 1.0]

Public API:
    compute_hotness(access_count, last_accessed_iso, half_life_days=45) -> float
    batch_score_claims(conn, owner_id, config) -> int
"""

from __future__ import annotations

import logging
import math
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional

if TYPE_CHECKING:
    import aiosqlite
    from cortex.config import CortexConfig

logger = logging.getLogger(__name__)

_LN2 = math.log(2)


def compute_hotness(
    access_count: int,
    last_accessed_iso: Optional[str],
    *,
    half_life_days: int = 45,
    now: Optional[datetime] = None,
) -> float:
    """Compute hotness score for a single claim.

    Args:
        access_count:       Number of times this claim has been accessed.
        last_accessed_iso:  ISO 8601 timestamp of last access, or None.
        half_life_days:     Days until the recency factor decays to 0.5.
        now:                Override current time (for testing).

    Returns:
        Float in [0.0, 1.0]. Higher = hotter (more active/recent).
    """
    if now is None:
        now = datetime.now(timezone.utc)

    # --- Frequency component: sigmoid of log1p(access_count) ---
    # Maps 0 → 0.0, 1 → ~0.38, 5 → ~0.64, 20 → ~0.80, 100 → ~0.90
    # B-002: Guard against negative access_count from bad data
    log_freq = math.log1p(max(0, access_count))
    frequency = 2.0 / (1.0 + math.exp(-log_freq)) - 1.0

    # --- Recency component: exponential decay ---
    if last_accessed_iso:
        try:
            # Handle both timezone-aware and naive ISO strings
            last_accessed = datetime.fromisoformat(last_accessed_iso.replace("Z", "+00:00"))
            if last_accessed.tzinfo is None:
                last_accessed = last_accessed.replace(tzinfo=timezone.utc)
            days_elapsed = max(0.0, (now - last_accessed).total_seconds() / 86400.0)
        except (ValueError, TypeError):
            # Unparseable timestamp → treat as very old
            days_elapsed = float(half_life_days * 4)
    else:
        # Never accessed → treat as very old
        days_elapsed = float(half_life_days * 4)

    # B-012: half_life_days=0 should disable recency (score=0), not silently
    # fall back to 45 which gives recency=1.0 for never-accessed claims.
    if half_life_days <= 0:
        recency = 0.0
    else:
        recency = math.exp(-_LN2 * days_elapsed / half_life_days)

    # --- Combined score ---
    score = frequency * recency
    return max(0.0, min(1.0, score))


async def batch_score_claims(
    conn: "aiosqlite.Connection",
    owner_id: str,
    config: Optional["CortexConfig"] = None,
) -> int:
    """Recompute hotness_score for all non-deleted claims belonging to owner_id.

    Args:
        conn:      Open aiosqlite connection.
        owner_id:  Owner/tenant identifier.
        config:    CortexConfig (uses hotness_half_life_days). Falls back to 45.

    Returns:
        Number of claims updated.
    """
    half_life = 45
    if config is not None:
        half_life = getattr(config, "hotness_half_life_days", 45)

    # B-013: Use batched fetching to avoid OOM on large corpora (100K+ rows).
    cursor = await conn.execute(
        """
        SELECT id, access_count, last_accessed
        FROM semantic_claim
        WHERE owner_id = ? AND (is_deleted = 0 OR is_deleted IS NULL)
        """,
        (owner_id,),
    )

    now = datetime.now(timezone.utc)
    total_updated = 0
    batch_size = 2000

    while True:
        rows = await cursor.fetchmany(batch_size)
        if not rows:
            break

        updates: list[tuple[float, str]] = []
        for row in rows:
            claim_id = row[0]
            access_count = row[1] or 0
            last_accessed = row[2]

            score = compute_hotness(
                access_count=access_count,
                last_accessed_iso=last_accessed,
                half_life_days=half_life,
                now=now,
            )
            updates.append((score, claim_id))

        await conn.executemany(
            "UPDATE semantic_claim SET hotness_score = ? WHERE id = ?",
            updates,
        )
        total_updated += len(updates)

    await conn.commit()

    if total_updated == 0:
        logger.debug("[Hotness] No claims to score for owner=%s", owner_id)
    else:
        logger.info(
            "[Hotness] Scored %d claims for owner=%s (half_life=%d days)",
            total_updated, owner_id, half_life,
        )
    return total_updated
