"""Integration tests: write-path consistency for uncovered core modules.

Validates write operations across core modules that lack integration test
coverage. Covers database mutation correctness, transaction safety,
retention policies, and cross-module write interactions for:

- Dedup: observation deduplication with FTS cleanup and transaction rollback
- SessionStateManager: structured state persistence, retention, recovery
- KnowledgeExporter/Importer: round-trip export, duplicate detection, import
- ObservationCategorizer: batch domain tagging with confidence filtering

Each test gets a fresh in-memory database with full CORE_SCHEMA to verify
writes against raw SQL — no caching layer in the way.
"""

import json
import sqlite3
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock

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.sessions import SessionManager
from greymatter_core.session_state import (
    MAX_STATES_PER_SESSION,
    PlanStep,
    SessionState,
    SessionStateManager,
)


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


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


@pytest.fixture
def raw_conn(db):
    """Raw sqlite3 connection for modules that operate on raw connections."""
    return db._conn


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


@pytest.fixture
def sessions(db):
    return SessionManager(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)


def _insert_observation(
    conn: sqlite3.Connection,
    obs_id: str,
    title: str,
    content: str,
    session_id: str = "sess-test",
    created_at: str | None = None,
    category: str = "insight",
    domains: str = "",
) -> None:
    """Insert an observation row directly 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, domains, created_at, updated_at)
           VALUES (?, ?, 'manual', ?, ?, ?, ?, ?, ?)""",
        (obs_id, session_id, category, title, content, domains, created_at, created_at),
    )
    # Also insert into FTS index
    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. Dedup Write Lifecycle
# ---------------------------------------------------------------------------


