"""Integration tests: write-path coverage for SessionStateManager, Dedup, and Export/Import.

Covers modules not tested by the cross-module write integration tests:

- **SessionStateManager**: save, load_latest, load_by_compaction, retention cleanup,
  merge_conversation_state, get_history — validates compaction-resilient state
  persistence and the full save → load → cleanup lifecycle.
- **SessionState data model**: to_dict/from_dict round-trips, format_for_injection
  with token budget enforcement.
- **Dedup**: dedup_observations (merge/window/dry-run/FTS cleanup),
  should_deduplicate (pre-creation duplicate detection),
  increment_observation (count compounding).
- **Export/Import**: KnowledgeExporter.to_json, to_json_file, to_markdown_file,
  KnowledgeImporter.from_json, from_json_file — full roundtrip with duplicate
  detection, domain/type filtering, and multi-entry batch verification.
- **Cross-module**: Session state writes during active sessions, multiple
  compaction tracking, session end preserving state history.

Architecture:
    Most tests use an in-memory SQLite database via the ``db`` fixture.
    Dedup tests use a file-based database (``raw_conn`` fixture) because
    dedup functions operate on raw ``sqlite3.Connection`` objects and need
    the observations + observations_fts tables created by ``init_schema``.

Run with::

    pytest core/tests/test_write_integration_sub3.py -v
"""

import json
import sqlite3
from datetime import datetime, timedelta, timezone

import pytest

from greymatter_core.db import SQLiteDatabase
from greymatter_core.dedup import (
    dedup_observations,
    increment_observation,
    should_deduplicate,
)
from greymatter_core.export import KnowledgeExporter, KnowledgeImporter
from greymatter_core.knowledge import KnowledgeStore
from greymatter_core.session_state import (
    MAX_STATES_PER_SESSION,
    PlanStep,
    SessionState,
    SessionStateManager,
)
from greymatter_core.sessions import SessionManager


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


@pytest.fixture
def db():
    """Shared in-memory database for write integration tests."""
    database = SQLiteDatabase(":memory:")
    yield database
    database.close()


@pytest.fixture
def sessions(db):
    return SessionManager(db)


@pytest.fixture
def knowledge(db):
    return KnowledgeStore(db)


@pytest.fixture
def state_mgr(db):
    return SessionStateManager(db)


@pytest.fixture
def exporter(db):
    return KnowledgeExporter(db)


@pytest.fixture
def importer(db):
    return KnowledgeImporter(db)


@pytest.fixture
def raw_conn(tmp_path):
    """Raw sqlite3.Connection with full schema for dedup tests.

    Uses a file-based DB because dedup operates on raw connections and
    needs the observations + observations_fts tables from init_schema.
    """
    db_path = str(tmp_path / "dedup_test.db")
    database = SQLiteDatabase(db_path)
    database.close()
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    yield conn
    conn.close()


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


def _make_state(
    session_id: str = "sess-test",
    compaction: int = 0,
    task: str = "Test task",
    **overrides,
) -> SessionState:
    """Build a SessionState with sensible defaults."""
    return SessionState(
        session_id=session_id,
        compaction_number=compaction,
        current_task=task,
        **overrides,
    )


def _insert_observation(
    conn: sqlite3.Connection,
    obs_id: str,
    title: str,
    content: str = '{"text": "test"}',
    session_id: str = "sess-1",
    created_at: str | None = None,
) -> None:
    """Insert an observation row for dedup testing."""
    if created_at is None:
        created_at = datetime.now(timezone.utc).isoformat()
    conn.execute(
        """INSERT INTO observations
           (id, session_id, trigger, category, title, content, created_at, updated_at, pruned)
           VALUES (?, ?, 'manual', 'insight', ?, ?, ?, ?, 0)""",
        (obs_id, session_id, title, content, created_at, created_at),
    )
    # Also insert into FTS table
    rowid = conn.execute(
        "SELECT rowid FROM observations WHERE id = ?", (obs_id,)
    ).fetchone()[0]
    conn.execute(
        "INSERT INTO observations_fts(rowid, title, content) VALUES (?, ?, ?)",
        (rowid, title, content),
    )
    conn.commit()


