"""Tests for ContextCheckpointManager — three-tier context-aware checkpoints.

Covers:
- should_checkpoint threshold logic (50% trigger, thrash prevention)
- save_checkpoint with all three tiers (conversational, active work, session summary)
- save_checkpoint with partial tiers (graceful handling of missing data)
- load_for_recovery with structured state (format_for_injection path)
- load_for_recovery fallback to sparse checkpoint
- load_for_recovery with no state at all
- load_state raw access
- get_checkpoint_history metadata listing
- Internal helpers: _build_state, _build_context_summary, _build_open_items_json
- _format_sparse_checkpoint fallback formatting
"""
from __future__ import annotations

import json

import pytest

pytest.importorskip("greymatter_core.checkpoint", reason="checkpoint module not yet implemented")

from greymatter_core.checkpoint import (
    CHECKPOINT_THRESHOLD_BYTES,
    CONTEXT_WINDOW_BYTES,
    MIN_CHECKPOINT_INTERVAL_BYTES,
    ContextCheckpointManager,
)
from greymatter_core.db import SQLiteDatabase
from greymatter_core.session_state import (
    TIER_ESSENTIAL,
    TIER_FULL,
    TIER_STANDARD,
    SessionStateManager,
)
from greymatter_core.sessions import SessionManager


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


@pytest.fixture
def db():
    """In-memory SQLite database with full schema."""
    return SQLiteDatabase(":memory:")


@pytest.fixture
def mgr(db):
    """ContextCheckpointManager backed by in-memory DB."""
    return ContextCheckpointManager(db)


@pytest.fixture
def session(db):
    """Create and return an active session."""
    sessions = SessionManager(db)
    return sessions.start()


# ---------------------------------------------------------------------------
# should_checkpoint — threshold logic
# ---------------------------------------------------------------------------


class TestShouldCheckpoint:
    """Tests for the 50% context trigger with thrash prevention."""

    def test_below_threshold_returns_false(self, mgr):
        """No checkpoint when context is below 50%."""
        below = CHECKPOINT_THRESHOLD_BYTES - 1
        assert mgr.should_checkpoint(below) is False

    def test_at_threshold_returns_true(self, mgr):
        """Checkpoint triggers at exactly 50%."""
        assert mgr.should_checkpoint(CHECKPOINT_THRESHOLD_BYTES) is True

    def test_above_threshold_returns_true(self, mgr):
        """Checkpoint triggers above 50%."""
        above = CHECKPOINT_THRESHOLD_BYTES + 1_000_000
        assert mgr.should_checkpoint(above) is True

    def test_zero_bytes_returns_false(self, mgr):
        """No checkpoint when no context consumed."""
        assert mgr.should_checkpoint(0) is False

    def test_thrash_prevention_blocks_rapid_checkpoints(self, mgr):
        """If last checkpoint was recent (bytes-wise), suppress."""
        current = CHECKPOINT_THRESHOLD_BYTES + 500_000
        last = CHECKPOINT_THRESHOLD_BYTES + 100_000
        # Diff is only 400K, below MIN_CHECKPOINT_INTERVAL_BYTES (2MB)
        assert mgr.should_checkpoint(current, last_checkpoint_bytes=last) is False

    def test_thrash_prevention_allows_after_interval(self, mgr):
        """After enough new context, allow another checkpoint."""
        last = CHECKPOINT_THRESHOLD_BYTES
        current = last + MIN_CHECKPOINT_INTERVAL_BYTES + 1
        assert mgr.should_checkpoint(current, last_checkpoint_bytes=last) is True

    def test_first_checkpoint_always_fires_above_threshold(self, mgr):
        """First checkpoint (last=0) always fires above threshold."""
        assert mgr.should_checkpoint(CHECKPOINT_THRESHOLD_BYTES, last_checkpoint_bytes=0) is True

    def test_threshold_is_50_percent(self):
        """Verify the threshold is 50% of 20MB."""
        assert CHECKPOINT_THRESHOLD_BYTES == CONTEXT_WINDOW_BYTES // 2


# ---------------------------------------------------------------------------
# save_checkpoint — three-tier persistence
# ---------------------------------------------------------------------------


