"""
cortex/core/archival.py — Archival sweep for cold-score claims.

Issue #1345: Sleep pipeline stage S4c.

After hotness scores are updated (S4b), this sweep identifies claims that
have fallen below the archive threshold and transitions them to 'archived'
status.  Exempt claims (cornerstones, identity category, high-confidence,
pinned) are never archived.

Exemption rules:
    - memory_type in ('cornerstone', 'immutable', 'core')
    - category == 'identity'
    - confidence >= 0.95
    - memory_type == 'pinned'

Public API:
    sweep_cold_claims(conn, owner_id, config, *, dry_run=False) -> ArchivalReport
"""

from __future__ import annotations

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

if TYPE_CHECKING:
    import aiosqlite
    from cortex.config import CortexConfig

logger = logging.getLogger(__name__)

# Memory types and categories that are never archived
_EXEMPT_MEMORY_TYPES = frozenset({"cornerstone", "immutable", "core", "pinned", "long-term"})
_EXEMPT_CATEGORIES = frozenset({"identity"})
_EXEMPT_CONFIDENCE_FLOOR = 0.95

# Minimum age before a claim can be archived (24 hours)
_MIN_AGE_FOR_ARCHIVAL = timedelta(hours=24)

# Safety cap: don't archive more than this many claims per sweep
_DEFAULT_MAX_CLAIMS_PER_SWEEP = 200


# ---------------------------------------------------------------------------
# Report type
# ---------------------------------------------------------------------------

@dataclass
class ArchivalReport:
    """Result of a single archival sweep run."""

    owner_id: str
    archived_count: int = 0
    skipped_exempt: int = 0
    dry_run: bool = False

    # Oldest and newest archived claim (ISO timestamps, from created_at)
    oldest_claim_created_at: Optional[str] = None
    newest_claim_created_at: Optional[str] = None

    # IDs archived (populated in debug mode or dry_run for full traceability)
    archived_ids: List[str] = field(default_factory=list)

    # Timing
    started_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )
    completed_at: Optional[str] = None
    duration_ms: int = 0

    error: Optional[str] = None

    @property
    def success(self) -> bool:
        return self.error is None

    def to_dict(self) -> dict:
        return {
            "owner_id": self.owner_id,
            "archived_count": self.archived_count,
            "skipped_exempt": self.skipped_exempt,
            "dry_run": self.dry_run,
            "oldest_claim_created_at": self.oldest_claim_created_at,
            "newest_claim_created_at": self.newest_claim_created_at,
            "started_at": self.started_at,
            "completed_at": self.completed_at,
            "duration_ms": self.duration_ms,
            "success": self.success,
            "error": self.error,
        }


# ---------------------------------------------------------------------------
# Main sweep function
# ---------------------------------------------------------------------------