# ---------------------------------------------------------------------------
# 1. SessionStateManager Write Lifecycle
# ---------------------------------------------------------------------------


class TestSessionStateManagerWriteLifecycle:
    """Verify SessionStateManager save, load, and retention writes."""

    def test_save_persists_to_db(self, db, state_mgr, sessions):
        """save() inserts a row into session_states with correct fields."""
        session = sessions.start(project="greymatter")
        state = _make_state(session_id=session["id"], task="Build auth module")

        result = state_mgr.save(state)

        assert result["session_id"] == session["id"]
        assert result["state_id"].startswith("ss-")
        assert result["captured_at"] is not None

        row = db.execute_one(
            "SELECT * FROM session_states WHERE id = ?", (result["state_id"],)
        )
        assert row is not None
        assert row["session_id"] == session["id"]
        assert row["compaction_number"] == 0
        assert row["state_version"] == 1

        # Verify state_data is valid JSON with expected fields
        data = json.loads(row["state_data"])
        assert data["current_task"] == "Build auth module"
        assert data["session_id"] == session["id"]

    def test_save_preserves_all_fields(self, db, state_mgr, sessions):
        """save() round-trips all SessionState fields through JSON."""
        session = sessions.start(project="greymatter")
        state = SessionState(
            session_id=session["id"],
            compaction_number=2,
            current_task="Implement feature X",
            plan_title="Feature X Plan",
            plan_steps=[
                PlanStep("Step 1", "completed", "Done"),
                PlanStep("Step 2", "in_progress"),
                PlanStep("Step 3"),
            ],
            current_step_index=1,
            tasks=[
                {"content": "Write tests", "status": "completed"},
                {"content": "Write code", "status": "in_progress"},
            ],
            decisions=[
                {"decision": "Use SQLite", "rationale": "Local-first"},
            ],
            active_files=[
                {"path": "src/auth.py", "role": "editing"},
            ],
            progress_summary="Auth module scaffolded",
            next_steps="Write integration tests",
            blockers=["Waiting on API spec"],
            custom={"reviewer": "Keith"},
        )

        state_mgr.save(state)
        loaded = state_mgr.load_latest(session["id"])

        assert loaded is not None
        assert loaded.session_id == session["id"]
        assert loaded.compaction_number == 2
        assert loaded.current_task == "Implement feature X"
        assert loaded.plan_title == "Feature X Plan"
        assert len(loaded.plan_steps) == 3
        assert loaded.plan_steps[0].status == "completed"
        assert loaded.plan_steps[1].status == "in_progress"
        assert loaded.plan_steps[2].status == "pending"
        assert loaded.current_step_index == 1
        assert len(loaded.tasks) == 2
        assert loaded.tasks[0]["status"] == "completed"
        assert len(loaded.decisions) == 1
        assert loaded.decisions[0]["decision"] == "Use SQLite"
        assert len(loaded.active_files) == 1
        assert loaded.active_files[0]["path"] == "src/auth.py"
        assert loaded.progress_summary == "Auth module scaffolded"
        assert loaded.next_steps == "Write integration tests"
        assert loaded.blockers == ["Waiting on API spec"]
        assert loaded.custom == {"reviewer": "Keith"}

    def test_load_latest_returns_most_recent(self, state_mgr, sessions):
        """load_latest() returns the state with highest compaction number."""
        session = sessions.start(project="test")

        state_mgr.save(_make_state(session["id"], compaction=0, task="Task 0"))
        state_mgr.save(_make_state(session["id"], compaction=1, task="Task 1"))
        state_mgr.save(_make_state(session["id"], compaction=2, task="Task 2"))

        latest = state_mgr.load_latest(session["id"])
        assert latest is not None
        assert latest.compaction_number == 2
        assert latest.current_task == "Task 2"

    def test_load_latest_nonexistent_returns_none(self, state_mgr):
        """load_latest() returns None for unknown session."""
        assert state_mgr.load_latest("sess-nonexistent") is None

    def test_load_by_compaction_returns_correct_state(self, state_mgr, sessions):
        """load_by_compaction() returns state for the requested compaction number."""
        session = sessions.start(project="test")

        state_mgr.save(_make_state(session["id"], compaction=0, task="First"))
        state_mgr.save(_make_state(session["id"], compaction=1, task="Second"))
        state_mgr.save(_make_state(session["id"], compaction=2, task="Third"))

        loaded = state_mgr.load_by_compaction(session["id"], 1)
        assert loaded is not None
        assert loaded.current_task == "Second"
        assert loaded.compaction_number == 1

    def test_load_by_compaction_nonexistent_returns_none(self, state_mgr, sessions):
        """load_by_compaction() returns None for a compaction that doesn't exist."""
        session = sessions.start(project="test")
        state_mgr.save(_make_state(session["id"], compaction=0, task="Only"))

        assert state_mgr.load_by_compaction(session["id"], 99) is None

    def test_get_history_returns_metadata(self, db, state_mgr, sessions):
        """get_history() returns metadata without full state_data."""
        session = sessions.start(project="test")

        state_mgr.save(_make_state(session["id"], compaction=0))
        state_mgr.save(_make_state(session["id"], compaction=1))

        history = state_mgr.get_history(session["id"])
        assert len(history) == 2
        # Most recent first (DESC order)
        assert history[0]["compaction_number"] == 1
        assert history[1]["compaction_number"] == 0
        assert "id" in history[0]
        assert "captured_at" in history[0]

    def test_retention_cleanup_enforces_limit(self, db, state_mgr, sessions):
        """save() deletes oldest states when exceeding MAX_STATES_PER_SESSION."""
        session = sessions.start(project="test")

        # Save more than the limit
        for i in range(MAX_STATES_PER_SESSION + 3):
            state_mgr.save(_make_state(session["id"], compaction=i, task=f"Task {i}"))

        rows = db.execute(
            "SELECT * FROM session_states WHERE session_id = ?", (session["id"],)
        )
        assert len(rows) == MAX_STATES_PER_SESSION

        # Verify the latest states are kept
        latest = state_mgr.load_latest(session["id"])
        assert latest is not None
        assert latest.compaction_number == MAX_STATES_PER_SESSION + 2

    def test_multiple_sessions_isolated(self, state_mgr, sessions):
        """States for different sessions do not interfere with each other."""
        s1 = sessions.start(project="project-a")
        s2 = sessions.start(project="project-b")

        state_mgr.save(_make_state(s1["id"], compaction=0, task="Session 1 task"))
        state_mgr.save(_make_state(s2["id"], compaction=0, task="Session 2 task"))

        loaded1 = state_mgr.load_latest(s1["id"])
        loaded2 = state_mgr.load_latest(s2["id"])

        assert loaded1.current_task == "Session 1 task"
        assert loaded2.current_task == "Session 2 task"

    def test_merge_conversation_state_fills_missing_fields(self, state_mgr):
        """merge_conversation_state() fills unset fields from conversation state."""
        state = SessionState(session_id="sess-test")
        conv = {
            "current_task": "Fix the bug",
            "next_steps": "Run tests",
            "recent_files": ["src/main.py", "tests/test_main.py"],
        }

        merged = state_mgr.merge_conversation_state(state, conv)

        assert merged.current_task == "Fix the bug"
        assert merged.next_steps == "Run tests"
        assert len(merged.active_files) == 2
        assert merged.active_files[0]["path"] == "src/main.py"
        assert merged.active_files[0]["role"] == "referenced"

    def test_merge_does_not_overwrite_explicit_fields(self, state_mgr):
        """merge_conversation_state() does not overwrite explicitly set fields."""
        state = SessionState(
            session_id="sess-test",
            current_task="Explicit task",
            next_steps="Explicit next",
        )
        conv = {
            "current_task": "From conversation",
            "next_steps": "From conversation",
        }

        merged = state_mgr.merge_conversation_state(state, conv)

        assert merged.current_task == "Explicit task"
        assert merged.next_steps == "Explicit next"

    def test_merge_deduplicates_files(self, state_mgr):
        """merge_conversation_state() does not add duplicate file paths."""
        state = SessionState(
            session_id="sess-test",
            active_files=[{"path": "src/main.py", "role": "editing"}],
        )
        conv = {
            "recent_files": ["src/main.py", "src/utils.py"],
        }

        merged = state_mgr.merge_conversation_state(state, conv)

        paths = [f["path"] for f in merged.active_files]
        assert paths.count("src/main.py") == 1
        assert "src/utils.py" in paths

    def test_merge_empty_conversation_state(self, state_mgr):
        """merge_conversation_state() with empty dict is a no-op."""
        state = SessionState(session_id="sess-test", current_task="Original")
        merged = state_mgr.merge_conversation_state(state, {})
        assert merged.current_task == "Original"

    def test_merge_none_conversation_state(self, state_mgr):
        """merge_conversation_state() with None is a no-op."""
        state = SessionState(session_id="sess-test", current_task="Original")
        merged = state_mgr.merge_conversation_state(state, None)
        assert merged.current_task == "Original"

    def test_save_sets_captured_at_if_missing(self, state_mgr, sessions):
        """save() sets captured_at on the state if it was empty."""
        session = sessions.start(project="test")
        state = SessionState(session_id=session["id"])
        assert state.captured_at == ""

        state_mgr.save(state)

        loaded = state_mgr.load_latest(session["id"])
        assert loaded.captured_at != ""