class TestSaveCheckpoint:
    """Tests for save_checkpoint with all three tiers."""

    def test_save_full_three_tier_checkpoint(self, mgr, session):
        """Save with all three tiers populated."""
        result = mgr.save_checkpoint(
            session_id=session["id"],
            context_metrics={"estimated_context_bytes": 12_000_000},
            conversational_state={
                "current_task": "Implement checkpoint system",
                "blockers": ["Need DB schema change"],
                "next_steps": "Write tests",
                "active_files": [
                    {"path": "core/checkpoint.py", "role": "editing"},
                    {"path": "core/sessions.py", "role": "reading"},
                ],
            },
            active_work={
                "work_item": {"id": "wi-123", "title": "Checkpoint feature"},
                "plan_title": "Checkpoint implementation",
                "plan_steps": [
                    {"description": "Research", "status": "completed"},
                    {"description": "Implement", "status": "in_progress"},
                    {"description": "Test", "status": "pending"},
                ],
                "current_step_index": 1,
                "decisions": [{"decision": "Use SQLite", "rationale": "Simpler"}],
                "progress_summary": "Research done, implementing now",
            },
            session_summary={
                "tasks": [
                    {"content": "Write core module", "status": "completed"},
                    {"content": "Write tests", "status": "pending"},
                ],
                "milestones": [{"type": "test_run", "summary": "5 passed"}],
                "custom": {"debug_flag": True},
            },
        )
        assert result["session_id"] == session["id"]
        assert result["checkpoint_id"]
        assert result["state_id"]
        assert result["context_pct"] == 60  # 12M / 20M = 60%
        assert result["tier_summary"]["conversational"] is True
        assert result["tier_summary"]["active_work"] is True
        assert result["tier_summary"]["session_summary"] is True
        assert result["checkpoint_at"]

    def test_save_conversational_only(self, mgr, session):
        """Save with only Tier 1 (conversational state)."""
        result = mgr.save_checkpoint(
            session_id=session["id"],
            conversational_state={
                "current_task": "Fix login bug",
                "next_steps": "Run tests",
            },
        )
        assert result["session_id"] == session["id"]
        assert result["tier_summary"]["conversational"] is True
        assert result["tier_summary"]["active_work"] is False
        assert result["tier_summary"]["session_summary"] is False

    def test_save_empty_tiers(self, mgr, session):
        """Save with no tier data — should still create checkpoint."""
        result = mgr.save_checkpoint(session_id=session["id"])
        assert result["checkpoint_id"]
        assert result["state_id"]
        assert result["context_pct"] == 0

    def test_save_auto_detects_session(self, mgr, session):
        """If session_id is empty, auto-detect current session."""
        result = mgr.save_checkpoint(
            session_id="",
            conversational_state={"current_task": "Auto-detected"},
        )
        assert result["session_id"] == session["id"]

    def test_save_creates_session_if_none(self, db):
        """If no session exists, create one."""
        mgr = ContextCheckpointManager(db)
        result = mgr.save_checkpoint(
            session_id="",
            conversational_state={"current_task": "New session"},
        )
        assert result["session_id"]

    def test_save_normalizes_string_files(self, mgr, session):
        """Active files as plain strings should be normalized to dicts."""
        result = mgr.save_checkpoint(
            session_id=session["id"],
            conversational_state={
                "current_task": "Testing",
                "active_files": ["foo.py", "bar.py"],
            },
        )
        state = mgr.load_state(session["id"])
        assert state is not None
        assert len(state.active_files) == 2
        assert state.active_files[0] == {"path": "foo.py", "role": "referenced"}

    def test_save_normalizes_string_blockers(self, mgr, session):
        """Blockers as a single string should become a list."""
        result = mgr.save_checkpoint(
            session_id=session["id"],
            conversational_state={
                "current_task": "Testing",
                "blockers": "CI is red",
            },
        )
        state = mgr.load_state(session["id"])
        assert state is not None
        assert state.blockers == ["CI is red"]

    def test_save_normalizes_string_plan_steps(self, mgr, session):
        """Plan steps as strings should become PlanStep objects."""
        result = mgr.save_checkpoint(
            session_id=session["id"],
            active_work={
                "plan_steps": ["Step A", "Step B"],
            },
        )
        state = mgr.load_state(session["id"])
        assert state is not None
        assert len(state.plan_steps) == 2
        assert state.plan_steps[0].description == "Step A"
        assert state.plan_steps[0].status == "pending"

    def test_save_merges_milestones_into_custom(self, mgr, session):
        """Milestones from session summary should be merged into custom."""
        result = mgr.save_checkpoint(
            session_id=session["id"],
            session_summary={
                "milestones": [{"type": "commit", "summary": "fix bug"}],
                "custom": {"extra": True},
            },
        )
        state = mgr.load_state(session["id"])
        assert state is not None
        assert "milestones" in state.custom
        assert state.custom["extra"] is True


