"""
tests/test_archival_sweep.py — Tests for archival sweep (sleep pipeline S4c, issue #1345).

Covers:
    - Claims below threshold get archived
    - Claims above threshold NOT touched
    - Already-archived claims NOT re-processed
    - dry_run mode logs but doesn't archive
    - Disabled config (archival_sweep_enabled=False) skips entirely
    - Exempt claims (cornerstone, identity category, high confidence) skipped
    - ArchivalReport fields populated correctly (counts, timestamps)
    - max_claims_per_sweep cap honoured
"""

from __future__ import annotations

import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock

import aiosqlite
import pytest
import pytest_asyncio

from cortex.core.archival import ArchivalReport, sweep_cold_claims

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

NOW = datetime(2026, 3, 20, 0, 0, 0, tzinfo=timezone.utc)  # 4 days ago, older than 24h minimum age


def _iso(dt: datetime | None = None) -> str:
    if dt is None:
        dt = NOW
    return dt.isoformat()


# Minimal schema: subset of semantic_claim needed for archival
_SCHEMA = """
CREATE TABLE IF NOT EXISTS semantic_claim (
    id TEXT PRIMARY KEY,
    owner_id TEXT NOT NULL,
    subject TEXT NOT NULL DEFAULT '',
    predicate TEXT NOT NULL DEFAULT '',
    object_value TEXT NOT NULL DEFAULT '',
    memory_type TEXT DEFAULT 'session',
    category TEXT DEFAULT 'misc',
    confidence REAL DEFAULT 0.5,
    status TEXT DEFAULT 'active',
    is_deleted INTEGER DEFAULT 0,
    hotness_score REAL,
    created_at TEXT,
    updated_at TEXT
);
"""


def _make_config(
    *,
    archive_threshold: float = 0.1,
    enabled: bool = True,
    max_per_sweep: int = 200,
) -> MagicMock:
    cfg = MagicMock()
    cfg.hotness_archive_threshold = archive_threshold
    cfg.archival_sweep_enabled = enabled
    cfg.hotness_max_claims_per_sweep = max_per_sweep
    return cfg