class TestDedupWriteLifecycle:
    """Verify dedup_observations transaction safety, FTS cleanup, and counts."""

    def test_no_duplicates_returns_zero(self, raw_conn):
        """dedup_observations returns 0 when no duplicates exist."""
        now = datetime.now(timezone.utc).isoformat()
        _insert_observation(raw_conn, "obs-1", "Title A", "Content A", created_at=now)
        _insert_observation(raw_conn, "obs-2", "Title B", "Content B", created_at=now)

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

    def test_duplicates_merged_to_earliest(self, raw_conn):
        """Duplicate observations within window are merged; earliest is kept."""
        now = datetime.now(timezone.utc)
        t1 = now.isoformat()
        t2 = (now + timedelta(seconds=30)).isoformat()
        t3 = (now + timedelta(seconds=60)).isoformat()

        _insert_observation(raw_conn, "obs-first", "Same Title", "First", created_at=t1)
        _insert_observation(raw_conn, "obs-second", "Same Title", "Second", created_at=t2)
        _insert_observation(raw_conn, "obs-third", "Same Title", "Third", created_at=t3)

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

        # Earliest kept
        keeper = raw_conn.execute(
            "SELECT * FROM observations WHERE id = 'obs-first'"
        ).fetchone()
        assert keeper is not None

        # Duplicates removed
        for dup_id in ("obs-second", "obs-third"):
            row = raw_conn.execute(
                "SELECT * FROM observations WHERE id = ?", (dup_id,)
            ).fetchone()
            assert row is None

    def test_keeper_content_annotated_with_dedup_count(self, raw_conn):
        """Keeper's content is annotated with dedup_count and merged IDs."""
        now = datetime.now(timezone.utc)
        t1 = now.isoformat()
        t2 = (now + timedelta(seconds=10)).isoformat()

        _insert_observation(raw_conn, "obs-keep", "Dup Title", "Original", created_at=t1)
        _insert_observation(raw_conn, "obs-dup", "Dup Title", "Duplicate", created_at=t2)

        dedup_observations(raw_conn, window_minutes=5)

        row = raw_conn.execute(
            "SELECT content FROM observations WHERE id = 'obs-keep'"
        ).fetchone()
        content = json.loads(row[0])
        assert content["dedup_count"] == 2
        assert "obs-dup" in content["dedup_merged_ids"]

    def test_fts_entries_cleaned_for_duplicates(self, raw_conn):
        """FTS entries for deleted duplicates are removed."""
        now = datetime.now(timezone.utc)
        t1 = now.isoformat()
        t2 = (now + timedelta(seconds=10)).isoformat()

        _insert_observation(raw_conn, "obs-a", "FTS Test", "Alpha", created_at=t1)
        _insert_observation(raw_conn, "obs-b", "FTS Test", "Beta", created_at=t2)

        # Get duplicate's rowid before dedup
        dup_rowid = raw_conn.execute(
            "SELECT rowid FROM observations WHERE id = 'obs-b'"
        ).fetchone()
        assert dup_rowid is not None

        dedup_observations(raw_conn, window_minutes=5)

        # FTS for deleted row should be gone
        remaining_fts = raw_conn.execute(
            "SELECT COUNT(*) FROM observations_fts WHERE observations_fts MATCH 'Beta'"
        ).fetchone()[0]
        assert remaining_fts == 0

        # FTS for keeper should remain
        keeper_fts = raw_conn.execute(
            "SELECT COUNT(*) FROM observations_fts WHERE observations_fts MATCH 'Alpha'"
        ).fetchone()[0]
        assert keeper_fts == 1

    def test_dry_run_does_not_modify_database(self, raw_conn):
        """dry_run=True computes count but makes no changes."""
        now = datetime.now(timezone.utc)
        t1 = now.isoformat()
        t2 = (now + timedelta(seconds=10)).isoformat()

        _insert_observation(raw_conn, "obs-dry-1", "Dry Title", "A", created_at=t1)
        _insert_observation(raw_conn, "obs-dry-2", "Dry Title", "B", created_at=t2)

        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_different_titles_not_merged(self, raw_conn):
        """Observations with different titles are never merged."""
        now = datetime.now(timezone.utc).isoformat()
        _insert_observation(raw_conn, "obs-x", "Title X", "Content X", created_at=now)
        _insert_observation(raw_conn, "obs-y", "Title Y", "Content Y", created_at=now)

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

    def test_outside_window_not_merged(self, raw_conn):
        """Same-title observations outside the time window are not merged."""
        now = datetime.now(timezone.utc)
        t1 = now.isoformat()
        t2 = (now + timedelta(minutes=10)).isoformat()

        _insert_observation(raw_conn, "obs-w1", "Window Test", "Early", created_at=t1)
        _insert_observation(raw_conn, "obs-w2", "Window Test", "Late", created_at=t2)

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

    def test_multiple_groups_merged_independently(self, raw_conn):
        """Multiple duplicate groups are each merged independently."""
        now = datetime.now(timezone.utc)
        t1 = now.isoformat()
        t2 = (now + timedelta(seconds=10)).isoformat()

        # Group 1: "Alpha" duplicates
        _insert_observation(raw_conn, "obs-a1", "Alpha", "A1", created_at=t1)
        _insert_observation(raw_conn, "obs-a2", "Alpha", "A2", created_at=t2)

        # Group 2: "Beta" duplicates
        _insert_observation(raw_conn, "obs-b1", "Beta", "B1", created_at=t1)
        _insert_observation(raw_conn, "obs-b2", "Beta", "B2", created_at=t2)

        deleted = dedup_observations(raw_conn, window_minutes=5)
        assert deleted == 2  # One dup from each group

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


class TestShouldDeduplicateCheck:
    """Verify the pre-insert dedup check."""

    def test_returns_existing_id_within_window(self, raw_conn):
        """should_deduplicate returns the existing observation's ID."""
        now = datetime.now(timezone.utc).isoformat()
        _insert_observation(raw_conn, "obs-existing", "Repeated Title", "Content", created_at=now)

        result = should_deduplicate(raw_conn, "Repeated Title", window_minutes=5)
        assert result == "obs-existing"

    def test_returns_none_for_new_title(self, raw_conn):
        """should_deduplicate returns None for a title not in the DB."""
        result = should_deduplicate(raw_conn, "Brand New Title", window_minutes=5)
        assert result is None

    def test_returns_none_for_old_observation(self, raw_conn):
        """should_deduplicate returns None for observations outside the window."""
        old_time = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat()
        _insert_observation(raw_conn, "obs-old", "Old Title", "Content", created_at=old_time)

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