# ---------------------------------------------------------------------------
# 2. SessionState Data Model
# ---------------------------------------------------------------------------


class TestSessionStateDataModel:
    """Verify SessionState serialization and format_for_injection."""

    def test_roundtrip_to_dict_from_dict(self):
        """to_dict() → from_dict() round-trips all fields."""
        original = SessionState(
            session_id="s1",
            compaction_number=3,
            captured_at="2026-03-25T10:00:00",
            current_task="Task",
            plan_title="Plan",
            plan_steps=[PlanStep("A", "completed"), PlanStep("B", "pending")],
            current_step_index=1,
            blockers=["Blocker 1"],
            custom={"key": "value"},
        )

        data = original.to_dict()
        restored = SessionState.from_dict(data)

        assert restored.session_id == "s1"
        assert restored.compaction_number == 3
        assert restored.plan_title == "Plan"
        assert len(restored.plan_steps) == 2
        assert restored.plan_steps[0].status == "completed"
        assert restored.blockers == ["Blocker 1"]
        assert restored.custom == {"key": "value"}

    def test_from_dict_handles_missing_fields(self):
        """from_dict() handles missing keys gracefully with defaults."""
        state = SessionState.from_dict({"session_id": "s1"})

        assert state.session_id == "s1"
        assert state.compaction_number == 0
        assert state.current_task == ""
        assert state.plan_steps == []
        assert state.blockers == []
        assert state.custom == {}

    def test_format_for_injection_essential_tier(self):
        """format_for_injection() includes essential fields within budget."""
        state = SessionState(
            session_id="s1",
            compaction_number=2,
            current_task="Implement auth",
            next_steps="Write tests",
            blockers=["Missing API spec"],
        )

        output = state.format_for_injection(max_chars=500)

        assert "compaction #2" in output
        assert "Implement auth" in output
        assert "Write tests" in output
        assert "Missing API spec" in output

    def test_format_for_injection_includes_plan(self):
        """format_for_injection() includes plan info when present."""
        state = SessionState(
            session_id="s1",
            compaction_number=1,
            plan_title="Auth Plan",
            plan_steps=[
                PlanStep("Setup", "completed"),
                PlanStep("Implement", "in_progress"),
            ],
            current_step_index=1,
        )

        output = state.format_for_injection(max_chars=2000)
        assert "Auth Plan" in output


