"""Tests for structured session state — data classes, serialization, and persistence.

Covers:
- PlanStep — to_dict / from_dict round-trip
- SessionState — to_dict / from_dict, format_for_injection tiers
- SessionStateManager — save, load_latest, load_by_compaction, get_history, cleanup
- merge_conversation_state — bridging regex capture into structured state
"""
from __future__ import annotations

import json

import pytest

from greymatter_core.db import SQLiteDatabase
from greymatter_core.session_state import (
    MAX_STATES_PER_SESSION,
    TIER_ESSENTIAL,
    TIER_FULL,
    TIER_STANDARD,
    PlanStep,
    SessionState,
    SessionStateManager,
)


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


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


def _ensure_session(db, session_id: str) -> None:
    """Insert a session row if it doesn't exist (FK requirement)."""
    existing = db.execute_one("SELECT id FROM sessions WHERE id = ?", (session_id,))
    if not existing:
        db.execute("INSERT INTO sessions (id) VALUES (?)", (session_id,))


@pytest.fixture
def manager(db):
    """SessionStateManager backed by in-memory DB."""
    _ensure_session(db, "sess-test")
    return SessionStateManager(db)


def _make_state(
    session_id: str = "sess-test",
    compaction: int = 0,
    task: str = "Fix the bug",
    **kwargs,
) -> SessionState:
    """Helper to create a SessionState with common defaults."""
    return SessionState(
        session_id=session_id,
        compaction_number=compaction,
        current_task=task,
        **kwargs,
    )


# ---------------------------------------------------------------------------
# PlanStep
# ---------------------------------------------------------------------------


class TestPlanStep:
    """Tests for PlanStep data class."""

    def test_to_dict(self):
        step = PlanStep(description="Read the code", status="completed", notes="Done")
        d = step.to_dict()
        assert d == {"description": "Read the code", "status": "completed", "notes": "Done"}

    def test_from_dict(self):
        step = PlanStep.from_dict({"description": "Write tests", "status": "in_progress", "notes": ""})
        assert step.description == "Write tests"
        assert step.status == "in_progress"

    def test_from_dict_missing_fields(self):
        step = PlanStep.from_dict({})
        assert step.description == ""
        assert step.status == "pending"
        assert step.notes == ""

    def test_roundtrip(self):
        original = PlanStep(description="Deploy", status="pending", notes="Needs review")
        restored = PlanStep.from_dict(original.to_dict())
        assert restored.description == original.description
        assert restored.status == original.status
        assert restored.notes == original.notes


# ---------------------------------------------------------------------------
# SessionState serialization
# ---------------------------------------------------------------------------


class TestSessionStateSerialization:
    """Tests for SessionState to_dict / from_dict."""

    def test_to_dict_all_fields(self):
        state = SessionState(
            session_id="sess-1",
            compaction_number=2,
            captured_at="2025-01-01T00:00:00",
            current_task="Fix the bug",
            plan_title="Bug fix plan",
            plan_steps=[PlanStep("Step 1", "completed"), PlanStep("Step 2", "pending")],
            current_step_index=1,
            tasks=[{"content": "Run tests", "status": "pending"}],
            decisions=[{"decision": "Use SQLite", "rationale": "Simpler"}],
            active_files=[{"path": "core/db.py", "role": "editing"}],
            progress_summary="Identified root cause",
            next_steps="Write the fix",
            blockers=["Need DB schema change"],
            custom={"extra": "data"},
        )
        d = state.to_dict()
        assert d["session_id"] == "sess-1"
        assert d["compaction_number"] == 2
        assert len(d["plan_steps"]) == 2
        assert d["plan_steps"][0]["status"] == "completed"
        assert d["blockers"] == ["Need DB schema change"]
        assert d["custom"] == {"extra": "data"}

    def test_from_dict_roundtrip(self):
        state = SessionState(
            session_id="sess-rt",
            compaction_number=1,
            current_task="Test roundtrip",
            plan_steps=[PlanStep("A"), PlanStep("B", "completed")],
            current_step_index=0,
            decisions=[{"decision": "Choice", "rationale": "Why"}],
            active_files=[{"path": "file.py", "role": "reading"}],
        )
        restored = SessionState.from_dict(state.to_dict())
        assert restored.session_id == state.session_id
        assert restored.compaction_number == state.compaction_number
        assert restored.current_task == state.current_task
        assert len(restored.plan_steps) == 2
        assert restored.plan_steps[1].status == "completed"
        assert restored.current_step_index == 0

    def test_from_dict_missing_fields_defaults(self):
        state = SessionState.from_dict({})
        assert state.session_id == ""
        assert state.compaction_number == 0
        assert state.current_step_index == -1
        assert state.plan_steps == []
        assert state.blockers == []
        assert state.custom == {}

    def test_json_roundtrip(self):
        state = _make_state(
            blockers=["Blocked on API"],
            active_files=[{"path": "test.py", "role": "testing"}],
        )
        json_str = json.dumps(state.to_dict())
        restored = SessionState.from_dict(json.loads(json_str))
        assert restored.current_task == "Fix the bug"
        assert restored.blockers == ["Blocked on API"]