class TestIncrementObservation:
    """Verify increment_observation write behavior."""

    def test_increment_bumps_dedup_count(self, raw_conn):
        """increment_observation increases the dedup_count field."""
        now = datetime.now(timezone.utc).isoformat()
        _insert_observation(
            raw_conn, "obs-inc", "Inc Title", '{"data": "original"}', created_at=now,
        )

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

        row = raw_conn.execute(
            "SELECT content FROM observations WHERE id = 'obs-inc'"
        ).fetchone()
        content = json.loads(row[0])
        assert content["dedup_count"] == 2
        assert content["data"] == "updated"

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

    def test_double_increment_accumulates(self, raw_conn):
        """Multiple increments accumulate the dedup_count."""
        now = datetime.now(timezone.utc).isoformat()
        _insert_observation(raw_conn, "obs-multi", "Multi", '{"val": 1}', created_at=now)

        increment_observation(raw_conn, "obs-multi", '{"val": 2}')
        increment_observation(raw_conn, "obs-multi", '{"val": 3}')
        increment_observation(raw_conn, "obs-multi", '{"val": 4}')

        row = raw_conn.execute(
            "SELECT content FROM observations WHERE id = 'obs-multi'"
        ).fetchone()
        content = json.loads(row[0])
        assert content["dedup_count"] == 4
        assert content["val"] == 4


# ---------------------------------------------------------------------------
# 2. SessionState Write Lifecycle
# ---------------------------------------------------------------------------


