"""
Tests for brynq.retention — Archive, cleanup, compaction, and stats.
"""

import json
from datetime import datetime, timedelta, timezone

import pytest


@pytest.fixture(autouse=True)
def _isolate_retention(broker_dirs, monkeypatch):
    """Redirect retention module's paths to the temp broker."""
    import brynq.retention as ret
    monkeypatch.setattr(ret, "BROKER_DIR", broker_dirs["channels"].parent)
    monkeypatch.setattr(ret, "CHANNELS_DIR", broker_dirs["channels"])
    monkeypatch.setattr(ret, "SESSIONS_DIR", broker_dirs["sessions"])
    monkeypatch.setattr(ret, "CURSORS_DIR", broker_dirs["cursors"])
    monkeypatch.setattr(ret, "ARCHIVE_DIR", broker_dirs["archive"])


def _write_msg(channels_dir, channel, message, timestamp=None):
    """Helper to write a message to a channel JSONL file."""
    ts = timestamp or datetime.now(timezone.utc).isoformat()
    entry = {
        "id": "test-id",
        "timestamp": ts,
        "session_id": "test-session",
        "session_name": "TestBot",
        "platform": "cli",
        "channel": channel,
        "msg_type": "info",
        "message": message,
    }
    path = channels_dir / f"{channel}.jsonl"
    with path.open("a", encoding="utf-8") as f:
        f.write(json.dumps(entry, default=str) + "\n")
    return entry


# ── Archive Channel ──────────────────────────────────────────────────────


class TestArchiveChannel:
    def test_archives_old_messages(self, broker_dirs):
        from brynq.retention import archive_channel

        old_ts = (datetime.now(timezone.utc) - timedelta(days=14)).isoformat()
        new_ts = datetime.now(timezone.utc).isoformat()
        _write_msg(broker_dirs["channels"], "dev", "old msg", timestamp=old_ts)
        _write_msg(broker_dirs["channels"], "dev", "new msg", timestamp=new_ts)

        result = archive_channel("dev")
        assert result["archived"] == 1
        assert result["remaining"] == 1

        # Active file should only have the new message
        active = broker_dirs["channels"] / "dev.jsonl"
        lines = active.read_text("utf-8").strip().splitlines()
        assert len(lines) == 1
        assert "new msg" in lines[0]

    def test_creates_archive_file(self, broker_dirs):
        from brynq.retention import archive_channel

        old_ts = (datetime.now(timezone.utc) - timedelta(days=14)).isoformat()
        _write_msg(broker_dirs["channels"], "dev", "old msg", timestamp=old_ts)

        result = archive_channel("dev")
        assert result["archive_path"]
        archive_files = list(broker_dirs["archive"].glob("dev_*.jsonl"))
        assert len(archive_files) == 1

    def test_custom_cutoff_date(self, broker_dirs):
        from brynq.retention import archive_channel

        # Message from 3 days ago
        ts_3d = (datetime.now(timezone.utc) - timedelta(days=3)).isoformat()
        _write_msg(broker_dirs["channels"], "dev", "recent-ish", timestamp=ts_3d)

        # Default cutoff is 7 days, so 3-day-old message should survive
        result = archive_channel("dev")
        assert result["archived"] == 0
        assert result["remaining"] == 1

        # But with a 2-day cutoff, it should be archived
        cutoff = datetime.now(timezone.utc) - timedelta(days=2)
        result = archive_channel("dev", before_date=cutoff)
        assert result["archived"] == 1
        assert result["remaining"] == 0

    def test_nonexistent_channel(self, broker_dirs):
        from brynq.retention import archive_channel
        result = archive_channel("ghost")
        assert result["archived"] == 0
        assert result["remaining"] == 0

    def test_all_messages_recent(self, broker_dirs):
        from brynq.retention import archive_channel

        new_ts = datetime.now(timezone.utc).isoformat()
        _write_msg(broker_dirs["channels"], "dev", "fresh", timestamp=new_ts)

        result = archive_channel("dev")
        assert result["archived"] == 0
        assert result["remaining"] == 1

    def test_archive_appends_to_existing(self, broker_dirs):
        from brynq.retention import archive_channel

        # First archival
        old_ts1 = (datetime.now(timezone.utc) - timedelta(days=14)).isoformat()
        _write_msg(broker_dirs["channels"], "dev", "batch1", timestamp=old_ts1)
        archive_channel("dev")

        # Add more old messages and archive again
        old_ts2 = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat()
        _write_msg(broker_dirs["channels"], "dev", "batch2", timestamp=old_ts2)
        archive_channel("dev")

        # Archive file should have both batches
        archive_files = list(broker_dirs["archive"].glob("dev_*.jsonl"))
        total_archived = 0
        for f in archive_files:
            total_archived += len(f.read_text("utf-8").strip().splitlines())
        assert total_archived == 2

    def test_malformed_lines_kept_in_active(self, broker_dirs):
        from brynq.retention import archive_channel

        path = broker_dirs["channels"] / "dev.jsonl"
        path.write_text("NOT VALID JSON\n", encoding="utf-8")

        result = archive_channel("dev")
        assert result["archived"] == 0
        assert result["remaining"] == 1  # Malformed line stays


