"""
tests/test_hotness_scoring.py — Tests for hotness scoring engine (#1343).

Covers:
    - compute_hotness: boundary cases (zero access, active+recent, old+inactive)
    - v27 migration applies cleanly
    - batch_score_claims updates DB rows
"""

from __future__ import annotations

import math
from datetime import datetime, timedelta, timezone

import aiosqlite
import pytest
import pytest_asyncio

from cortex.core.hotness import batch_score_claims, compute_hotness

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

NOW = datetime(2026, 3, 24, 0, 0, 0, tzinfo=timezone.utc)


def _iso(dt: datetime) -> str:
    return dt.isoformat()


# Minimal schema needed for tests (subset of v3 + v27)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS cortex_config (
    key TEXT PRIMARY KEY,
    value TEXT,
    updated_at TEXT
);
INSERT OR IGNORE INTO cortex_config (key, value) VALUES ('schema_version', '25');

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 '',
    access_count INTEGER DEFAULT 0,
    last_accessed TEXT,
    is_deleted INTEGER DEFAULT 0,
    hotness_score REAL DEFAULT 0.0,
    status TEXT DEFAULT 'active'
);
"""


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


# ---------------------------------------------------------------------------
# compute_hotness unit tests
# ---------------------------------------------------------------------------

class TestComputeHotness:
    """Tests for the pure compute_hotness function."""

    def test_zero_access_returns_zero(self):
        """Zero access count → score is 0.0 regardless of recency."""
        score = compute_hotness(0, _iso(NOW), now=NOW)
        assert score == 0.0, f"Expected 0.0 for zero access, got {score}"

    def test_recent_and_active_high_score(self):
        """High access count + recent access → score > 0.5."""
        score = compute_hotness(
            access_count=20,
            last_accessed_iso=_iso(NOW - timedelta(hours=1)),
            now=NOW,
        )
        assert score > 0.5, f"Expected > 0.5 for active+recent, got {score}"

    def test_old_and_inactive_low_score(self):
        """Low access count + old access → score < 0.1."""
        score = compute_hotness(
            access_count=1,
            last_accessed_iso=_iso(NOW - timedelta(days=200)),
            now=NOW,
        )
        assert score < 0.1, f"Expected < 0.1 for old+inactive, got {score}"

    def test_half_life_decay(self):
        """After exactly half_life_days, recency should be ~0.5."""
        half_life = 45
        score_at_half = compute_hotness(
            access_count=100,  # high freq to isolate recency effect
            last_accessed_iso=_iso(NOW - timedelta(days=half_life)),
            half_life_days=half_life,
            now=NOW,
        )
        # With access_count=100, frequency ≈ 0.9908
        # recency at half_life = 0.5, so score ≈ 0.495
        score_at_zero = compute_hotness(
            access_count=100,
            last_accessed_iso=_iso(NOW),
            half_life_days=half_life,
            now=NOW,
        )
        ratio = score_at_half / score_at_zero if score_at_zero > 0 else 0
        assert 0.45 < ratio < 0.55, f"Expected ratio ~0.5 at half-life, got {ratio}"

    def test_none_last_accessed_low_score(self):
        """None last_accessed → treated as very old, low score."""
        score = compute_hotness(access_count=5, last_accessed_iso=None, now=NOW)
        assert score < 0.05, f"Expected very low for None last_accessed, got {score}"

    def test_clamped_to_unit_interval(self):
        """Score is always in [0.0, 1.0]."""
        score = compute_hotness(
            access_count=1_000_000,
            last_accessed_iso=_iso(NOW),
            now=NOW,
        )
        assert 0.0 <= score <= 1.0, f"Score out of bounds: {score}"

    def test_invalid_iso_string(self):
        """Unparseable timestamp → treated as very old."""
        score = compute_hotness(access_count=10, last_accessed_iso="not-a-date", now=NOW)
        assert score < 0.1, f"Expected very low for invalid ISO, got {score}"

    # --- B-002: Negative access_count must not crash math.log1p ---

    def test_negative_access_count_does_not_crash(self):
        """B-002: Negative access_count (bad data) must not raise ValueError."""
        score = compute_hotness(access_count=-5, last_accessed_iso=_iso(NOW), now=NOW)
        assert score == 0.0, f"Expected 0.0 for negative access_count, got {score}"

    def test_negative_access_count_treated_as_zero(self):
        """B-002: Negative access_count should produce same result as 0."""
        score_neg = compute_hotness(access_count=-100, last_accessed_iso=_iso(NOW), now=NOW)
        score_zero = compute_hotness(access_count=0, last_accessed_iso=_iso(NOW), now=NOW)
        assert score_neg == score_zero

    # --- B-012: half_life_days=0 should disable recency, not give 1.0 ---

    def test_half_life_zero_disables_recency(self):
        """B-012: half_life_days=0 → recency disabled → score=0.0."""
        score = compute_hotness(
            access_count=100,
            last_accessed_iso=_iso(NOW),
            half_life_days=0,
            now=NOW,
        )
        assert score == 0.0, f"Expected 0.0 when half_life_days=0, got {score}"

    def test_half_life_negative_disables_recency(self):
        """B-012: Negative half_life_days → recency disabled → score=0.0."""
        score = compute_hotness(
            access_count=50,
            last_accessed_iso=_iso(NOW),
            half_life_days=-10,
            now=NOW,
        )
        assert score == 0.0, f"Expected 0.0 when half_life_days<0, got {score}"


# ---------------------------------------------------------------------------
# v27 migration test
# ---------------------------------------------------------------------------

class TestV27Migration:
    """Test that the v27 migration applies cleanly."""

    @pytest.mark.asyncio
    async def test_migration_adds_column(self):
        """v27 migration adds hotness_score column to semantic_claim."""
        # Start with a schema WITHOUT hotness_score
        conn = await aiosqlite.connect(":memory:")
        await conn.executescript("""
            CREATE TABLE cortex_config (key TEXT PRIMARY KEY, value TEXT, updated_at TEXT);
            INSERT INTO cortex_config (key, value) VALUES ('schema_version', '25');
            CREATE TABLE semantic_claim (
                id TEXT PRIMARY KEY,
                owner_id TEXT,
                access_count INTEGER DEFAULT 0,
                last_accessed TEXT,
                is_deleted INTEGER DEFAULT 0
            );
        """)

        # Import and apply the migration
        from cortex.storage.migrations.v27_hotness_score import apply

        await apply(conn)

        # Verify column exists
        cursor = await conn.execute("PRAGMA table_info(semantic_claim)")
        columns = [row[1] for row in await cursor.fetchall()]
        assert "hotness_score" in columns

        # Verify index exists
        cursor = await conn.execute(
            "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_claim_hotness'"
        )
        row = await cursor.fetchone()
        assert row is not None, "idx_claim_hotness index should exist"

        await conn.close()

    @pytest.mark.asyncio
    async def test_sql_up_does_not_contain_raw_alter(self):
        """B-014: SQL_UP should not contain an uncommented ALTER TABLE statement."""
        from cortex.storage.migrations.v27_hotness_score import SQL_UP
        # All ALTER TABLE lines should be commented out
        for line in SQL_UP.strip().splitlines():
            stripped = line.strip()
            if stripped.startswith("--") or stripped == "":
                continue
            assert "ALTER TABLE" not in stripped.upper(), (
                f"SQL_UP contains uncommented ALTER TABLE: {stripped}"
            )

    @pytest.mark.asyncio
    async def test_migration_is_idempotent(self):
        """Applying v27 twice doesn't raise."""
        conn = await aiosqlite.connect(":memory:")
        await conn.executescript("""
            CREATE TABLE cortex_config (key TEXT PRIMARY KEY, value TEXT, updated_at TEXT);
            INSERT INTO cortex_config (key, value) VALUES ('schema_version', '25');
            CREATE TABLE semantic_claim (
                id TEXT PRIMARY KEY,
                owner_id TEXT,
                access_count INTEGER DEFAULT 0,
                last_accessed TEXT,
                is_deleted INTEGER DEFAULT 0
            );
        """)

        from cortex.storage.migrations.v27_hotness_score import apply

        await apply(conn)
        await apply(conn)  # Should not raise

        await conn.close()