class TestSessionStateWriteLifecycle:
    """Verify SessionStateManager persistence, retrieval, and retention."""

    def test_save_persists_full_state(self, db, sessions, state_mgr):
        """save() writes all SessionState fields to the database."""
        session = sessions.start(project="greymatter")

        state = SessionState(
            session_id=session["id"],
            compaction_number=1,
            current_task="Building auth module",
            plan_title="Auth Implementation",
            plan_steps=[
                PlanStep(description="Design schema", status="completed"),
                PlanStep(description="Write middleware", status="in_progress"),
                PlanStep(description="Add tests", status="pending"),
            ],
            current_step_index=1,
            tasks=[
                {"content": "Create JWT utility", "status": "completed"},
                {"content": "Write token validator", "status": "in_progress"},
            ],
            decisions=[
                {"decision": "Use HS256 over RS256", "rationale": "Simpler for solo use"},
            ],
            active_files=[
                {"path": "core/auth.py", "role": "editing"},
                {"path": "core/tests/test_auth.py", "role": "testing"},
            ],
            progress_summary="Schema done, middleware in progress",
            next_steps="Complete middleware, then tests",
            blockers=["Need Pydantic v2 model_validator docs"],
            custom={"iteration": 2},
        )

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

        # Verify via raw SQL
        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"] == 1
        assert row["state_version"] == 1

        stored = json.loads(row["state_data"])
        assert stored["current_task"] == "Building auth module"
        assert stored["plan_title"] == "Auth Implementation"
        assert len(stored["plan_steps"]) == 3
        assert stored["plan_steps"][1]["status"] == "in_progress"
        assert len(stored["decisions"]) == 1
        assert stored["blockers"] == ["Need Pydantic v2 model_validator docs"]

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

        for compaction in range(3):
            state = SessionState(
                session_id=session["id"],
                compaction_number=compaction,
                current_task=f"Task at compaction {compaction}",
            )
            state_mgr.save(state)

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

    def test_load_by_compaction_returns_specific(self, sessions, state_mgr):
        """load_by_compaction() returns state for a specific compaction number."""
        session = sessions.start(project="greymatter")

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

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

    def test_load_nonexistent_session_returns_none(self, state_mgr):
        """load_latest() returns None for a session with no states."""
        assert state_mgr.load_latest("sess-nonexistent") is None

    def test_retention_enforces_max_states(self, sessions, state_mgr):
        """Saving more than MAX_STATES_PER_SESSION prunes oldest states."""
        session = sessions.start(project="greymatter")

        for compaction in range(MAX_STATES_PER_SESSION + 3):
            state = SessionState(
                session_id=session["id"],
                compaction_number=compaction,
                current_task=f"Task {compaction}",
            )
            state_mgr.save(state)

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

        # Latest states should be kept
        latest = state_mgr.load_latest(session["id"])
        assert latest.compaction_number == MAX_STATES_PER_SESSION + 2

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

        for compaction in range(3):
            state = SessionState(
                session_id=session["id"],
                compaction_number=compaction,
            )
            state_mgr.save(state)

        history = state_mgr.get_history(session["id"])
        assert len(history) == 3
        for entry in history:
            assert "id" in entry
            assert "compaction_number" in entry
            assert "state_version" in entry
            assert "captured_at" in entry
            # state_data should NOT be in history metadata
            assert "state_data" not in entry

    def test_plan_step_round_trip(self, sessions, state_mgr):
        """PlanStep objects survive save/load round-trip."""
        session = sessions.start(project="greymatter")

        state = SessionState(
            session_id=session["id"],
            compaction_number=0,
            plan_steps=[
                PlanStep("Step 1", "completed", "Done well"),
                PlanStep("Step 2", "in_progress", "Working on it"),
                PlanStep("Step 3", "pending", ""),
            ],
            current_step_index=1,
        )
        state_mgr.save(state)

        loaded = state_mgr.load_latest(session["id"])
        assert loaded is not None
        assert len(loaded.plan_steps) == 3
        assert loaded.plan_steps[0].description == "Step 1"
        assert loaded.plan_steps[0].status == "completed"
        assert loaded.plan_steps[0].notes == "Done well"
        assert loaded.plan_steps[1].status == "in_progress"
        assert loaded.current_step_index == 1

    def test_merge_conversation_state(self, state_mgr):
        """merge_conversation_state fills empty fields from conversation state."""
        state = SessionState(
            session_id="sess-merge",
            compaction_number=0,
            active_files=[{"path": "existing.py", "role": "editing"}],
        )

        conversation = {
            "current_task": "Implement retry logic",
            "next_steps": "Add exponential backoff",
            "recent_files": ["existing.py", "retry.py", "tests.py"],
        }

        merged = state_mgr.merge_conversation_state(state, conversation)
        assert merged.current_task == "Implement retry logic"
        assert merged.next_steps == "Add exponential backoff"
        # existing.py should not be duplicated
        paths = [f["path"] for f in merged.active_files]
        assert paths.count("existing.py") == 1
        assert "retry.py" in paths
        assert "tests.py" in paths

    def test_merge_does_not_overwrite_explicit_fields(self, state_mgr):
        """merge_conversation_state does not overwrite fields already set."""
        state = SessionState(
            session_id="sess-merge",
            compaction_number=0,
            current_task="Explicitly set task",
            next_steps="Explicitly set next steps",
        )

        conversation = {
            "current_task": "Different task from conversation",
            "next_steps": "Different next steps",
        }

        merged = state_mgr.merge_conversation_state(state, conversation)
        assert merged.current_task == "Explicitly set task"
        assert merged.next_steps == "Explicitly set next steps"

    def test_format_for_injection_tiered(self):
        """format_for_injection includes essential fields and respects budget."""
        state = SessionState(
            session_id="sess-inject",
            compaction_number=2,
            current_task="Build the query optimizer",
            plan_title="Query Optimizer",
            plan_steps=[
                PlanStep("Parse SQL", "completed"),
                PlanStep("Build AST", "in_progress"),
                PlanStep("Optimize joins", "pending"),
            ],
            current_step_index=1,
            blockers=["Need index statistics"],
            next_steps="Finish AST builder",
            progress_summary="SQL parser done, AST half-built",
        )

        # Essential tier (small budget)
        essential = state.format_for_injection(max_chars=500)
        assert "compaction #2" in essential
        assert "Build the query optimizer" in essential

        # Full tier (large budget)
        full = state.format_for_injection(max_chars=10000)
        assert "Build the query optimizer" in full
        assert "index statistics" in full

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

        state_mgr.save(SessionState(
            session_id=s1["id"], compaction_number=0, current_task="Task A",
        ))
        state_mgr.save(SessionState(
            session_id=s2["id"], compaction_number=0, current_task="Task B",
        ))

        loaded_a = state_mgr.load_latest(s1["id"])
        loaded_b = state_mgr.load_latest(s2["id"])

        assert loaded_a.current_task == "Task A"
        assert loaded_b.current_task == "Task B"

        history_a = state_mgr.get_history(s1["id"])
        history_b = state_mgr.get_history(s2["id"])
        assert len(history_a) == 1
        assert len(history_b) == 1


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