# ---------------------------------------------------------------------------
# 3. Dedup Write Operations
# ---------------------------------------------------------------------------


class TestDedupObservationsWriteLifecycle:
    """Verify dedup_observations merges duplicates and cleans up correctly."""

    def test_dedup_merges_same_title_within_window(self, raw_conn):
        """dedup_observations() merges observations with same title within window."""
        now = datetime.now(timezone.utc)

        _insert_observation(raw_conn, "obs-1", "API timeout", created_at=now.isoformat())
        _insert_observation(
            raw_conn, "obs-2", "API timeout",
            created_at=(now + timedelta(minutes=2)).isoformat(),
        )
        _insert_observation(
            raw_conn, "obs-3", "API timeout",
            created_at=(now + timedelta(minutes=4)).isoformat(),
        )

        deleted = dedup_observations(raw_conn, window_minutes=5)
        assert deleted == 2

        # Keeper (earliest) still exists with dedup_count
        row = raw_conn.execute(
            "SELECT content FROM observations WHERE id = ?", ("obs-1",)
        ).fetchone()
        assert row is not None
        content = json.loads(row[0])
        assert content["dedup_count"] == 3
        assert set(content["dedup_merged_ids"]) == {"obs-2", "obs-3"}

        # Duplicates removed
        assert raw_conn.execute(
            "SELECT id FROM observations WHERE id = ?", ("obs-2",)
        ).fetchone() is None
        assert raw_conn.execute(
            "SELECT id FROM observations WHERE id = ?", ("obs-3",)
        ).fetchone() is None

    def test_dedup_does_not_merge_different_titles(self, raw_conn):
        """dedup_observations() keeps observations with different titles separate."""
        now = datetime.now(timezone.utc)

        _insert_observation(raw_conn, "obs-1", "Auth error", created_at=now.isoformat())
        _insert_observation(raw_conn, "obs-2", "DB timeout", created_at=now.isoformat())

        deleted = dedup_observations(raw_conn, window_minutes=5)
        assert deleted == 0

        count = raw_conn.execute("SELECT COUNT(*) FROM observations").fetchone()[0]
        assert count == 2

    def test_dedup_does_not_merge_outside_window(self, raw_conn):
        """dedup_observations() keeps same-title observations outside time window."""
        now = datetime.now(timezone.utc)

        _insert_observation(raw_conn, "obs-1", "Retry storm", created_at=now.isoformat())
        _insert_observation(
            raw_conn, "obs-2", "Retry storm",
            created_at=(now + timedelta(minutes=10)).isoformat(),
        )

        deleted = dedup_observations(raw_conn, window_minutes=5)
        assert deleted == 0

    def test_dedup_dry_run_does_not_modify(self, raw_conn):
        """dedup_observations(dry_run=True) reports count but does not delete."""
        now = datetime.now(timezone.utc)

        _insert_observation(raw_conn, "obs-1", "Same", created_at=now.isoformat())
        _insert_observation(
            raw_conn, "obs-2", "Same",
            created_at=(now + timedelta(minutes=1)).isoformat(),
        )

        deleted = dedup_observations(raw_conn, window_minutes=5, dry_run=True)
        assert deleted == 1

        # Both rows still exist
        count = raw_conn.execute("SELECT COUNT(*) FROM observations").fetchone()[0]
        assert count == 2

    def test_dedup_removes_fts_entries_for_duplicates(self, raw_conn):
        """dedup_observations() also removes FTS entries for deleted duplicates."""
        now = datetime.now(timezone.utc)

        _insert_observation(raw_conn, "obs-1", "FTS test", created_at=now.isoformat())
        _insert_observation(
            raw_conn, "obs-2", "FTS test",
            created_at=(now + timedelta(minutes=1)).isoformat(),
        )

        # Verify FTS entries exist before dedup
        fts_before = raw_conn.execute(
            "SELECT COUNT(*) FROM observations_fts WHERE title MATCH 'FTS'"
        ).fetchone()[0]
        assert fts_before >= 2

        dedup_observations(raw_conn, window_minutes=5)

        # Only keeper's FTS entry should remain
        fts_after = raw_conn.execute(
            "SELECT COUNT(*) FROM observations_fts WHERE title MATCH 'FTS'"
        ).fetchone()[0]
        assert fts_after == 1

    def test_dedup_empty_table_returns_zero(self, raw_conn):
        """dedup_observations() on empty table returns 0."""
        deleted = dedup_observations(raw_conn, window_minutes=5)
        assert deleted == 0

    def test_dedup_single_observation_returns_zero(self, raw_conn):
        """dedup_observations() with one observation returns 0."""
        _insert_observation(raw_conn, "obs-1", "Solo")
        deleted = dedup_observations(raw_conn, window_minutes=5)
        assert deleted == 0