# ---------------------------------------------------------------------------
# SessionState.format_for_injection — tiered output
# ---------------------------------------------------------------------------


class TestFormatForInjection:
    """Tests for the tiered formatting method."""

    def test_empty_state_minimal_output(self):
        state = SessionState()
        output = state.format_for_injection()
        assert "Structured Session State" in output

    def test_essential_fields_always_present(self):
        state = _make_state(
            task="Deploy hotfix",
            blockers=["CI is red"],
            next_steps="Fix flaky test",
        )
        output = state.format_for_injection(max_chars=TIER_ESSENTIAL)
        assert "Deploy hotfix" in output
        assert "CI is red" in output

    def test_plan_progress_shown(self):
        state = _make_state(
            plan_title="Refactor DB",
            plan_steps=[
                PlanStep("Read code", "completed"),
                PlanStep("Write migration", "in_progress"),
                PlanStep("Test", "pending"),
            ],
            current_step_index=1,
        )
        output = state.format_for_injection(max_chars=TIER_STANDARD)
        assert "Refactor DB" in output
        assert "1/3 done" in output
        assert "Write migration" in output

    def test_full_tier_includes_tasks(self):
        state = _make_state(
            tasks=[
                {"content": "Read code", "status": "completed"},
                {"content": "Write fix", "status": "in_progress"},
            ],
        )
        output = state.format_for_injection(max_chars=TIER_FULL)
        assert "Read code" in output
        assert "Write fix" in output

    def test_decisions_in_standard_tier(self):
        state = _make_state(
            decisions=[{"decision": "Use async", "rationale": "Non-blocking I/O"}],
        )
        output = state.format_for_injection(max_chars=TIER_STANDARD)
        assert "Use async" in output
        assert "Non-blocking I/O" in output

    def test_active_files_in_standard_tier(self):
        state = _make_state(
            active_files=[{"path": "core/db.py", "role": "editing"}],
        )
        output = state.format_for_injection(max_chars=TIER_STANDARD)
        assert "core/db.py" in output
        assert "editing" in output

    def test_truncation_respects_budget(self):
        state = _make_state(
            task="A" * 500,
            progress_summary="B" * 500,
            decisions=[{"decision": "C" * 500, "rationale": "D" * 500}],
            active_files=[{"path": f"file{i}.py", "role": "reading"} for i in range(20)],
            custom={"key": "E" * 500},
        )
        output = state.format_for_injection(max_chars=200)
        assert len(output) <= 1000  # Some overhead for header, but within reason

    def test_work_item_shown(self):
        state = _make_state(
            active_work_item={"id": "wi-123", "title": "Fix login bug"},
        )
        output = state.format_for_injection()
        assert "wi-123" in output
        assert "Fix login bug" in output

    def test_custom_context_in_full_tier(self):
        state = _make_state(custom={"debug_info": "stack trace here"})
        output = state.format_for_injection(max_chars=TIER_FULL)
        assert "debug_info" in output


# ---------------------------------------------------------------------------
# SessionStateManager — persistence
# ---------------------------------------------------------------------------