class TestExportImportWriteLifecycle:
    """Verify export round-trip, import duplicate detection, and fallback."""

    def test_export_json_contains_all_entries(self, knowledge, exporter):
        """to_json() exports all non-pruned entries."""
        knowledge.create(
            entry_type="expertise", title="WAL Mode",
            content="WAL enables concurrent reads", domain="database",
        )
        knowledge.create(
            entry_type="gotcha", title="PRAGMA Ordering",
            content="Set PRAGMA before writes", domain="database",
        )

        data = exporter.to_json(domain="database")
        assert data["format"] == "greymatter-export"
        assert data["version"] == "1.0"
        assert data["counts"]["knowledge_entries"] == 2
        assert len(data["knowledge_entries"]) == 2

    def test_export_excludes_pruned(self, knowledge, exporter):
        """to_json() excludes pruned entries."""
        knowledge.create(
            entry_type="expertise", title="Active",
            content="Still relevant", domain="testing",
        )
        pruned_entry = knowledge.create(
            entry_type="gotcha", title="Pruned",
            content="No longer relevant", domain="testing",
        )
        knowledge.prune(pruned_entry["id"])

        data = exporter.to_json(domain="testing")
        assert data["counts"]["knowledge_entries"] == 1
        exported_titles = [e["title"] for e in data["knowledge_entries"]]
        assert "Active" in exported_titles
        assert "Pruned" not in exported_titles

    def test_export_filters_by_domain(self, knowledge, exporter):
        """to_json() filters by domain when specified."""
        knowledge.create(
            entry_type="expertise", title="DB Stuff",
            content="Database content", domain="database",
        )
        knowledge.create(
            entry_type="expertise", title="API Stuff",
            content="API content", domain="api",
        )

        data = exporter.to_json(domain="database")
        assert data["counts"]["knowledge_entries"] == 1
        assert data["knowledge_entries"][0]["title"] == "DB Stuff"

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

        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(self, knowledge, exporter):
        """from_json() creates entries from exported data."""
        knowledge.create(
            entry_type="expertise", title="Original Entry",
            content="Content to export", domain="database",
            tags="sqlite",
        )
        data = exporter.to_json()

        # Create a fresh database for import
        db2 = SQLiteDatabase(":memory:")
        importer2 = KnowledgeImporter(db2)

        result = importer2.from_json(data)
        assert result["imported"] == 1
        assert result["skipped"] == 0
        assert result["errors"] == 0

        imported_row = db2.execute_one(
            "SELECT * FROM knowledge_entries WHERE title = 'Original Entry'"
        )
        assert imported_row is not None
        assert imported_row["content"] == "Content to export"
        assert imported_row["domain"] == "database"
        db2.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="Existing Entry",
            content="Already in DB", domain="database",
        )
        data = exporter.to_json()

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

    def test_import_invalid_format_returns_error(self, importer):
        """from_json() rejects data with wrong format field."""
        result = importer.from_json({"format": "wrong-format"})
        assert "error" in result

    def test_import_empty_payload(self, importer):
        """from_json() handles an export with no entries gracefully."""
        data = {
            "format": "greymatter-export",
            "knowledge_entries": [],
        }
        result = importer.from_json(data)
        assert result["imported"] == 0
        assert result["skipped"] == 0
        assert result["errors"] == 0

    def test_round_trip_preserves_fields(self, knowledge, exporter):
        """Export preserves all key fields accurately."""
        knowledge.create(
            entry_type="gotcha", title="WAL Gotcha",
            content="PRAGMA journal_mode must be set before any writes",
            domain="database", scope="project",
            valence="negative", tags="sqlite,wal",
        )

        data = exporter.to_json()
        exported = data["knowledge_entries"][0]

        assert exported["entry_type"] == "gotcha"
        assert exported["title"] == "WAL Gotcha"
        assert exported["content"] == "PRAGMA journal_mode must be set before any writes"
        assert exported["domain"] == "database"
        assert exported["scope"] == "project"
        assert exported["valence"] == "negative"
        assert exported["tags"] == "sqlite,wal"

    def test_export_to_file_and_import(self, knowledge, exporter, tmp_path):
        """Round-trip through a JSON file works correctly."""
        knowledge.create(
            entry_type="expertise", title="File Export Test",
            content="Exported to file", domain="testing",
        )

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

        # Import from file into fresh DB
        db2 = SQLiteDatabase(":memory:")
        importer2 = KnowledgeImporter(db2)
        result = importer2.from_json_file(export_path)
        assert result["imported"] == 1
        db2.close()

    def test_markdown_export_formatting(self, knowledge, exporter):
        """to_markdown() produces well-formatted markdown."""
        knowledge.create(
            entry_type="expertise", title="Markdown Test",
            content="Content for markdown export",
            domain="testing", tags="md,export",
        )

        md = exporter.to_markdown()
        assert "# GreyMatter Knowledge Export" in md
        assert "## testing" in md
        assert "### Markdown Test" in md
        assert "Content for markdown export" in md
        assert "**Type**: expertise" in md

    def test_markdown_file_export(self, knowledge, exporter, tmp_path):
        """to_markdown_file() writes to disk and returns entry count."""
        knowledge.create(
            entry_type="expertise", title="MD File Test",
            content="Content", domain="testing",
        )

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

        with open(path) as f:
            content = f.read()
        assert "MD File Test" in content