# ---------------------------------------------------------------------------
# batch_score_claims integration test
# ---------------------------------------------------------------------------

class TestBatchScoreClaims:
    """Test batch_score_claims against an in-memory DB."""

    @pytest.mark.asyncio
    async def test_batch_scores_updates_db(self, db):
        """batch_score_claims computes and persists scores."""
        owner = "test-owner"

        # Insert test claims
        await db.execute(
            "INSERT INTO semantic_claim (id, owner_id, access_count, last_accessed) VALUES (?, ?, ?, ?)",
            ("c1", owner, 20, _iso(NOW - timedelta(hours=2))),
        )
        await db.execute(
            "INSERT INTO semantic_claim (id, owner_id, access_count, last_accessed) VALUES (?, ?, ?, ?)",
            ("c2", owner, 1, _iso(NOW - timedelta(days=200))),
        )
        await db.execute(
            "INSERT INTO semantic_claim (id, owner_id, access_count, last_accessed) VALUES (?, ?, ?, ?)",
            ("c3", owner, 0, None),
        )
        await db.commit()

        count = await batch_score_claims(conn=db, owner_id=owner)
        assert count == 3

        # Verify scores in DB
        cursor = await db.execute(
            "SELECT id, hotness_score FROM semantic_claim ORDER BY id"
        )
        rows = await cursor.fetchall()
        scores = {r[0]: r[1] for r in rows}

        # c1: active+recent → highest
        assert scores["c1"] > 0.5, f"c1 should be hot, got {scores['c1']}"
        # c2: old+inactive → low
        assert scores["c2"] < 0.1, f"c2 should be cold, got {scores['c2']}"
        # c3: zero access → zero
        assert scores["c3"] == 0.0, f"c3 should be zero, got {scores['c3']}"

    @pytest.mark.asyncio
    async def test_skips_deleted_claims(self, db):
        """Deleted claims are not scored."""
        owner = "test-owner"
        await db.execute(
            "INSERT INTO semantic_claim (id, owner_id, access_count, last_accessed, is_deleted) VALUES (?, ?, ?, ?, ?)",
            ("deleted1", owner, 50, _iso(NOW), 1),
        )
        await db.commit()

        count = await batch_score_claims(conn=db, owner_id=owner)
        assert count == 0

    @pytest.mark.asyncio
    async def test_batch_handles_large_dataset(self, db):
        """B-013: batch_score_claims should handle many rows via fetchmany."""
        owner = "bulk-owner"
        # Insert 500 claims (enough to exceed a small batch_size)
        for i in range(500):
            await db.execute(
                "INSERT INTO semantic_claim (id, owner_id, access_count, last_accessed) VALUES (?, ?, ?, ?)",
                (f"bulk-{i}", owner, i % 50, _iso(NOW - timedelta(days=i % 100))),
            )
        await db.commit()

        count = await batch_score_claims(conn=db, owner_id=owner)
        assert count == 500, f"Expected 500 claims scored, got {count}"

        # Verify all have non-default scores (except access_count=0 ones)
        cursor = await db.execute(
            "SELECT COUNT(*) FROM semantic_claim WHERE owner_id = ? AND hotness_score > 0",
            (owner,),
        )
        row = await cursor.fetchone()
        # access_count=0 at i=0,50,100,... → 10 zeros out of 500
        assert row[0] > 0, "Some claims should have positive hotness scores"

    @pytest.mark.asyncio
    async def test_respects_owner_isolation(self, db):
        """Only scores claims for the specified owner_id."""
        await db.execute(
            "INSERT INTO semantic_claim (id, owner_id, access_count, last_accessed) VALUES (?, ?, ?, ?)",
            ("a1", "alice", 10, _iso(NOW)),
        )
        await db.execute(
            "INSERT INTO semantic_claim (id, owner_id, access_count, last_accessed) VALUES (?, ?, ?, ?)",
            ("b1", "bob", 10, _iso(NOW)),
        )
        await db.commit()

        count = await batch_score_claims(conn=db, owner_id="alice")
        assert count == 1

        # Bob's claim should still be 0.0
        cursor = await db.execute(
            "SELECT hotness_score FROM semantic_claim WHERE id = 'b1'"
        )
        row = await cursor.fetchone()
        assert row[0] == 0.0