class TestShouldDeduplicateWrite:
    """Verify should_deduplicate() detects recent duplicates."""

    def test_returns_id_for_recent_duplicate(self, raw_conn):
        """should_deduplicate() returns existing ID when recent duplicate found."""
        _insert_observation(raw_conn, "obs-existing", "Rate limit hit")

        result = should_deduplicate(raw_conn, "Rate limit hit", window_minutes=5)
        assert result == "obs-existing"

    def test_returns_none_for_no_match(self, raw_conn):
        """should_deduplicate() returns None when no matching title found."""
        _insert_observation(raw_conn, "obs-1", "Different title")

        result = should_deduplicate(raw_conn, "No match", window_minutes=5)
        assert result is None

    def test_returns_none_for_old_observation(self, raw_conn):
        """should_deduplicate() returns None when match is outside time window."""
        old_time = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat()
        _insert_observation(raw_conn, "obs-old", "Old observation", created_at=old_time)

        result = should_deduplicate(raw_conn, "Old observation", window_minutes=5)
        assert result is None


class TestIncrementObservationWrite:
    """Verify increment_observation() updates dedup count correctly."""

    def test_increment_bumps_count(self, raw_conn):
        """increment_observation() increases dedup_count by 1."""
        _insert_observation(raw_conn, "obs-inc", "Incrementable")

        increment_observation(raw_conn, "obs-inc", '{"text": "new data"}')

        row = raw_conn.execute(
            "SELECT content FROM observations WHERE id = ?", ("obs-inc",)
        ).fetchone()
        content = json.loads(row[0])
        assert content["dedup_count"] == 2  # 1 (implicit first) + 1

    def test_increment_preserves_latest_content(self, raw_conn):
        """increment_observation() keeps the latest content payload."""
        _insert_observation(raw_conn, "obs-fresh", "Fresh obs")

        increment_observation(
            raw_conn, "obs-fresh",
            '{"text": "updated payload", "severity": "high"}',
        )

        row = raw_conn.execute(
            "SELECT content FROM observations WHERE id = ?", ("obs-fresh",)
        ).fetchone()
        content = json.loads(row[0])
        assert content["text"] == "updated payload"
        assert content["severity"] == "high"
        assert content["dedup_count"] == 2

    def test_increment_multiple_times_compounds(self, raw_conn):
        """Multiple increment_observation() calls compound the count."""
        _insert_observation(raw_conn, "obs-multi", "Multi")

        for _ in range(5):
            increment_observation(raw_conn, "obs-multi", '{"text": "latest"}')

        row = raw_conn.execute(
            "SELECT content FROM observations WHERE id = ?", ("obs-multi",)
        ).fetchone()
        content = json.loads(row[0])
        assert content["dedup_count"] == 6  # 1 original + 5 increments

    def test_increment_nonexistent_is_noop(self, raw_conn):
        """increment_observation() on nonexistent ID does not raise."""
        # Should not raise
        increment_observation(raw_conn, "obs-ghost", '{"text": "ghost"}')