# ---------------------------------------------------------------------------
# 4. Categorizer Write Lifecycle
# ---------------------------------------------------------------------------


class TestCategorizerWriteLifecycle:
    """Verify ObservationCategorizer database mutations with mocked LLM."""

    @pytest.fixture
    def mock_llm(self):
        """Mock LLM that returns predictable categorization results."""
        llm = MagicMock()
        llm.generate_json.return_value = {
            "domains": ["python", "testing"],
            "suggested_category": "pattern",
            "category_changed": False,
            "confidence": 0.9,
        }
        return llm

    @pytest.fixture
    def categorizer(self, mock_llm):
        from greymatter_core.categorizer import ObservationCategorizer
        return ObservationCategorizer(mock_llm, min_confidence=0.5)

    @pytest.fixture
    def categorizer_auto_recat(self, mock_llm):
        from greymatter_core.categorizer import ObservationCategorizer
        return ObservationCategorizer(mock_llm, min_confidence=0.5, auto_recategorize=True)

    def test_categorize_and_apply_writes_domains(self, db, raw_conn, categorizer):
        """categorize_and_apply() writes domain tags to the observation."""
        now = datetime.now(timezone.utc).isoformat()
        _insert_observation(raw_conn, "obs-cat", "Test Obs", "Testing content", created_at=now)

        obs = {
            "id": "obs-cat", "title": "Test Obs",
            "content": "Testing content", "category": "insight",
        }
        result = categorizer.categorize_and_apply(obs, db)

        assert result["domains"] == ["python", "testing"]
        assert result["confidence"] == 0.9

        row = db.execute_one("SELECT domains FROM observations WHERE id = 'obs-cat'")
        assert row["domains"] == "python,testing"

    def test_categorize_and_apply_no_recat_by_default(
        self, db, raw_conn, mock_llm, categorizer,
    ):
        """categorize_and_apply() does not change category when auto_recategorize=False."""
        now = datetime.now(timezone.utc).isoformat()
        _insert_observation(
            raw_conn, "obs-no-recat", "No Recat", "Content",
            created_at=now, category="insight",
        )

        mock_llm.generate_json.return_value = {
            "domains": ["python"],
            "suggested_category": "pattern",
            "category_changed": True,
            "confidence": 0.95,
        }

        obs = {
            "id": "obs-no-recat", "title": "No Recat",
            "content": "Content", "category": "insight",
        }
        categorizer.categorize_and_apply(obs, db)

        row = db.execute_one(
            "SELECT category, domains FROM observations WHERE id = 'obs-no-recat'"
        )
        assert row["category"] == "insight"  # Unchanged
        assert row["domains"] == "python"

    def test_categorize_and_apply_with_recat_enabled(
        self, db, raw_conn, mock_llm, categorizer_auto_recat,
    ):
        """categorize_and_apply() changes category when auto_recategorize=True."""
        now = datetime.now(timezone.utc).isoformat()
        _insert_observation(
            raw_conn, "obs-recat", "Recat", "Content",
            created_at=now, category="insight",
        )

        mock_llm.generate_json.return_value = {
            "domains": ["security"],
            "suggested_category": "gotcha",
            "category_changed": True,
            "confidence": 0.95,
        }

        obs = {
            "id": "obs-recat", "title": "Recat",
            "content": "Content", "category": "insight",
        }
        categorizer_auto_recat.categorize_and_apply(obs, db)

        row = db.execute_one(
            "SELECT category, domains FROM observations WHERE id = 'obs-recat'"
        )
        assert row["category"] == "gotcha"
        assert row["domains"] == "security"

    def test_categorize_batch_tags_untagged(self, db, raw_conn, categorizer):
        """categorize_batch() tags observations that have no domains."""
        now = datetime.now(timezone.utc).isoformat()
        for i in range(3):
            _insert_observation(
                raw_conn, f"obs-batch-{i}", f"Batch Obs {i}",
                f"Content {i}", created_at=now,
            )

        stats = categorizer.categorize_batch(db, batch_size=10)
        assert stats["processed"] == 3
        assert stats["tagged"] == 3
        assert stats["failed"] == 0
        assert stats["skipped"] == 0

        for i in range(3):
            row = db.execute_one(
                f"SELECT domains FROM observations WHERE id = 'obs-batch-{i}'"
            )
            assert row["domains"] == "python,testing"

    def test_categorize_batch_skips_already_tagged(self, db, raw_conn, categorizer):
        """categorize_batch with untagged_only=True skips already-tagged observations."""
        now = datetime.now(timezone.utc).isoformat()
        _insert_observation(
            raw_conn, "obs-tagged", "Tagged", "Content",
            created_at=now, domains="python",
        )
        _insert_observation(
            raw_conn, "obs-untagged", "Untagged", "Content", created_at=now,
        )

        stats = categorizer.categorize_batch(db, batch_size=10, untagged_only=True)
        assert stats["processed"] == 1
        assert stats["tagged"] == 1

    def test_categorize_batch_skips_low_confidence(
        self, db, raw_conn, mock_llm, categorizer,
    ):
        """categorize_batch skips observations with confidence below min_confidence."""
        now = datetime.now(timezone.utc).isoformat()
        _insert_observation(raw_conn, "obs-low", "Low Conf", "Content", created_at=now)

        mock_llm.generate_json.return_value = {
            "domains": ["maybe-python"],
            "suggested_category": "insight",
            "category_changed": False,
            "confidence": 0.3,  # Below min_confidence of 0.5
        }

        stats = categorizer.categorize_batch(db, batch_size=10)
        assert stats["processed"] == 1
        assert stats["skipped"] == 1
        assert stats["tagged"] == 0

        row = db.execute_one("SELECT domains FROM observations WHERE id = 'obs-low'")
        assert row["domains"] == ""  # Unchanged

    def test_categorize_empty_observation_returns_empty(self, categorizer):
        """categorize() returns empty result for observation with no title or content."""
        result = categorizer.categorize({"title": "", "content": ""})
        assert result["domains"] == []
        assert result["confidence"] == 0.0

    def test_categorize_and_apply_no_id_is_noop(self, db, categorizer):
        """categorize_and_apply() does nothing when observation has no ID."""
        obs = {"title": "No ID", "content": "Content", "category": "insight"}
        result = categorizer.categorize_and_apply(obs, db)
        assert result["domains"] == ["python", "testing"]
        # No DB write occurred — no rows affected