# ---------------------------------------------------------------------------
# load_for_recovery — structured state and fallback
# ---------------------------------------------------------------------------


class TestLoadForRecovery:
    """Tests for load_for_recovery — the SessionStart entry point."""

    def test_load_structured_state(self, mgr, session):
        """When structured state exists, return formatted injection."""
        mgr.save_checkpoint(
            session_id=session["id"],
            conversational_state={
                "current_task": "Implement feature X",
                "blockers": ["API rate limit"],
                "next_steps": "Add retry logic",
            },
            active_work={
                "work_item": {"id": "wi-456", "title": "Feature X"},
            },
        )
        recovery = mgr.load_for_recovery(session["id"])
        assert recovery is not None
        assert "Implement feature X" in recovery
        assert "API rate limit" in recovery
        assert "wi-456" in recovery

    def test_load_respects_max_chars(self, mgr, session):
        """Injection output should respect the max_chars budget."""
        mgr.save_checkpoint(
            session_id=session["id"],
            conversational_state={
                "current_task": "A" * 500,
                "blockers": ["B" * 200],
                "next_steps": "C" * 200,
            },
            active_work={
                "decisions": [{"decision": "D" * 500, "rationale": "E" * 500}],
                "progress_summary": "F" * 500,
            },
        )
        essential = mgr.load_for_recovery(session["id"], max_chars=TIER_ESSENTIAL)
        standard = mgr.load_for_recovery(session["id"], max_chars=TIER_STANDARD)
        full = mgr.load_for_recovery(session["id"], max_chars=TIER_FULL)
        # Essential should be shorter than or equal to standard
        assert len(essential) <= len(standard) + 100  # allow small variance
        # Standard should be shorter than or equal to full
        assert len(standard) <= len(full) + 100

    def test_load_falls_back_to_sparse_checkpoint(self, db):
        """When no structured state exists, fall back to sparse checkpoint."""
        sessions = SessionManager(db)
        s = sessions.start()
        sessions.checkpoint(
            s["id"],
            context_summary="Sparse fallback test",
            open_items='[{"id": "wi-789", "title": "Sparse item"}]',
        )
        mgr = ContextCheckpointManager(db)
        recovery = mgr.load_for_recovery(s["id"])
        assert recovery is not None
        assert "Sparse fallback test" in recovery
        assert "Sparse item" in recovery

    def test_load_returns_none_when_no_state(self, db):
        """When no state or checkpoint exists, return None."""
        sessions = SessionManager(db)
        s = sessions.start()
        mgr = ContextCheckpointManager(db)
        recovery = mgr.load_for_recovery(s["id"])
        assert recovery is None

    def test_load_nonexistent_session(self, mgr):
        """Nonexistent session ID returns None."""
        recovery = mgr.load_for_recovery("nonexistent-session")
        assert recovery is None


# ---------------------------------------------------------------------------
# load_state — raw access
# ---------------------------------------------------------------------------


class TestLoadState:
    """Tests for direct SessionState access."""

    def test_load_state_returns_session_state(self, mgr, session):
        """Load the raw SessionState object."""
        mgr.save_checkpoint(
            session_id=session["id"],
            conversational_state={"current_task": "Direct access test"},
        )
        state = mgr.load_state(session["id"])
        assert state is not None
        assert state.current_task == "Direct access test"
        assert state.session_id == session["id"]

    def test_load_state_none_for_no_data(self, mgr, session):
        """Returns None when no state saved."""
        state = mgr.load_state(session["id"])
        assert state is None


# ---------------------------------------------------------------------------
# get_checkpoint_history — metadata listing
# ---------------------------------------------------------------------------