async def _insert_claim(
    conn: aiosqlite.Connection,
    *,
    claim_id: str | None = None,
    owner_id: str = "owner1",
    memory_type: str = "session",
    category: str = "misc",
    confidence: float = 0.5,
    status: str = "active",
    is_deleted: int = 0,
    hotness_score: float | None = 0.05,
    created_at: str | None = None,
) -> str:
    cid = claim_id or str(uuid.uuid4())
    cat = created_at or _iso()
    await conn.execute(
        """
        INSERT INTO semantic_claim
            (id, owner_id, memory_type, category, confidence, status,
             is_deleted, hotness_score, created_at, updated_at)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
        (cid, owner_id, memory_type, category, confidence, status,
         is_deleted, hotness_score, cat, cat),
    )
    await conn.commit()
    return cid


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest_asyncio.fixture
async def db():
    """In-memory SQLite DB with minimal schema."""
    conn = await aiosqlite.connect(":memory:")
    await conn.executescript(_SCHEMA)
    yield conn
    await conn.close()


# ---------------------------------------------------------------------------
# Core archival behaviour
# ---------------------------------------------------------------------------

class TestSweepColdClaims:

    @pytest.mark.asyncio
    async def test_cold_claim_gets_archived(self, db):
        """A claim with hotness_score < threshold should be archived."""
        cid = await _insert_claim(db, hotness_score=0.05)  # below default 0.1

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 1
        assert cid in report.archived_ids

        # Verify DB row updated
        cursor = await db.execute("SELECT status FROM semantic_claim WHERE id = ?", (cid,))
        row = await cursor.fetchone()
        assert row[0] == "archived"

    @pytest.mark.asyncio
    async def test_warm_claim_not_archived(self, db):
        """A claim with hotness_score >= threshold should NOT be archived."""
        cid = await _insert_claim(db, hotness_score=0.5)  # above default 0.1

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 0
        assert cid not in report.archived_ids

        cursor = await db.execute("SELECT status FROM semantic_claim WHERE id = ?", (cid,))
        row = await cursor.fetchone()
        assert row[0] == "active"

    @pytest.mark.asyncio
    async def test_already_archived_claim_not_reprocessed(self, db):
        """Claims already in 'archived' status are excluded from the sweep."""
        cid = await _insert_claim(db, hotness_score=0.02, status="archived")

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 0
        # Status unchanged
        cursor = await db.execute("SELECT status FROM semantic_claim WHERE id = ?", (cid,))
        row = await cursor.fetchone()
        assert row[0] == "archived"

    @pytest.mark.asyncio
    async def test_null_hotness_score_skipped(self, db):
        """Claims with NULL hotness_score (not yet scored) should be skipped."""
        cid = await _insert_claim(db, hotness_score=None)

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 0
        cursor = await db.execute("SELECT status FROM semantic_claim WHERE id = ?", (cid,))
        row = await cursor.fetchone()
        assert row[0] == "active"

    @pytest.mark.asyncio
    async def test_deleted_claim_not_archived(self, db):
        """Soft-deleted claims (is_deleted=1) should be excluded."""
        cid = await _insert_claim(db, hotness_score=0.02, is_deleted=1)

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 0

    @pytest.mark.asyncio
    async def test_multiple_claims_mixed(self, db):
        """Only cold active claims get archived; warm and non-active untouched."""
        cold1 = await _insert_claim(db, hotness_score=0.02)
        cold2 = await _insert_claim(db, hotness_score=0.08)
        warm = await _insert_claim(db, hotness_score=0.5)
        already_archived = await _insert_claim(db, hotness_score=0.01, status="archived")

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 2
        assert cold1 in report.archived_ids
        assert cold2 in report.archived_ids
        assert warm not in report.archived_ids
        assert already_archived not in report.archived_ids

    @pytest.mark.asyncio
    async def test_report_timestamps_populated(self, db):
        """oldest_claim_created_at / newest_claim_created_at match inserted data."""
        from datetime import timedelta

        old_dt = datetime(2026, 1, 1, tzinfo=timezone.utc)
        new_dt = datetime(2026, 3, 1, tzinfo=timezone.utc)

        await _insert_claim(db, hotness_score=0.03, created_at=_iso(old_dt))
        await _insert_claim(db, hotness_score=0.04, created_at=_iso(new_dt))

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 2
        assert report.oldest_claim_created_at == _iso(old_dt)
        assert report.newest_claim_created_at == _iso(new_dt)


# ---------------------------------------------------------------------------
# Dry-run mode
# ---------------------------------------------------------------------------

class TestDryRun:

    @pytest.mark.asyncio
    async def test_dry_run_does_not_archive(self, db):
        """dry_run=True must not mutate any rows."""
        cid = await _insert_claim(db, hotness_score=0.03)

        report = await sweep_cold_claims(db, "owner1", _make_config(), dry_run=True)

        # Report says it would have archived
        assert report.archived_count == 1
        assert report.dry_run is True

        # But DB row is unchanged
        cursor = await db.execute("SELECT status FROM semantic_claim WHERE id = ?", (cid,))
        row = await cursor.fetchone()
        assert row[0] == "active"

    @pytest.mark.asyncio
    async def test_dry_run_report_includes_ids(self, db):
        """dry_run report.archived_ids contains would-be-archived claim IDs."""
        cid = await _insert_claim(db, hotness_score=0.04)

        report = await sweep_cold_claims(db, "owner1", _make_config(), dry_run=True)

        assert cid in report.archived_ids


# ---------------------------------------------------------------------------
# Config: disabled sweep
# ---------------------------------------------------------------------------

class TestDisabledConfig:

    @pytest.mark.asyncio
    async def test_disabled_config_skips_sweep(self, db):
        """archival_sweep_enabled=False should return immediately with 0 archived."""
        cid = await _insert_claim(db, hotness_score=0.01)

        report = await sweep_cold_claims(db, "owner1", _make_config(enabled=False))

        assert report.archived_count == 0
        assert report.success is True

        # DB row unchanged
        cursor = await db.execute("SELECT status FROM semantic_claim WHERE id = ?", (cid,))
        row = await cursor.fetchone()
        assert row[0] == "active"

    @pytest.mark.asyncio
    async def test_no_config_uses_defaults(self, db):
        """Passing config=None should use safe defaults (threshold=0.1, enabled=True)."""
        # Score well below default threshold (0.1)
        cid = await _insert_claim(db, hotness_score=0.02)

        report = await sweep_cold_claims(db, "owner1", config=None)

        assert report.archived_count == 1

    @pytest.mark.asyncio
    async def test_custom_threshold(self, db):
        """Custom archive_threshold of 0.5 should catch more claims."""
        cid_cold = await _insert_claim(db, hotness_score=0.3)   # below 0.5
        cid_warm = await _insert_claim(db, hotness_score=0.7)   # above 0.5

        report = await sweep_cold_claims(
            db, "owner1", _make_config(archive_threshold=0.5)
        )

        assert report.archived_count == 1
        assert cid_cold in report.archived_ids
        assert cid_warm not in report.archived_ids


# ---------------------------------------------------------------------------
# Exemption rules
# ---------------------------------------------------------------------------

class TestExemptions:

    @pytest.mark.asyncio
    async def test_cornerstone_exempt(self, db):
        """memory_type='cornerstone' should never be archived."""
        cid = await _insert_claim(db, hotness_score=0.01, memory_type="cornerstone")

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 0
        assert report.skipped_exempt == 1
        cursor = await db.execute("SELECT status FROM semantic_claim WHERE id = ?", (cid,))
        row = await cursor.fetchone()
        assert row[0] == "active"

    @pytest.mark.asyncio
    async def test_immutable_exempt(self, db):
        """memory_type='immutable' should never be archived."""
        cid = await _insert_claim(db, hotness_score=0.01, memory_type="immutable")
        report = await sweep_cold_claims(db, "owner1", _make_config())
        assert report.archived_count == 0
        assert report.skipped_exempt == 1

    @pytest.mark.asyncio
    async def test_identity_category_exempt(self, db):
        """category='identity' should never be archived."""
        cid = await _insert_claim(db, hotness_score=0.01, category="identity")

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 0
        assert report.skipped_exempt == 1
        cursor = await db.execute("SELECT status FROM semantic_claim WHERE id = ?", (cid,))
        row = await cursor.fetchone()
        assert row[0] == "active"

    @pytest.mark.asyncio
    async def test_high_confidence_exempt(self, db):
        """confidence >= 0.95 should protect a claim from archival."""
        cid = await _insert_claim(db, hotness_score=0.01, confidence=0.95)

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 0
        assert report.skipped_exempt == 1

    @pytest.mark.asyncio
    async def test_below_confidence_floor_not_exempt(self, db):
        """confidence < 0.95 does NOT protect a cold claim from archival."""
        cid = await _insert_claim(db, hotness_score=0.01, confidence=0.94)

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 1

    @pytest.mark.asyncio
    async def test_mixed_exempt_and_cold(self, db):
        """Only non-exempt cold claims are archived; exempt ones skipped."""
        cold = await _insert_claim(db, hotness_score=0.01)
        exempt = await _insert_claim(
            db, hotness_score=0.01, memory_type="cornerstone"
        )

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 1
        assert cold in report.archived_ids
        assert exempt not in report.archived_ids
        assert report.skipped_exempt == 1


# ---------------------------------------------------------------------------
# Max claims per sweep cap
# ---------------------------------------------------------------------------

class TestMaxPerSweep:

    @pytest.mark.asyncio
    async def test_max_claims_cap(self, db):
        """max_claims_per_sweep=2 should limit archival to 2 even if 5 qualify."""
        for _ in range(5):
            await _insert_claim(db, hotness_score=0.01)

        report = await sweep_cold_claims(
            db, "owner1", _make_config(max_per_sweep=2)
        )

        assert report.archived_count == 2


# ---------------------------------------------------------------------------
# Owner isolation
# ---------------------------------------------------------------------------

class TestOwnerIsolation:

    @pytest.mark.asyncio
    async def test_only_matching_owner_archived(self, db):
        """Claims belonging to a different owner_id should not be archived."""
        cid_owner1 = await _insert_claim(db, owner_id="owner1", hotness_score=0.01)
        cid_owner2 = await _insert_claim(db, owner_id="owner2", hotness_score=0.01)

        report = await sweep_cold_claims(db, "owner1", _make_config())

        assert report.archived_count == 1
        assert cid_owner1 in report.archived_ids

        # owner2 claim unchanged
        cursor = await db.execute(
            "SELECT status FROM semantic_claim WHERE id = ?", (cid_owner2,)
        )
        row = await cursor.fetchone()
        assert row[0] == "active"


# ---------------------------------------------------------------------------
# Report metadata
# ---------------------------------------------------------------------------

class TestReportMetadata:

    @pytest.mark.asyncio
    async def test_success_flag_true_on_clean_run(self, db):
        report = await sweep_cold_claims(db, "owner1", _make_config())
        assert report.success is True
        assert report.error is None

    @pytest.mark.asyncio
    async def test_completed_at_set(self, db):
        report = await sweep_cold_claims(db, "owner1", _make_config())
        assert report.completed_at is not None

    @pytest.mark.asyncio
    async def test_duration_ms_positive(self, db):
        await _insert_claim(db, hotness_score=0.01)
        report = await sweep_cold_claims(db, "owner1", _make_config())
        assert report.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_to_dict_serializable(self, db):
        """to_dict() should produce a plain dict with no non-serializable values."""
        import json
        await _insert_claim(db, hotness_score=0.03)
        report = await sweep_cold_claims(db, "owner1", _make_config())
        d = report.to_dict()
        json.dumps(d)  # must not raise
        assert d["archived_count"] == 1


# ---------------------------------------------------------------------------
# Issue #1553: long-term exempt + 24h minimum age
# ---------------------------------------------------------------------------

class TestLongTermExemptAndMinAge:
    """Tests for long-term memory_type exemption and 24h minimum age check."""

    @pytest.mark.asyncio
    async def test_long_term_memory_type_exempt(self, db):
        """Claims with memory_type='long-term' must never be archived."""
        cid = await _insert_claim(db, hotness_score=0.01, memory_type="long-term")
        report = await sweep_cold_claims(db, "owner1", _make_config())
        assert report.archived_count == 0
        assert report.skipped_exempt == 1

        # Verify claim is still active
        cur = await db.execute("SELECT status FROM semantic_claim WHERE id = ?", (cid,))
        row = await cur.fetchone()
        assert row[0] == "active"

    @pytest.mark.asyncio
    async def test_young_claim_not_archived(self, db):
        """Claims less than 24h old must not be archived regardless of hotness."""
        from datetime import timedelta

        recent = datetime.now(timezone.utc) - timedelta(hours=1)
        cid = await _insert_claim(db, hotness_score=0.01, created_at=_iso(recent))
        report = await sweep_cold_claims(db, "owner1", _make_config())
        assert report.archived_count == 0
        assert report.skipped_exempt == 1

    @pytest.mark.asyncio
    async def test_old_claim_can_be_archived(self, db):
        """Claims older than 24h with low hotness should still be archived."""
        from datetime import timedelta

        old = datetime.now(timezone.utc) - timedelta(hours=48)
        cid = await _insert_claim(db, hotness_score=0.01, created_at=_iso(old))
        report = await sweep_cold_claims(db, "owner1", _make_config())
        assert report.archived_count == 1