# ---------------------------------------------------------------------------
# 5. Cross-Module Write Pipeline
# ---------------------------------------------------------------------------


class TestCrossModuleWritePipeline:
    """Verify write interactions spanning dedup, session_state, and export."""

    def test_session_state_with_export_workflow(
        self, db, knowledge, sessions, state_mgr, exporter,
    ):
        """Session state captures context; knowledge is exported; state survives."""
        session = sessions.start(project="greymatter")

        # Create knowledge during session
        entry = knowledge.create(
            entry_type="expertise", title="Cross-Module Test",
            content="Testing dedup + state + export interaction",
            domain="integration",
        )

        # Save session state
        state = SessionState(
            session_id=session["id"],
            compaction_number=0,
            current_task="Integration testing",
            active_files=[{"path": "test_integration.py", "role": "testing"}],
            progress_summary=f"Created knowledge entry {entry['id']}",
        )
        state_mgr.save(state)

        # Export knowledge
        data = exporter.to_json(domain="integration")
        assert data["counts"]["knowledge_entries"] == 1

        # Session state still retrievable
        loaded = state_mgr.load_latest(session["id"])
        assert loaded is not None
        assert loaded.current_task == "Integration testing"
        assert entry["id"] in loaded.progress_summary

    def test_dedup_then_export_consistency(self, db, raw_conn, knowledge, exporter):
        """After dedup, export reflects the merged state correctly."""
        now = datetime.now(timezone.utc)
        t1 = now.isoformat()
        t2 = (now + timedelta(seconds=10)).isoformat()

        _insert_observation(raw_conn, "obs-exp-1", "Export Dedup", "First", created_at=t1)
        _insert_observation(raw_conn, "obs-exp-2", "Export Dedup", "Second", created_at=t2)

        # Dedup
        deleted = dedup_observations(raw_conn, window_minutes=5)
        assert deleted == 1

        # Knowledge entry created separately
        knowledge.create(
            entry_type="expertise", title="Post-Dedup",
            content="Created after dedup", domain="testing",
        )

        # Export should include knowledge but observations are separate
        data = exporter.to_json(include_observations=True)
        assert data["counts"]["knowledge_entries"] == 1

        # Only 1 observation should remain after dedup
        obs_count = raw_conn.execute(
            "SELECT COUNT(*) FROM observations"
        ).fetchone()[0]
        assert obs_count == 1

    def test_session_state_retention_across_compactions(
        self, db, sessions, state_mgr, knowledge,
    ):
        """Session state retention works across multiple compaction cycles."""
        session = sessions.start(project="greymatter")

        # Simulate multiple compactions
        for compaction in range(MAX_STATES_PER_SESSION + 2):
            entry = knowledge.create(
                entry_type="expertise",
                title=f"Knowledge at compaction {compaction}",
                content=f"Content {compaction}",
                domain="testing",
            )
            state = SessionState(
                session_id=session["id"],
                compaction_number=compaction,
                current_task=f"Working on compaction {compaction}",
                progress_summary=f"Created {entry['id']}",
            )
            state_mgr.save(state)

        # Only last MAX_STATES_PER_SESSION should remain
        history = state_mgr.get_history(session["id"])
        assert len(history) == MAX_STATES_PER_SESSION

        # Latest is the most recent compaction
        latest = state_mgr.load_latest(session["id"])
        assert latest.compaction_number == MAX_STATES_PER_SESSION + 1

        # All knowledge entries still exist (retention is per session_states only)
        total = knowledge.count()
        assert total == MAX_STATES_PER_SESSION + 2