# ── Clean Stale Sessions ────────────────────────────────────────────────


class TestCleanStaleSessions:
    def test_removes_stale_session(self, broker_dirs):
        from brynq.retention import clean_stale_sessions

        stale_time = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
        session = broker_dirs["sessions"] / "stale-bot.json"
        session.write_text(json.dumps({
            "session_id": "stale-bot",
            "heartbeat": stale_time,
        }))

        cleaned = clean_stale_sessions(max_age_hours=24)
        assert "stale-bot" in cleaned
        assert not session.exists()

    def test_keeps_fresh_session(self, broker_dirs):
        from brynq.retention import clean_stale_sessions

        fresh_time = datetime.now(timezone.utc).isoformat()
        session = broker_dirs["sessions"] / "active-bot.json"
        session.write_text(json.dumps({
            "session_id": "active-bot",
            "heartbeat": fresh_time,
        }))

        cleaned = clean_stale_sessions(max_age_hours=24)
        assert "active-bot" not in cleaned
        assert session.exists()

    def test_removes_corrupt_session_file(self, broker_dirs):
        from brynq.retention import clean_stale_sessions

        bad = broker_dirs["sessions"] / "corrupt.json"
        bad.write_text("NOT JSON")

        cleaned = clean_stale_sessions()
        assert "corrupt" in cleaned
        assert not bad.exists()

    def test_removes_cursors_for_cleaned_session(self, broker_dirs):
        from brynq.retention import clean_stale_sessions

        stale_time = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
        session = broker_dirs["sessions"] / "stale.json"
        session.write_text(json.dumps({
            "session_id": "stale",
            "heartbeat": stale_time,
        }))
        # Create a cursor file for this session
        cursor = broker_dirs["cursors"] / "stale_general.cursor"
        cursor.write_text("42")

        clean_stale_sessions(max_age_hours=24)
        assert not cursor.exists()

    def test_no_sessions_returns_empty(self, broker_dirs):
        from brynq.retention import clean_stale_sessions
        cleaned = clean_stale_sessions()
        assert cleaned == []

    def test_custom_max_age(self, broker_dirs):
        from brynq.retention import clean_stale_sessions

        # Session is 2 hours old
        ts = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
        session = broker_dirs["sessions"] / "recent.json"
        session.write_text(json.dumps({
            "session_id": "recent",
            "heartbeat": ts,
        }))

        # With 24h threshold, session is fine
        cleaned_24h = clean_stale_sessions(max_age_hours=24)
        assert "recent" not in cleaned_24h

        # With 1h threshold, session is stale
        cleaned_1h = clean_stale_sessions(max_age_hours=1)
        assert "recent" in cleaned_1h