async def sweep_cold_claims(
    conn: "aiosqlite.Connection",
    owner_id: str,
    config: Optional["CortexConfig"] = None,
    *,
    dry_run: bool = False,
) -> ArchivalReport:
    """Archive active claims whose hotness_score is below the archive threshold.

    Args:
        conn:      Open aiosqlite connection.
        owner_id:  Owner/tenant identifier.
        config:    CortexConfig (uses hotness_archive_threshold,
                   archival_sweep_enabled, hotness_max_claims_per_sweep).
                   Falls back to safe defaults if None.
        dry_run:   If True, log what would be archived but do not commit
                   any changes to the database.

    Returns:
        ArchivalReport with counts and claim timestamps.
    """
    import time
    t0 = time.monotonic()

    report = ArchivalReport(owner_id=owner_id, dry_run=dry_run)

    # --- Read config values (with safe defaults) ---
    archive_threshold = 0.1
    enabled = True
    max_per_sweep = _DEFAULT_MAX_CLAIMS_PER_SWEEP

    if config is not None:
        archive_threshold = getattr(config, "hotness_archive_threshold", 0.1)
        enabled = getattr(config, "archival_sweep_enabled", True)
        max_per_sweep = getattr(config, "hotness_max_claims_per_sweep", _DEFAULT_MAX_CLAIMS_PER_SWEEP)

    if not enabled:
        logger.info(
            "[Archival] Sweep disabled via config (archival_sweep_enabled=False) for owner=%s",
            owner_id,
        )
        report.completed_at = datetime.now(timezone.utc).isoformat()
        report.duration_ms = int((time.monotonic() - t0) * 1000)
        return report

    prefix = "[Archival/dry-run]" if dry_run else "[Archival]"

    # --- Fetch candidates: active claims below archive threshold ---
    # hotness_score IS NULL means not yet scored; skip those (treat as active).
    cursor = await conn.execute(
        """
        SELECT id, memory_type, category, confidence, created_at, hotness_score
        FROM semantic_claim
        WHERE owner_id = ?
          AND status = 'active'
          AND (is_deleted = 0 OR is_deleted IS NULL)
          AND hotness_score IS NOT NULL
          AND hotness_score < ?
        ORDER BY hotness_score ASC
        LIMIT ?
        """,
        (owner_id, archive_threshold, max_per_sweep),
    )
    candidates = await cursor.fetchall()

    if not candidates:
        logger.info(
            "%s No cold claims found for owner=%s (threshold=%.3f)",
            prefix, owner_id, archive_threshold,
        )
        report.completed_at = datetime.now(timezone.utc).isoformat()
        report.duration_ms = int((time.monotonic() - t0) * 1000)
        return report

    logger.info(
        "%s Found %d candidate claims below threshold=%.3f for owner=%s",
        prefix, len(candidates), archive_threshold, owner_id,
    )

    # --- Filter exempt claims ---
    to_archive: list[tuple[str, str]] = []  # (id, created_at)
    skipped = 0

    for row in candidates:
        claim_id: str = row[0]
        memory_type: str = row[1] or ""
        category: str = row[2] or ""
        confidence: float = row[3] or 0.0
        created_at: Optional[str] = row[4]
        hotness_score: float = row[5]

        # Exemption check
        if memory_type in _EXEMPT_MEMORY_TYPES:
            logger.debug(
                "%s Skipping exempt claim id=%s memory_type=%s",
                prefix, claim_id, memory_type,
            )
            skipped += 1
            continue

        if category in _EXEMPT_CATEGORIES:
            logger.debug(
                "%s Skipping exempt claim id=%s category=%s",
                prefix, claim_id, category,
            )
            skipped += 1
            continue

        if confidence >= _EXEMPT_CONFIDENCE_FLOOR:
            logger.debug(
                "%s Skipping exempt claim id=%s confidence=%.3f",
                prefix, claim_id, confidence,
            )
            skipped += 1
            continue

        # 24h minimum age check — never archive claims less than 24h old
        if created_at:
            try:
                created_dt = datetime.fromisoformat(created_at)
                if created_dt.tzinfo is None:
                    created_dt = created_dt.replace(tzinfo=timezone.utc)
                age = datetime.now(timezone.utc) - created_dt
                if age < _MIN_AGE_FOR_ARCHIVAL:
                    logger.debug(
                        "%s Skipping too-young claim id=%s age=%s",
                        prefix, claim_id, age,
                    )
                    skipped += 1
                    continue
            except (ValueError, TypeError):
                pass  # If created_at is unparseable, don't skip

        logger.debug(
            "%s Will archive claim id=%s hotness=%.4f created_at=%s",
            prefix, claim_id, hotness_score, created_at,
        )
        to_archive.append((claim_id, created_at or ""))
        report.archived_ids.append(claim_id)

    report.skipped_exempt = skipped

    if not to_archive:
        logger.info(
            "%s All %d candidates were exempt from archival for owner=%s",
            prefix, len(candidates), owner_id,
        )
        report.completed_at = datetime.now(timezone.utc).isoformat()
        report.duration_ms = int((time.monotonic() - t0) * 1000)
        return report

    # --- Compute oldest/newest created_at ---
    valid_dates = [ca for _, ca in to_archive if ca]
    if valid_dates:
        valid_dates.sort()
        report.oldest_claim_created_at = valid_dates[0]
        report.newest_claim_created_at = valid_dates[-1]

    report.archived_count = len(to_archive)

    if dry_run:
        logger.info(
            "%s Would archive %d claims (skipped %d exempt) for owner=%s",
            prefix, report.archived_count, skipped, owner_id,
        )
    else:
        # --- Batch UPDATE: set status='archived' ---
        now = datetime.now(timezone.utc).isoformat()
        ids_to_archive = [claim_id for claim_id, _ in to_archive]

        # SQLite doesn't support bulk UPDATE with a list directly, so use executemany
        await conn.executemany(
            """
            UPDATE semantic_claim
            SET status = 'archived', updated_at = ?
            WHERE id = ? AND owner_id = ?
            """,
            [(now, claim_id, owner_id) for claim_id in ids_to_archive],
        )
        await conn.commit()

        logger.info(
            "%s Archived %d claims (skipped %d exempt) for owner=%s "
            "oldest=%s newest=%s",
            prefix,
            report.archived_count,
            skipped,
            owner_id,
            report.oldest_claim_created_at,
            report.newest_claim_created_at,
        )

    elapsed_ms = int((time.monotonic() - t0) * 1000)
    report.completed_at = datetime.now(timezone.utc).isoformat()
    report.duration_ms = elapsed_ms

    return report