class TestGetCheckpointHistory:
    """Tests for checkpoint history retrieval."""

    def test_history_returns_metadata(self, mgr, session):
        """History should return state metadata without full data."""
        mgr.save_checkpoint(
            session_id=session["id"],
            conversational_state={"current_task": "First"},
        )
        mgr.save_checkpoint(
            session_id=session["id"],
            conversational_state={"current_task": "Second"},
        )
        history = mgr.get_checkpoint_history(session["id"])
        assert len(history) == 2
        assert "compaction_number" in history[0]
        assert "captured_at" in history[0]

    def test_history_empty_session(self, mgr, session):
        """Empty history for session with no checkpoints."""
        history = mgr.get_checkpoint_history(session["id"])
        assert history == []


# ---------------------------------------------------------------------------
# _build_context_summary — sparse checkpoint text
# ---------------------------------------------------------------------------


class TestBuildContextSummary:
    """Tests for the sparse context summary builder."""

    def test_includes_context_percentage(self, mgr):
        summary = mgr._build_context_summary(
            {}, {},
            {"estimated_context_bytes": 10_000_000},
        )
        assert "50%" in summary

    def test_includes_task(self, mgr):
        summary = mgr._build_context_summary(
            {"current_task": "Fix the login bug"}, {}, {},
        )
        assert "Fix the login bug" in summary

    def test_includes_work_item(self, mgr):
        summary = mgr._build_context_summary(
            {},
            {"work_item": {"id": "wi-100", "title": "Deploy hotfix"}},
            {},
        )
        assert "wi-100" in summary
        assert "Deploy hotfix" in summary

    def test_empty_inputs(self, mgr):
        summary = mgr._build_context_summary({}, {}, {})
        assert "Context-aware checkpoint" in summary


# ---------------------------------------------------------------------------
# _build_open_items_json — sparse checkpoint items
# ---------------------------------------------------------------------------


class TestBuildOpenItemsJson:
    """Tests for the open items JSON builder."""

    def test_includes_work_item(self, mgr):
        raw = mgr._build_open_items_json(
            {"work_item": {"id": "wi-200", "title": "Test item"}}, {},
        )
        items = json.loads(raw)
        assert any(i["id"] == "wi-200" for i in items)

    def test_includes_pending_tasks(self, mgr):
        raw = mgr._build_open_items_json(
            {},
            {"tasks": [
                {"content": "Done", "status": "completed"},
                {"content": "Todo", "status": "pending"},
            ]},
        )
        items = json.loads(raw)
        assert any(i["content"] == "Todo" for i in items)
        assert not any(i.get("content") == "Done" for i in items)

    def test_caps_at_500_chars(self, mgr):
        raw = mgr._build_open_items_json(
            {},
            {"tasks": [
                {"content": f"Task {i} with long description " + "x" * 50, "status": "pending"}
                for i in range(20)
            ]},
        )
        assert len(raw) <= 600  # Allow some slack for the compact form

    def test_empty_inputs(self, mgr):
        raw = mgr._build_open_items_json({}, {})
        assert json.loads(raw) == []


# ---------------------------------------------------------------------------
# _format_sparse_checkpoint — fallback formatting
# ---------------------------------------------------------------------------


class TestFormatSparseCheckpoint:
    """Tests for the static sparse checkpoint formatter."""

    def test_formats_summary_and_items(self):
        formatted = ContextCheckpointManager._format_sparse_checkpoint({
            "context_summary": "Testing checkpoint recovery",
            "open_items": '[{"title": "Fix bug"}, {"title": "Add test"}]',
            "checkpoint_at": "2026-03-28T12:00:00",
        })
        assert "Session Checkpoint Recovery" in formatted
        assert "Testing checkpoint recovery" in formatted
        assert "Fix bug" in formatted
        assert "Add test" in formatted
        assert "2026-03-28T12:00:00" in formatted

    def test_handles_invalid_json_items(self):
        formatted = ContextCheckpointManager._format_sparse_checkpoint({
            "context_summary": "Test",
            "open_items": "not-json",
            "checkpoint_at": "",
        })
        assert "not-json" in formatted

    def test_handles_empty_checkpoint(self):
        formatted = ContextCheckpointManager._format_sparse_checkpoint({})
        assert "Session Checkpoint Recovery" in formatted