# ---------------------------------------------------------------------------
# 4. Export/Import Write Lifecycle
# ---------------------------------------------------------------------------


class TestExportImportWriteLifecycle:
    """Verify KnowledgeExporter/KnowledgeImporter roundtrip consistency."""

    def test_export_to_json_contains_entries(self, knowledge, exporter):
        """to_json() includes created knowledge entries."""
        knowledge.create(
            entry_type="expertise", title="SQLite WAL",
            content="WAL enables concurrent reads", domain="database",
            tags="sqlite,wal",
        )
        knowledge.create(
            entry_type="gotcha", title="FTS5 Tokenizer",
            content="FTS5 uses unicode61 by default", domain="database",
        )

        data = exporter.to_json()

        assert data["format"] == "greymatter-export"
        assert data["version"] == "1.0"
        assert data["counts"]["knowledge_entries"] == 2
        titles = [e["title"] for e in data["knowledge_entries"]]
        assert "SQLite WAL" in titles
        assert "FTS5 Tokenizer" in titles

    def test_export_excludes_pruned_entries(self, knowledge, exporter):
        """to_json() excludes soft-deleted (pruned) entries."""
        entry = knowledge.create(
            entry_type="expertise", title="Pruned Entry",
            content="Should not appear in export",
        )
        knowledge.prune(entry["id"])

        data = exporter.to_json()
        titles = [e["title"] for e in data["knowledge_entries"]]
        assert "Pruned Entry" not in titles

    def test_export_filters_by_domain(self, knowledge, exporter):
        """to_json(domain=...) filters to specified domain."""
        knowledge.create(
            entry_type="expertise", title="Python Entry",
            content="Python-specific", domain="python",
        )
        knowledge.create(
            entry_type="expertise", title="Rust Entry",
            content="Rust-specific", domain="rust",
        )

        data = exporter.to_json(domain="python")
        assert data["counts"]["knowledge_entries"] == 1
        assert data["knowledge_entries"][0]["title"] == "Python Entry"

    def test_export_filters_by_entry_type(self, knowledge, exporter):
        """to_json(entry_type=...) filters to specified type."""
        knowledge.create(
            entry_type="expertise", title="Expertise",
            content="Expert knowledge", domain="test",
        )
        knowledge.create(
            entry_type="gotcha", title="Gotcha",
            content="Watch out", domain="test",
        )

        data = exporter.to_json(entry_type="gotcha")
        assert data["counts"]["knowledge_entries"] == 1
        assert data["knowledge_entries"][0]["title"] == "Gotcha"

    def test_import_creates_entries_in_db(self, db, knowledge, exporter, importer):
        """from_json() creates entries in the database from exported data."""
        # Create and export
        knowledge.create(
            entry_type="expertise", title="Import Test",
            content="Should be importable", domain="testing",
            scope="project", tags="import,test",
        )
        exported = exporter.to_json()

        # Create a fresh DB for import
        import_db = SQLiteDatabase(":memory:")
        try:
            import_importer = KnowledgeImporter(import_db)
            result = import_importer.from_json(exported)

            assert result["imported"] == 1
            assert result["skipped"] == 0
            assert result["errors"] == 0

            # Verify in DB
            rows = import_db.execute(
                "SELECT * FROM knowledge_entries WHERE title = ?", ("Import Test",)
            )
            assert len(rows) == 1
            assert rows[0]["content"] == "Should be importable"
            assert rows[0]["domain"] == "testing"
        finally:
            import_db.close()

    def test_import_skips_duplicates(self, knowledge, exporter, importer):
        """from_json() skips entries that already exist (by title + entry_type)."""
        knowledge.create(
            entry_type="expertise", title="Already Exists",
            content="Original content", domain="testing",
        )
        exported = exporter.to_json()

        # Import into same DB — should skip
        result = importer.from_json(exported)
        assert result["skipped"] == 1
        assert result["imported"] == 0

    def test_import_rejects_invalid_format(self, importer):
        """from_json() rejects data without greymatter-export format marker."""
        result = importer.from_json({"format": "wrong", "knowledge_entries": []})
        assert "error" in result

    def test_import_handles_empty_entries(self, importer):
        """from_json() with empty knowledge_entries succeeds with zero imported."""
        result = importer.from_json({
            "format": "greymatter-export",
            "knowledge_entries": [],
        })
        assert result["imported"] == 0
        assert result["skipped"] == 0
        assert result["errors"] == 0

    def test_export_to_json_file_roundtrip(self, tmp_path, knowledge, exporter):
        """to_json_file() writes valid JSON that can be re-read."""
        knowledge.create(
            entry_type="expertise", title="File Export",
            content="Should persist to file", domain="testing",
        )

        path = str(tmp_path / "export.json")
        counts = exporter.to_json_file(path)
        assert counts["knowledge_entries"] == 1

        with open(path) as f:
            data = json.load(f)
        assert data["format"] == "greymatter-export"
        assert len(data["knowledge_entries"]) == 1

    def test_import_from_json_file_roundtrip(self, tmp_path, knowledge, exporter):
        """to_json_file() → from_json_file() full roundtrip."""
        knowledge.create(
            entry_type="gotcha", title="File Roundtrip",
            content="Must survive file roundtrip", domain="testing",
        )

        path = str(tmp_path / "roundtrip.json")
        exporter.to_json_file(path)

        # Import into fresh DB
        import_db = SQLiteDatabase(":memory:")
        try:
            import_importer = KnowledgeImporter(import_db)
            result = import_importer.from_json_file(path)

            assert result["imported"] == 1
            assert result["errors"] == 0

            rows = import_db.execute(
                "SELECT * FROM knowledge_entries WHERE title = ?", ("File Roundtrip",)
            )
            assert len(rows) == 1
            assert rows[0]["content"] == "Must survive file roundtrip"
        finally:
            import_db.close()

    def test_export_to_markdown_file(self, tmp_path, knowledge, exporter):
        """to_markdown_file() writes readable Markdown with all entries."""
        knowledge.create(
            entry_type="expertise", title="Markdown Export",
            content="Should appear in markdown", domain="testing",
            tags="test",
        )

        path = str(tmp_path / "export.md")
        count = exporter.to_markdown_file(path)
        assert count >= 1

        with open(path) as f:
            content = f.read()
        assert "# GreyMatter Knowledge Export" in content
        assert "Markdown Export" in content
        assert "Should appear in markdown" in content
        assert "testing" in content

    def test_export_multiple_domains_grouped(self, knowledge, exporter):
        """to_markdown() groups entries by domain."""
        knowledge.create(
            entry_type="expertise", title="Python Tip",
            content="Use asyncio", domain="python",
        )
        knowledge.create(
            entry_type="expertise", title="Rust Tip",
            content="Use ownership", domain="rust",
        )

        md = exporter.to_markdown()
        # Both domains present
        assert "## python" in md
        assert "## rust" in md

    def test_import_multiple_entries_counts(self, knowledge, exporter):
        """from_json() correctly counts multiple imported entries."""
        for i in range(5):
            knowledge.create(
                entry_type="expertise", title=f"Batch {i}",
                content=f"Content {i}", domain="testing",
            )
        exported = exporter.to_json()

        import_db = SQLiteDatabase(":memory:")
        try:
            import_importer = KnowledgeImporter(import_db)
            result = import_importer.from_json(exported)
            assert result["imported"] == 5
            assert result["total"] == 5
        finally:
            import_db.close()