# ── Get Stats ────────────────────────────────────────────────────────────


class TestGetStats:
    def test_empty_broker_stats(self, broker_dirs):
        from brynq.retention import get_stats
        stats = get_stats()
        assert stats["channels"] == 0
        assert stats["total_messages"] == 0
        assert stats["active_sessions"] == 0
        assert stats["stale_sessions"] == 0
        assert stats["storage_bytes"] == 0

    def test_counts_channels_and_messages(self, broker_dirs):
        from brynq.retention import get_stats

        _write_msg(broker_dirs["channels"], "dev", "msg1")
        _write_msg(broker_dirs["channels"], "dev", "msg2")
        _write_msg(broker_dirs["channels"], "ops", "msg3")

        stats = get_stats()
        assert stats["channels"] == 2
        assert stats["total_messages"] == 3

    def test_tracks_oldest_and_newest(self, broker_dirs):
        from brynq.retention import get_stats

        _write_msg(broker_dirs["channels"], "dev", "old",
                   timestamp="2026-01-01T00:00:00+00:00")
        _write_msg(broker_dirs["channels"], "dev", "new",
                   timestamp="2026-03-15T00:00:00+00:00")

        stats = get_stats()
        assert "2026-01-01" in stats["oldest_message"]
        assert "2026-03-15" in stats["newest_message"]

    def test_counts_active_and_stale_sessions(self, broker_dirs):
        from brynq.retention import get_stats

        fresh = datetime.now(timezone.utc).isoformat()
        stale = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()

        (broker_dirs["sessions"] / "active.json").write_text(
            json.dumps({"session_id": "active", "heartbeat": fresh}))
        (broker_dirs["sessions"] / "stale.json").write_text(
            json.dumps({"session_id": "stale", "heartbeat": stale}))

        stats = get_stats()
        assert stats["active_sessions"] == 1
        assert stats["stale_sessions"] == 1

    def test_counts_archive_files(self, broker_dirs):
        from brynq.retention import get_stats

        (broker_dirs["archive"] / "dev_2026-01-01.jsonl").write_text(
            '{"message": "archived"}\n')

        stats = get_stats()
        assert stats["archive_count"] == 1

    def test_storage_includes_archives(self, broker_dirs):
        from brynq.retention import get_stats

        _write_msg(broker_dirs["channels"], "dev", "active msg")
        (broker_dirs["archive"] / "dev_old.jsonl").write_text(
            '{"message": "archived"}\n')

        stats = get_stats()
        assert stats["storage_bytes"] > 0


# ── Purge Channel ────────────────────────────────────────────────────────


class TestPurgeChannel:
    def test_purge_removes_all_messages(self, broker_dirs):
        from brynq.retention import purge_channel

        _write_msg(broker_dirs["channels"], "dev", "msg1")
        _write_msg(broker_dirs["channels"], "dev", "msg2")
        _write_msg(broker_dirs["channels"], "dev", "msg3")

        count = purge_channel("dev")
        assert count == 3

        # File should be empty but still exist
        path = broker_dirs["channels"] / "dev.jsonl"
        assert path.exists()
        assert path.read_text("utf-8").strip() == ""

    def test_purge_nonexistent_channel(self, broker_dirs):
        from brynq.retention import purge_channel
        count = purge_channel("ghost")
        assert count == 0

    def test_purge_empty_channel(self, broker_dirs):
        from brynq.retention import purge_channel

        path = broker_dirs["channels"] / "empty.jsonl"
        path.write_text("", encoding="utf-8")

        count = purge_channel("empty")
        assert count == 0


# ── Compact Channel ─────────────────────────────────────────────────────