class TestSessionStateManager:
    """Tests for save/load/history/cleanup."""

    def test_save_and_load_latest(self, db, manager):
        _ensure_session(db, "sess-1")
        state = _make_state(session_id="sess-1", compaction=0, task="Initial")
        result = manager.save(state)
        assert "state_id" in result

        loaded = manager.load_latest("sess-1")
        assert loaded is not None
        assert loaded.current_task == "Initial"
        assert loaded.session_id == "sess-1"

    def test_load_latest_returns_highest_compaction(self, db, manager):
        _ensure_session(db, "sess-1")
        manager.save(_make_state(session_id="sess-1", compaction=0, task="First"))
        manager.save(_make_state(session_id="sess-1", compaction=1, task="Second"))
        manager.save(_make_state(session_id="sess-1", compaction=2, task="Third"))

        loaded = manager.load_latest("sess-1")
        assert loaded is not None
        assert loaded.current_task == "Third"
        assert loaded.compaction_number == 2

    def test_load_latest_nonexistent_session(self, manager):
        assert manager.load_latest("nonexistent") is None

    def test_load_by_compaction(self, db, manager):
        _ensure_session(db, "sess-1")
        manager.save(_make_state(session_id="sess-1", compaction=0, task="Zero"))
        manager.save(_make_state(session_id="sess-1", compaction=1, task="One"))

        loaded = manager.load_by_compaction("sess-1", 0)
        assert loaded is not None
        assert loaded.current_task == "Zero"

        loaded = manager.load_by_compaction("sess-1", 1)
        assert loaded is not None
        assert loaded.current_task == "One"

    def test_load_by_compaction_not_found(self, db, manager):
        _ensure_session(db, "sess-1")
        assert manager.load_by_compaction("sess-1", 99) is None

    def test_get_history(self, db, manager):
        _ensure_session(db, "sess-1")
        manager.save(_make_state(session_id="sess-1", compaction=0))
        manager.save(_make_state(session_id="sess-1", compaction=1))
        manager.save(_make_state(session_id="sess-1", compaction=2))

        history = manager.get_history("sess-1")
        assert len(history) == 3
        # Should be ordered by compaction DESC
        compactions = [h["compaction_number"] for h in history]
        assert compactions == [2, 1, 0]

    def test_get_history_empty_session(self, manager):
        assert manager.get_history("nonexistent") == []

    def test_cleanup_enforces_retention_limit(self, db, manager):
        _ensure_session(db, "sess-1")
        for i in range(MAX_STATES_PER_SESSION + 3):
            manager.save(_make_state(session_id="sess-1", compaction=i, task=f"Task {i}"))

        history = manager.get_history("sess-1")
        assert len(history) == MAX_STATES_PER_SESSION

        # Latest should survive
        loaded = manager.load_latest("sess-1")
        assert loaded is not None
        assert loaded.compaction_number == MAX_STATES_PER_SESSION + 2

    def test_save_sets_captured_at(self, manager):
        state = _make_state()
        assert state.captured_at == ""
        result = manager.save(state)
        assert result["captured_at"] != ""

    def test_different_sessions_isolated(self, db, manager):
        _ensure_session(db, "sess-a")
        _ensure_session(db, "sess-b")
        manager.save(_make_state(session_id="sess-a", compaction=0, task="A"))
        manager.save(_make_state(session_id="sess-b", compaction=0, task="B"))

        a = manager.load_latest("sess-a")
        b = manager.load_latest("sess-b")
        assert a is not None and a.current_task == "A"
        assert b is not None and b.current_task == "B"


# ---------------------------------------------------------------------------
# merge_conversation_state
# ---------------------------------------------------------------------------


class TestMergeConversationState:
    """Tests for merging regex-extracted state into SessionState."""

    def test_fills_empty_fields(self, manager):
        state = SessionState(session_id="sess-1")
        conv = {
            "current_task": "Fix bug",
            "next_steps": "Run tests",
            "recent_files": ["file1.py", "file2.py"],
        }
        merged = manager.merge_conversation_state(state, conv)
        assert merged.current_task == "Fix bug"
        assert merged.next_steps == "Run tests"
        assert len(merged.active_files) == 2
        assert merged.active_files[0]["path"] == "file1.py"
        assert merged.active_files[0]["role"] == "referenced"

    def test_does_not_overwrite_existing_fields(self, manager):
        state = SessionState(
            session_id="sess-1",
            current_task="Original task",
            next_steps="Original next",
        )
        conv = {
            "current_task": "Overwrite attempt",
            "next_steps": "Overwrite attempt",
        }
        merged = manager.merge_conversation_state(state, conv)
        assert merged.current_task == "Original task"
        assert merged.next_steps == "Original next"

    def test_deduplicates_files(self, manager):
        state = SessionState(
            session_id="sess-1",
            active_files=[{"path": "existing.py", "role": "editing"}],
        )
        conv = {
            "recent_files": ["existing.py", "new.py"],
        }
        merged = manager.merge_conversation_state(state, conv)
        paths = [f["path"] for f in merged.active_files]
        assert paths.count("existing.py") == 1
        assert "new.py" in paths

    def test_empty_conversation_state(self, manager):
        state = _make_state(task="Keep me")
        merged = manager.merge_conversation_state(state, {})
        assert merged.current_task == "Keep me"

    def test_none_conversation_state(self, manager):
        state = _make_state(task="Keep me")
        merged = manager.merge_conversation_state(state, None)
        assert merged.current_task == "Keep me"