# ---------------------------------------------------------------------------
# 5. Cross-Module: Session State + Session Lifecycle
# ---------------------------------------------------------------------------


class TestSessionStateSessionLifecycleIntegration:
    """Verify session state writes integrate with session lifecycle."""

    def test_save_state_during_active_session(self, db, state_mgr, sessions):
        """Session state can be saved during an active session."""
        session = sessions.start(project="greymatter")
        state = SessionState(
            session_id=session["id"],
            compaction_number=0,
            current_task="Working on feature",
            active_work_item={"id": "wi-test", "title": "Feature"},
        )

        result = state_mgr.save(state)
        assert result["state_id"].startswith("ss-")

        # Session still active
        s_row = db.execute_one("SELECT * FROM sessions WHERE id = ?", (session["id"],))
        assert s_row["ended_at"] is None

        # State persisted
        loaded = state_mgr.load_latest(session["id"])
        assert loaded.current_task == "Working on feature"
        assert loaded.active_work_item["id"] == "wi-test"

    def test_multiple_compactions_tracked(self, db, state_mgr, sessions):
        """Multiple compaction states accumulate for one session."""
        session = sessions.start(project="greymatter")

        for i in range(3):
            state = SessionState(
                session_id=session["id"],
                compaction_number=i,
                current_task=f"After compaction {i}",
                progress_summary=f"Completed phase {i}",
            )
            state_mgr.save(state)

        history = state_mgr.get_history(session["id"])
        assert len(history) == 3

        # Can load any specific compaction
        for i in range(3):
            loaded = state_mgr.load_by_compaction(session["id"], i)
            assert loaded.current_task == f"After compaction {i}"

    def test_session_end_preserves_states(self, db, state_mgr, sessions):
        """Ending a session does not delete its states."""
        session = sessions.start(project="greymatter")
        state_mgr.save(_make_state(session["id"], compaction=0, task="Before end"))

        sessions.end(session["id"], summary="Done")

        # State still accessible
        loaded = state_mgr.load_latest(session["id"])
        assert loaded is not None
        assert loaded.current_task == "Before end"