class TestCompactChannel:
    def test_removes_malformed_lines(self, broker_dirs):
        from brynq.retention import compact_channel

        path = broker_dirs["channels"] / "dev.jsonl"
        path.write_text(
            '{"message": "good1"}\n'
            'BROKEN LINE\n'
            '{"message": "good2"}\n'
            'ALSO BROKEN\n',
            encoding="utf-8",
        )

        result = compact_channel("dev")
        assert result["before"] == 4
        assert result["after"] == 2
        assert result["removed"] == 2

        # Verify only valid lines remain
        lines = path.read_text("utf-8").strip().splitlines()
        assert len(lines) == 2
        for line in lines:
            json.loads(line)  # Should not raise

    def test_no_changes_for_clean_file(self, broker_dirs):
        from brynq.retention import compact_channel

        _write_msg(broker_dirs["channels"], "dev", "msg1")
        _write_msg(broker_dirs["channels"], "dev", "msg2")

        result = compact_channel("dev")
        assert result["removed"] == 0
        assert result["before"] == result["after"]

    def test_nonexistent_channel(self, broker_dirs):
        from brynq.retention import compact_channel
        result = compact_channel("ghost")
        assert result["before"] == 0
        assert result["after"] == 0
        assert result["removed"] == 0

    def test_compaction_is_atomic(self, broker_dirs):
        """After compaction, the file content matches expected valid entries."""
        from brynq.retention import compact_channel

        path = broker_dirs["channels"] / "dev.jsonl"
        path.write_text(
            '{"a": 1}\n'
            'bad\n'
            '{"b": 2}\n',
            encoding="utf-8",
        )

        compact_channel("dev")
        lines = path.read_text("utf-8").strip().splitlines()
        entries = [json.loads(line) for line in lines]
        assert entries[0] == {"a": 1}
        assert entries[1] == {"b": 2}


# ── Internal Helpers ─────────────────────────────────────────────────────


class TestAtomicWriteLines:
    def test_writes_lines_to_file(self, broker_dirs):
        from brynq.retention import _atomic_write_lines

        path = broker_dirs["channels"] / "test.jsonl"
        _atomic_write_lines(path, ['{"a": 1}', '{"b": 2}'])

        lines = path.read_text("utf-8").strip().splitlines()
        assert len(lines) == 2
        assert json.loads(lines[0]) == {"a": 1}

    def test_overwrites_existing_content(self, broker_dirs):
        from brynq.retention import _atomic_write_lines

        path = broker_dirs["channels"] / "test.jsonl"
        path.write_text("old content\n", encoding="utf-8")
        _atomic_write_lines(path, ['{"new": true}'])

        lines = path.read_text("utf-8").strip().splitlines()
        assert len(lines) == 1
        assert json.loads(lines[0]) == {"new": True}


class TestSafeRemove:
    def test_removes_existing_file(self, broker_dirs):
        from brynq.retention import _safe_remove

        path = broker_dirs["channels"] / "temp.txt"
        path.write_text("x")
        _safe_remove(path)
        assert not path.exists()

    def test_ignores_missing_file(self, broker_dirs):
        from brynq.retention import _safe_remove
        path = broker_dirs["channels"] / "nonexistent.txt"
        _safe_remove(path)  # Should not raise


class TestRemoveCursorsForSession:
    def test_removes_all_cursors(self, broker_dirs):
        from brynq.retention import _remove_cursors_for_session

        (broker_dirs["cursors"] / "sess1_general.cursor").write_text("5")
        (broker_dirs["cursors"] / "sess1_dev.cursor").write_text("3")
        (broker_dirs["cursors"] / "sess2_general.cursor").write_text("1")

        _remove_cursors_for_session("sess1")

        assert not (broker_dirs["cursors"] / "sess1_general.cursor").exists()
        assert not (broker_dirs["cursors"] / "sess1_dev.cursor").exists()
        # sess2 cursor should be untouched
        assert (broker_dirs["cursors"] / "sess2_general.cursor").exists()
