"""Tests for the storage service (CRUD operations)."""

from __future__ import annotations

import sqlite3
from pathlib import Path

import pytest

from anteroom.db import _FTS_SCHEMA, _FTS_TRIGGERS, _SCHEMA, ThreadSafeConnection
from anteroom.services.storage import (
    add_tag_to_conversation,
    append_agent_run_event,
    copy_conversation_to_db,
    count_active_agent_runs,
    create_agent_run,
    create_conversation,
    create_folder,
    create_message,
    create_tag,
    create_tool_call,
    delete_agent_run,
    delete_conversation,
    delete_empty_conversations,
    delete_folder,
    delete_message,
    delete_messages_after_position,
    delete_tag,
    fork_conversation,
    get_agent_run,
    get_conversation,
    get_conversation_tags,
    list_agent_run_events,
    list_agent_runs,
    list_conversations,
    list_folders,
    list_messages,
    list_tags,
    list_tool_calls,
    move_conversation_to_folder,
    register_user,
    remove_tag_from_conversation,
    replace_document_content,
    save_attachment,
    update_agent_run,
    update_conversation_slug,
    update_conversation_title,
    update_conversation_type,
    update_folder,
    update_message_content,
    update_tag,
    update_tool_call,
)


@pytest.fixture()
def db() -> ThreadSafeConnection:
    conn = sqlite3.connect(":memory:", check_same_thread=False)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys=ON")
    conn.executescript(_SCHEMA)
    try:
        conn.executescript(_FTS_SCHEMA)
        conn.executescript(_FTS_TRIGGERS)
    except sqlite3.OperationalError:
        pass
    conn.commit()
    return ThreadSafeConnection(conn)


class TestConversations:
    def test_create_conversation_returns_dict(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Hello")
        assert conv["title"] == "Hello"
        assert "id" in conv
        assert "created_at" in conv
        assert "updated_at" in conv

    def test_get_conversation(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Test")
        fetched = get_conversation(db, conv["id"])
        assert fetched is not None
        assert fetched["id"] == conv["id"]
        assert fetched["title"] == "Test"

    def test_get_conversation_missing(self, db: sqlite3.Connection) -> None:
        result = get_conversation(db, "nonexistent-id")
        assert result is None

    def test_list_conversations_empty(self, db: sqlite3.Connection) -> None:
        result = list_conversations(db)
        assert result == []

    def test_list_conversations_returns_all(self, db: sqlite3.Connection) -> None:
        create_conversation(db, title="First")
        create_conversation(db, title="Second")
        result = list_conversations(db)
        assert len(result) == 2

    def test_list_conversations_includes_message_count(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Counted")
        create_message(db, conv["id"], "user", "hi")
        create_message(db, conv["id"], "assistant", "hello")
        result = list_conversations(db)
        assert result[0]["message_count"] == 2

    def test_list_conversations_ordered_by_updated_at(self, db: sqlite3.Connection) -> None:
        c1 = create_conversation(db, title="Older")
        create_conversation(db, title="Newer")
        create_message(db, c1["id"], "user", "bump")
        result = list_conversations(db)
        assert result[0]["id"] == c1["id"]

    def test_update_conversation_title(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Original")
        updated = update_conversation_title(db, conv["id"], "Renamed")
        assert updated is not None
        assert updated["title"] == "Renamed"

    def test_create_conversation_default_type_is_chat(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Chat")
        assert conv["type"] == "chat"

    def test_create_conversation_with_note_type(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="My Notes", conversation_type="note")
        assert conv["type"] == "note"
        fetched = get_conversation(db, conv["id"])
        assert fetched is not None
        assert fetched["type"] == "note"

    def test_create_conversation_with_document_type(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="My Doc", conversation_type="document")
        assert conv["type"] == "document"

    def test_create_conversation_invalid_type_raises(self, db: sqlite3.Connection) -> None:
        with pytest.raises(ValueError, match="Invalid conversation type"):
            create_conversation(db, title="Bad", conversation_type="invalid")

    def test_list_conversations_returns_type(self, db: sqlite3.Connection) -> None:
        create_conversation(db, title="Chat", conversation_type="chat")
        create_conversation(db, title="Note", conversation_type="note")
        result = list_conversations(db)
        types = {c["type"] for c in result}
        assert types == {"chat", "note"}

    def test_list_conversations_filter_by_type(self, db: sqlite3.Connection) -> None:
        create_conversation(db, title="Chat 1", conversation_type="chat")
        create_conversation(db, title="Note 1", conversation_type="note")
        create_conversation(db, title="Doc 1", conversation_type="document")
        notes = list_conversations(db, conversation_type="note")
        assert len(notes) == 1
        assert notes[0]["type"] == "note"

    def test_list_conversations_filter_by_space_id(self, db: sqlite3.Connection) -> None:
        from anteroom.services.space_storage import create_space

        s1 = create_space(db, "space-one")
        s2 = create_space(db, "space-two")
        create_conversation(db, title="In S1", space_id=s1["id"])
        create_conversation(db, title="In S2", space_id=s2["id"])
        create_conversation(db, title="No space")
        result = list_conversations(db, space_id=s1["id"])
        assert len(result) == 1
        assert result[0]["title"] == "In S1"

    def test_list_conversations_no_space_filter_returns_all(self, db: sqlite3.Connection) -> None:
        from anteroom.services.space_storage import create_space

        s = create_space(db, "space-filter-test")
        create_conversation(db, title="In space", space_id=s["id"])
        create_conversation(db, title="No space")
        result = list_conversations(db)
        assert len(result) == 2

    def test_list_conversations_space_and_type_filter(self, db: sqlite3.Connection) -> None:
        from anteroom.services.space_storage import create_space

        s = create_space(db, "space-combo")
        create_conversation(db, title="Chat in space", conversation_type="chat", space_id=s["id"])
        create_conversation(db, title="Note in space", conversation_type="note", space_id=s["id"])
        create_conversation(db, title="Chat no space", conversation_type="chat")
        result = list_conversations(db, space_id=s["id"], conversation_type="note")
        assert len(result) == 1
        assert result[0]["title"] == "Note in space"

    def test_update_conversation_type(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Will Change")
        updated = update_conversation_type(db, conv["id"], "note")
        assert updated is not None
        assert updated["type"] == "note"

    def test_update_conversation_type_invalid_raises(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Bad Update")
        with pytest.raises(ValueError, match="Invalid conversation type"):
            update_conversation_type(db, conv["id"], "invalid")

    def test_delete_conversation(self, db: sqlite3.Connection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Doomed")
        result = delete_conversation(db, conv["id"], tmp_path)
        assert result is True
        assert get_conversation(db, conv["id"]) is None

    def test_delete_conversation_missing(self, db: sqlite3.Connection, tmp_path: Path) -> None:
        result = delete_conversation(db, "no-such-id", tmp_path)
        assert result is False

    def test_delete_conversation_cascades_messages(self, db: sqlite3.Connection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Cascade")
        create_message(db, conv["id"], "user", "hello")
        delete_conversation(db, conv["id"], tmp_path)
        msgs = db.execute("SELECT * FROM messages WHERE conversation_id = ?", (conv["id"],)).fetchall()
        assert len(msgs) == 0

    def test_delete_conversation_cascades_tool_calls(self, db: sqlite3.Connection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="TC Cascade")
        msg = create_message(db, conv["id"], "assistant", "calling tool")
        create_tool_call(db, msg["id"], "my_tool", "server1", {"arg": "val"})
        delete_conversation(db, conv["id"], tmp_path)
        tcs = db.execute("SELECT * FROM tool_calls WHERE message_id = ?", (msg["id"],)).fetchall()
        assert len(tcs) == 0


class TestConversationSlugs:
    def test_create_conversation_has_slug(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Slugged")
        assert conv.get("slug") is not None
        assert len(conv["slug"].split("-")) == 3

    def test_get_conversation_by_slug(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Find Me")
        fetched = get_conversation(db, conv["slug"])
        assert fetched is not None
        assert fetched["id"] == conv["id"]

    def test_create_conversation_with_working_dir(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="With Dir", working_dir="/tmp/my-project")
        assert conv["working_dir"] == "/tmp/my-project"
        fetched = get_conversation(db, conv["id"])
        assert fetched is not None
        assert fetched["working_dir"] == "/tmp/my-project"

    def test_create_conversation_working_dir_default_none(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="No Dir")
        assert conv["working_dir"] is None
        fetched = get_conversation(db, conv["id"])
        assert fetched is not None
        assert fetched["working_dir"] is None

    def test_list_conversations_includes_slug(self, db: sqlite3.Connection) -> None:
        create_conversation(db, title="Listed")
        result = list_conversations(db)
        assert "slug" in result[0]
        assert result[0]["slug"] is not None

    def test_update_conversation_slug(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Rename Slug")
        updated = update_conversation_slug(db, conv["id"], "my-custom-slug")
        assert updated is not None
        assert updated["slug"] == "my-custom-slug"

    def test_update_slug_duplicate_raises(self, db: sqlite3.Connection) -> None:
        conv1 = create_conversation(db, title="First")
        create_conversation(db, title="Second")
        import sqlite3 as _sqlite3

        with pytest.raises(_sqlite3.IntegrityError):
            update_conversation_slug(db, conv1["id"], create_conversation(db, title="Third")["slug"])

    def test_backfill_slug_on_access(self, db: sqlite3.Connection) -> None:
        # Simulate an old conversation without a slug
        db.execute(
            "INSERT INTO conversations (id, title, type, created_at, updated_at) "
            "VALUES ('old-id', 'Old Conv', 'chat', '2024-01-01', '2024-01-01')"
        )
        db.commit()
        fetched = get_conversation(db, "old-id")
        assert fetched is not None
        assert fetched["slug"] is not None
        # Verify it was persisted
        row = db.execute_fetchone("SELECT slug FROM conversations WHERE id = 'old-id'")
        assert row["slug"] is not None


class TestDeleteEmptyConversations:
    def test_removes_empty(self, db: sqlite3.Connection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Empty")
        count = delete_empty_conversations(db, tmp_path)
        assert count == 1
        assert get_conversation(db, conv["id"]) is None

    def test_keeps_non_empty(self, db: sqlite3.Connection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="HasMessages")
        create_message(db, conv["id"], "user", "hello")
        count = delete_empty_conversations(db, tmp_path)
        assert count == 0
        assert get_conversation(db, conv["id"]) is not None

    def test_mixed(self, db: sqlite3.Connection, tmp_path: Path) -> None:
        empty1 = create_conversation(db, title="Empty1")
        empty2 = create_conversation(db, title="Empty2")
        kept = create_conversation(db, title="Kept")
        create_message(db, kept["id"], "user", "hi")
        count = delete_empty_conversations(db, tmp_path)
        assert count == 2
        assert get_conversation(db, empty1["id"]) is None
        assert get_conversation(db, empty2["id"]) is None
        assert get_conversation(db, kept["id"]) is not None

    def test_excludes_ids(self, db: sqlite3.Connection, tmp_path: Path) -> None:
        protected = create_conversation(db, title="Protected")
        doomed = create_conversation(db, title="Doomed")
        count = delete_empty_conversations(db, tmp_path, exclude_ids={protected["id"]})
        assert count == 1
        assert get_conversation(db, protected["id"]) is not None
        assert get_conversation(db, doomed["id"]) is None

    def test_returns_zero_when_none_empty(self, db: sqlite3.Connection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Full")
        create_message(db, conv["id"], "user", "content")
        assert delete_empty_conversations(db, tmp_path) == 0


class TestMessages:
    def test_create_message(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Msgs")
        msg = create_message(db, conv["id"], "user", "hello")
        assert msg["role"] == "user"
        assert msg["content"] == "hello"
        assert msg["position"] == 0

    def test_create_message_increments_position(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Pos")
        m1 = create_message(db, conv["id"], "user", "first")
        m2 = create_message(db, conv["id"], "assistant", "second")
        assert m1["position"] == 0
        assert m2["position"] == 1

    def test_list_messages_ordered_by_position(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Order")
        create_message(db, conv["id"], "user", "a")
        create_message(db, conv["id"], "assistant", "b")
        create_message(db, conv["id"], "user", "c")
        msgs = list_messages(db, conv["id"])
        assert [m["content"] for m in msgs] == ["a", "b", "c"]

    def test_list_messages_empty(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Empty")
        msgs = list_messages(db, conv["id"])
        assert msgs == []

    def test_list_messages_includes_attachments_and_tool_calls(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Full")
        msg = create_message(db, conv["id"], "assistant", "response")
        create_tool_call(db, msg["id"], "tool", "srv", {"k": "v"})
        msgs = list_messages(db, conv["id"])
        assert "attachments" in msgs[0]
        assert "tool_calls" in msgs[0]
        assert len(msgs[0]["tool_calls"]) == 1

    def test_create_message_updates_conversation_updated_at(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Updated")
        original_updated = conv["updated_at"]
        create_message(db, conv["id"], "user", "bump")
        refreshed = get_conversation(db, conv["id"])
        assert refreshed is not None
        assert refreshed["updated_at"] >= original_updated


class TestToolCalls:
    def test_create_tool_call(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Tools")
        msg = create_message(db, conv["id"], "assistant", "calling")
        tc = create_tool_call(db, msg["id"], "search", "search_server", {"query": "test"})
        assert tc["tool_name"] == "search"
        assert tc["status"] == "pending"
        assert tc["input"] == {"query": "test"}
        assert tc["output"] is None

    def test_update_tool_call(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Tools")
        msg = create_message(db, conv["id"], "assistant", "calling")
        tc = create_tool_call(db, msg["id"], "search", "srv", {"q": "x"})
        update_tool_call(db, tc["id"], {"result": "found"}, "success")
        tcs = list_tool_calls(db, msg["id"])
        assert len(tcs) == 1
        assert tcs[0]["status"] == "success"
        assert tcs[0]["output"] == {"result": "found"}

    def test_list_tool_calls_empty(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="NoTools")
        msg = create_message(db, conv["id"], "user", "plain")
        tcs = list_tool_calls(db, msg["id"])
        assert tcs == []

    def test_create_tool_call_with_custom_id(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="CustomId")
        msg = create_message(db, conv["id"], "assistant", "calling")
        tc = create_tool_call(db, msg["id"], "tool", "srv", {}, tool_call_id="custom-123")
        assert tc["id"] == "custom-123"


class TestSearchConversations:
    def test_search_by_title(self, db: sqlite3.Connection) -> None:
        create_conversation(db, title="Python tutorial")
        create_conversation(db, title="Rust handbook")
        results = list_conversations(db, search="Python")
        assert len(results) == 1
        assert results[0]["title"] == "Python tutorial"

    def test_search_by_message_content(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Generic chat")
        create_message(db, conv["id"], "user", "Tell me about quantum computing")
        results = list_conversations(db, search="quantum")
        assert len(results) == 1
        assert results[0]["id"] == conv["id"]

    def test_search_no_results(self, db: sqlite3.Connection) -> None:
        create_conversation(db, title="Something")
        results = list_conversations(db, search="zzzznotfound")
        assert len(results) == 0


class TestForkConversation:
    def test_fork_copies_messages_up_to_position(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Original")
        create_message(db, conv["id"], "user", "msg0")
        create_message(db, conv["id"], "assistant", "msg1")
        create_message(db, conv["id"], "user", "msg2")
        create_message(db, conv["id"], "assistant", "msg3")

        forked = fork_conversation(db, conv["id"], 1)
        assert forked["title"] == "Original (fork)"
        assert forked["id"] != conv["id"]

        msgs = list_messages(db, forked["id"])
        assert len(msgs) == 2
        assert msgs[0]["content"] == "msg0"
        assert msgs[1]["content"] == "msg1"

    def test_fork_preserves_positions(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Positions")
        create_message(db, conv["id"], "user", "a")
        create_message(db, conv["id"], "assistant", "b")
        create_message(db, conv["id"], "user", "c")

        forked = fork_conversation(db, conv["id"], 2)
        msgs = list_messages(db, forked["id"])
        assert [m["position"] for m in msgs] == [0, 1, 2]

    def test_fork_copies_tool_calls(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Tools")
        create_message(db, conv["id"], "user", "hi")
        msg = create_message(db, conv["id"], "assistant", "calling")
        create_tool_call(db, msg["id"], "tool1", "srv", {"k": "v"})

        forked = fork_conversation(db, conv["id"], 1)
        msgs = list_messages(db, forked["id"])
        assert len(msgs[1]["tool_calls"]) == 1
        assert msgs[1]["tool_calls"][0]["tool_name"] == "tool1"

    def test_fork_new_message_ids(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="IDs")
        m0 = create_message(db, conv["id"], "user", "hi")

        forked = fork_conversation(db, conv["id"], 0)
        msgs = list_messages(db, forked["id"])
        assert msgs[0]["id"] != m0["id"]

    def test_fork_nonexistent_conversation(self, db: sqlite3.Connection) -> None:
        with pytest.raises(ValueError, match="Conversation not found"):
            fork_conversation(db, "no-such-id", 0)

    def test_fork_inherits_model(self, db: sqlite3.Connection) -> None:
        from anteroom.services.storage import update_conversation_model

        conv = create_conversation(db, title="WithModel")
        update_conversation_model(db, conv["id"], "gpt-4o")
        create_message(db, conv["id"], "user", "hi")

        forked = fork_conversation(db, conv["id"], 0)
        assert forked["model"] == "gpt-4o"


class TestUpdateMessageContent:
    def test_update_message_content(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Edit")
        msg = create_message(db, conv["id"], "user", "original")
        updated = update_message_content(db, conv["id"], msg["id"], "edited")
        assert updated is not None
        assert updated["content"] == "edited"

    def test_update_message_wrong_conversation(self, db: sqlite3.Connection) -> None:
        conv1 = create_conversation(db, title="C1")
        conv2 = create_conversation(db, title="C2")
        msg = create_message(db, conv1["id"], "user", "hello")
        result = update_message_content(db, conv2["id"], msg["id"], "edited")
        assert result is None

    def test_update_message_nonexistent(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Edit")
        result = update_message_content(db, conv["id"], "no-such-id", "edited")
        assert result is None


class TestDeleteMessagesAfterPosition:
    def test_delete_messages_after_position(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Delete")
        create_message(db, conv["id"], "user", "keep0")
        create_message(db, conv["id"], "assistant", "keep1")
        create_message(db, conv["id"], "user", "remove2")
        create_message(db, conv["id"], "assistant", "remove3")

        count = delete_messages_after_position(db, conv["id"], 1)
        assert count == 2
        msgs = list_messages(db, conv["id"])
        assert len(msgs) == 2
        assert [m["content"] for m in msgs] == ["keep0", "keep1"]

    def test_delete_messages_after_last_position(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="NoDelete")
        create_message(db, conv["id"], "user", "only")
        count = delete_messages_after_position(db, conv["id"], 0)
        assert count == 0
        msgs = list_messages(db, conv["id"])
        assert len(msgs) == 1

    def test_delete_cascades_tool_calls(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Cascade")
        create_message(db, conv["id"], "user", "hi")
        msg = create_message(db, conv["id"], "assistant", "calling")
        create_tool_call(db, msg["id"], "tool1", "srv", {"x": 1})

        delete_messages_after_position(db, conv["id"], 0)
        tcs = list_tool_calls(db, msg["id"])
        assert len(tcs) == 0


class TestFolders:
    def test_create_folder(self, db: sqlite3.Connection) -> None:
        folder = create_folder(db, "Work")
        assert folder["name"] == "Work"
        assert folder["id"]
        assert folder["position"] == 0
        assert folder["collapsed"] is False
        assert folder["parent_id"] is None

    def test_create_subfolder(self, db: sqlite3.Connection) -> None:
        parent = create_folder(db, "Parent")
        child = create_folder(db, "Child", parent_id=parent["id"])
        assert child["parent_id"] == parent["id"]
        assert child["position"] == 0

    def test_nested_subfolders(self, db: sqlite3.Connection) -> None:
        root = create_folder(db, "Root")
        mid = create_folder(db, "Mid", parent_id=root["id"])
        leaf = create_folder(db, "Leaf", parent_id=mid["id"])
        assert leaf["parent_id"] == mid["id"]
        assert mid["parent_id"] == root["id"]

    def test_delete_folder_cascades_children(self, db: sqlite3.Connection) -> None:
        root = create_folder(db, "Root")
        child = create_folder(db, "Child", parent_id=root["id"])
        grandchild = create_folder(db, "Grandchild", parent_id=child["id"])
        conv = create_conversation(db, "In grandchild")
        move_conversation_to_folder(db, conv["id"], grandchild["id"])
        delete_folder(db, root["id"])
        assert list_folders(db) == []
        updated_conv = get_conversation(db, conv["id"])
        assert updated_conv["folder_id"] is None

    def test_create_folders_auto_increment_position(self, db: sqlite3.Connection) -> None:
        f1 = create_folder(db, "First")
        f2 = create_folder(db, "Second")
        f3 = create_folder(db, "Third")
        assert f1["position"] == 0
        assert f2["position"] == 1
        assert f3["position"] == 2

    def test_list_folders_empty(self, db: sqlite3.Connection) -> None:
        assert list_folders(db) == []

    def test_list_folders_ordered_by_position(self, db: sqlite3.Connection) -> None:
        create_folder(db, "A")
        create_folder(db, "B")
        create_folder(db, "C")
        folders = list_folders(db)
        assert len(folders) == 3
        assert [f["name"] for f in folders] == ["A", "B", "C"]

    def test_update_folder_name(self, db: sqlite3.Connection) -> None:
        folder = create_folder(db, "Old Name")
        updated = update_folder(db, folder["id"], name="New Name")
        assert updated is not None
        assert updated["name"] == "New Name"

    def test_update_folder_collapsed(self, db: sqlite3.Connection) -> None:
        folder = create_folder(db, "Collapsible")
        updated = update_folder(db, folder["id"], collapsed=True)
        assert updated is not None
        assert updated["collapsed"] is True

    def test_update_folder_position(self, db: sqlite3.Connection) -> None:
        folder = create_folder(db, "Movable")
        updated = update_folder(db, folder["id"], position=5)
        assert updated is not None
        assert updated["position"] == 5

    def test_update_folder_not_found(self, db: sqlite3.Connection) -> None:
        assert update_folder(db, "nonexistent-id", name="X") is None

    def test_delete_folder(self, db: sqlite3.Connection) -> None:
        folder = create_folder(db, "Doomed")
        assert delete_folder(db, folder["id"]) is True
        assert list_folders(db) == []

    def test_delete_folder_not_found(self, db: sqlite3.Connection) -> None:
        assert delete_folder(db, "nonexistent-id") is False

    def test_delete_folder_unlinks_conversations(self, db: sqlite3.Connection) -> None:
        folder = create_folder(db, "Has Convos")
        conv = create_conversation(db)
        move_conversation_to_folder(db, conv["id"], folder["id"])
        c = get_conversation(db, conv["id"])
        assert c is not None
        assert c["folder_id"] == folder["id"]

        delete_folder(db, folder["id"])
        c = get_conversation(db, conv["id"])
        assert c is not None
        assert c["folder_id"] is None

    def test_move_conversation_to_folder(self, db: sqlite3.Connection) -> None:
        folder = create_folder(db, "Target")
        conv = create_conversation(db)
        move_conversation_to_folder(db, conv["id"], folder["id"])
        c = get_conversation(db, conv["id"])
        assert c is not None
        assert c["folder_id"] == folder["id"]

    def test_move_conversation_to_no_folder(self, db: sqlite3.Connection) -> None:
        folder = create_folder(db, "Source")
        conv = create_conversation(db)
        move_conversation_to_folder(db, conv["id"], folder["id"])
        move_conversation_to_folder(db, conv["id"], None)
        c = get_conversation(db, conv["id"])
        assert c is not None
        assert c["folder_id"] is None

    def test_list_conversations_includes_folder_id(self, db: sqlite3.Connection) -> None:
        folder = create_folder(db, "Folder")
        conv = create_conversation(db)
        move_conversation_to_folder(db, conv["id"], folder["id"])
        convs = list_conversations(db)
        assert len(convs) == 1
        assert convs[0]["folder_id"] == folder["id"]


class TestTags:
    def test_create_tag(self, db: sqlite3.Connection) -> None:
        tag = create_tag(db, "important", "#ff0000")
        assert tag["name"] == "important"
        assert tag["color"] == "#ff0000"
        assert tag["id"]

    def test_create_tag_default_color(self, db: sqlite3.Connection) -> None:
        tag = create_tag(db, "general")
        assert tag["color"] == "#3b82f6"

    def test_list_tags(self, db: sqlite3.Connection) -> None:
        create_tag(db, "b-tag")
        create_tag(db, "a-tag")
        tags = list_tags(db)
        assert len(tags) == 2
        assert tags[0]["name"] == "a-tag"
        assert tags[1]["name"] == "b-tag"

    def test_update_tag(self, db: sqlite3.Connection) -> None:
        tag = create_tag(db, "old", "#000000")
        updated = update_tag(db, tag["id"], name="new", color="#ffffff")
        assert updated is not None
        assert updated["name"] == "new"
        assert updated["color"] == "#ffffff"

    def test_update_tag_not_found(self, db: sqlite3.Connection) -> None:
        assert update_tag(db, "nonexistent") is None

    def test_delete_tag(self, db: sqlite3.Connection) -> None:
        tag = create_tag(db, "doomed")
        assert delete_tag(db, tag["id"]) is True
        assert list_tags(db) == []

    def test_delete_tag_not_found(self, db: sqlite3.Connection) -> None:
        assert delete_tag(db, "nonexistent") is False

    def test_add_tag_to_conversation(self, db: sqlite3.Connection) -> None:
        tag = create_tag(db, "important")
        conv = create_conversation(db)
        add_tag_to_conversation(db, conv["id"], tag["id"])
        tags = get_conversation_tags(db, conv["id"])
        assert len(tags) == 1
        assert tags[0]["name"] == "important"

    def test_remove_tag_from_conversation(self, db: sqlite3.Connection) -> None:
        tag = create_tag(db, "temp")
        conv = create_conversation(db)
        add_tag_to_conversation(db, conv["id"], tag["id"])
        remove_tag_from_conversation(db, conv["id"], tag["id"])
        assert get_conversation_tags(db, conv["id"]) == []

    def test_conversation_multiple_tags(self, db: sqlite3.Connection) -> None:
        t1 = create_tag(db, "alpha")
        t2 = create_tag(db, "beta")
        conv = create_conversation(db)
        add_tag_to_conversation(db, conv["id"], t1["id"])
        add_tag_to_conversation(db, conv["id"], t2["id"])
        tags = get_conversation_tags(db, conv["id"])
        assert len(tags) == 2

    def test_delete_tag_removes_from_conversations(self, db: sqlite3.Connection) -> None:
        tag = create_tag(db, "doomed")
        conv = create_conversation(db)
        add_tag_to_conversation(db, conv["id"], tag["id"])
        delete_tag(db, tag["id"])
        assert get_conversation_tags(db, conv["id"]) == []

    def test_list_conversations_includes_tags(self, db: sqlite3.Connection) -> None:
        tag = create_tag(db, "flagged", "#ff0000")
        conv = create_conversation(db)
        add_tag_to_conversation(db, conv["id"], tag["id"])
        convs = list_conversations(db)
        assert len(convs) == 1
        assert len(convs[0]["tags"]) == 1
        assert convs[0]["tags"][0]["name"] == "flagged"


class TestCopyConversationToDb:
    @pytest.fixture()
    def target_db(self) -> ThreadSafeConnection:
        conn = sqlite3.connect(":memory:", check_same_thread=False)
        conn.row_factory = sqlite3.Row
        conn.execute("PRAGMA foreign_keys=ON")
        conn.executescript(_SCHEMA)
        try:
            conn.executescript(_FTS_SCHEMA)
            conn.executescript(_FTS_TRIGGERS)
        except sqlite3.OperationalError:
            pass
        conn.commit()
        return ThreadSafeConnection(conn)

    def test_copy_conversation(self, db, target_db) -> None:
        conv = create_conversation(db, title="Test Copy")
        create_message(db, conv["id"], "user", "Hello")
        create_message(db, conv["id"], "assistant", "Hi there")

        copied = copy_conversation_to_db(db, target_db, conv["id"])
        assert copied is not None
        assert copied["title"] == "Test Copy"
        assert copied["id"] != conv["id"]

        msgs = list_messages(target_db, copied["id"])
        assert len(msgs) == 2
        assert msgs[0]["content"] == "Hello"
        assert msgs[1]["content"] == "Hi there"

    def test_copy_preserves_positions(self, db, target_db) -> None:
        conv = create_conversation(db, title="Positions")
        create_message(db, conv["id"], "user", "First")
        create_message(db, conv["id"], "assistant", "Second")
        create_message(db, conv["id"], "user", "Third")

        copied = copy_conversation_to_db(db, target_db, conv["id"])
        assert copied is not None
        msgs = list_messages(target_db, copied["id"])
        assert [m["position"] for m in msgs] == [0, 1, 2]

    def test_copy_copies_tool_calls(self, db, target_db) -> None:
        conv = create_conversation(db)
        msg = create_message(db, conv["id"], "assistant", "tool usage")
        create_tool_call(db, msg["id"], "search", "mcp-server", {"query": "test"})

        copied = copy_conversation_to_db(db, target_db, conv["id"])
        assert copied is not None
        msgs = list_messages(target_db, copied["id"])
        assert len(msgs) == 1
        assert len(msgs[0]["tool_calls"]) == 1
        assert msgs[0]["tool_calls"][0]["tool_name"] == "search"

    def test_copy_nonexistent_conversation(self, db, target_db) -> None:
        result = copy_conversation_to_db(db, target_db, "nonexistent-id")
        assert result is None

    def test_copy_does_not_modify_source(self, db, target_db) -> None:
        conv = create_conversation(db, title="Original")
        create_message(db, conv["id"], "user", "Hello")

        copy_conversation_to_db(db, target_db, conv["id"])

        original = get_conversation(db, conv["id"])
        assert original is not None
        assert original["title"] == "Original"
        source_msgs = list_messages(db, conv["id"])
        assert len(source_msgs) == 1


class TestDatabaseManager:
    def test_add_and_get(self, tmp_path) -> None:
        from anteroom.db import DatabaseManager

        mgr = DatabaseManager()
        mgr.add("personal", tmp_path / "personal.db")
        mgr.add("shared", tmp_path / "shared.db")

        assert mgr.get("personal") is not None
        assert mgr.get("shared") is not None
        assert mgr.personal is not None

    def test_get_default_returns_personal(self, tmp_path) -> None:
        from anteroom.db import DatabaseManager

        mgr = DatabaseManager()
        mgr.add("personal", tmp_path / "personal.db")
        assert mgr.get() is mgr.personal

    def test_get_unknown_raises(self, tmp_path) -> None:
        from anteroom.db import DatabaseManager

        mgr = DatabaseManager()
        mgr.add("personal", tmp_path / "personal.db")
        with pytest.raises(KeyError):
            mgr.get("unknown")

    def test_list_databases(self, tmp_path) -> None:
        from anteroom.db import DatabaseManager

        mgr = DatabaseManager()
        mgr.add("personal", tmp_path / "personal.db")
        mgr.add("shared", tmp_path / "shared.db")

        dbs = mgr.list_databases()
        assert len(dbs) == 2
        names = [d["name"] for d in dbs]
        assert "personal" in names
        assert "shared" in names

    def test_remove(self, tmp_path) -> None:
        from anteroom.db import DatabaseManager

        mgr = DatabaseManager()
        mgr.add("personal", tmp_path / "personal.db")
        mgr.add("shared", tmp_path / "shared.db")
        mgr.remove("shared")

        dbs = mgr.list_databases()
        assert len(dbs) == 1
        assert dbs[0]["name"] == "personal"
        with pytest.raises(KeyError):
            mgr.get("shared")

    def test_close_all(self, tmp_path) -> None:
        from anteroom.db import DatabaseManager

        mgr = DatabaseManager()
        mgr.add("personal", tmp_path / "personal.db")
        mgr.add("shared", tmp_path / "shared.db")
        mgr.close_all()
        assert mgr.list_databases() == []


class TestUserIdentityInStorage:
    def test_create_conversation_with_identity(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Hello", user_id="u1", user_display_name="Alice")
        assert conv["title"] == "Hello"
        row = db.execute_fetchone("SELECT user_id, user_display_name FROM conversations WHERE id = ?", (conv["id"],))
        assert row is not None
        assert row["user_id"] == "u1"
        assert row["user_display_name"] == "Alice"

    def test_create_conversation_without_identity(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="No ID")
        row = db.execute_fetchone("SELECT user_id, user_display_name FROM conversations WHERE id = ?", (conv["id"],))
        assert row is not None
        assert row["user_id"] is None
        assert row["user_display_name"] is None

    def test_create_message_with_identity(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Msgs")
        msg = create_message(db, conv["id"], "user", "hi", user_id="u1", user_display_name="Alice")
        assert msg["content"] == "hi"
        row = db.execute_fetchone("SELECT user_id, user_display_name FROM messages WHERE id = ?", (msg["id"],))
        assert row is not None
        assert row["user_id"] == "u1"
        assert row["user_display_name"] == "Alice"

    def test_create_message_without_identity(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Msgs")
        msg = create_message(db, conv["id"], "user", "hi")
        row = db.execute_fetchone("SELECT user_id, user_display_name FROM messages WHERE id = ?", (msg["id"],))
        assert row is not None
        assert row["user_id"] is None
        assert row["user_display_name"] is None

    def test_create_folder_with_identity(self, db: sqlite3.Connection) -> None:
        folder = create_folder(db, "Work", user_id="u1", user_display_name="Alice")
        row = db.execute_fetchone("SELECT user_id, user_display_name FROM folders WHERE id = ?", (folder["id"],))
        assert row is not None
        assert row["user_id"] == "u1"
        assert row["user_display_name"] == "Alice"

    def test_create_tag_with_identity(self, db: sqlite3.Connection) -> None:
        tag = create_tag(db, "important", user_id="u1", user_display_name="Alice")
        row = db.execute_fetchone("SELECT user_id, user_display_name FROM tags WHERE id = ?", (tag["id"],))
        assert row is not None
        assert row["user_id"] == "u1"
        assert row["user_display_name"] == "Alice"

    def test_fork_preserves_user_identity(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Original", user_id="u1", user_display_name="Alice")
        create_message(db, conv["id"], "user", "msg0", user_id="u1", user_display_name="Alice")
        create_message(db, conv["id"], "assistant", "msg1")

        forked = fork_conversation(db, conv["id"], 1)
        forked_msgs = list_messages(db, forked["id"])
        row0 = db.execute_fetchone(
            "SELECT user_id, user_display_name FROM messages WHERE id = ?", (forked_msgs[0]["id"],)
        )
        assert row0["user_id"] == "u1"
        assert row0["user_display_name"] == "Alice"

    def test_copy_conversation_preserves_user_identity(self, db) -> None:
        target_conn = sqlite3.connect(":memory:", check_same_thread=False)
        target_conn.row_factory = sqlite3.Row
        target_conn.execute("PRAGMA foreign_keys=ON")
        target_conn.executescript(_SCHEMA)
        try:
            target_conn.executescript(_FTS_SCHEMA)
            target_conn.executescript(_FTS_TRIGGERS)
        except sqlite3.OperationalError:
            pass
        target_conn.commit()
        target_db = ThreadSafeConnection(target_conn)

        conv = create_conversation(db, title="Copy", user_id="u1", user_display_name="Alice")
        create_message(db, conv["id"], "user", "hi", user_id="u1", user_display_name="Alice")

        copied = copy_conversation_to_db(db, target_db, conv["id"])
        assert copied is not None
        msgs = list_messages(target_db, copied["id"])
        row = target_db.execute_fetchone(
            "SELECT user_id, user_display_name FROM messages WHERE id = ?", (msgs[0]["id"],)
        )
        assert row["user_id"] == "u1"
        assert row["user_display_name"] == "Alice"


class TestConversationTypeSQLInjection:
    """Security: verify type field cannot be used for SQL injection."""

    def test_type_filter_with_sql_injection_ignores_filter(self, db: sqlite3.Connection) -> None:
        """Invalid type is not in VALID_CONVERSATION_TYPES, so filter is ignored (returns all).

        This is safe because the type is never interpolated into SQL — it's checked
        against an allowlist first, and only valid types reach the parameterized query.
        """
        create_conversation(db, title="Safe", conversation_type="chat")
        result = list_conversations(db, conversation_type="chat' OR '1'='1")
        # Invalid type is silently ignored: returns unfiltered results
        assert len(result) == 1
        assert result[0]["type"] == "chat"

    def test_type_filter_with_union_injection_ignores_filter(self, db: sqlite3.Connection) -> None:
        create_conversation(db, title="Safe", conversation_type="chat")
        result = list_conversations(db, conversation_type="chat' UNION SELECT * FROM messages--")
        assert len(result) == 1  # ignored, returns all

    def test_type_filter_with_semicolon_injection_ignores_filter(self, db: sqlite3.Connection) -> None:
        create_conversation(db, title="Safe", conversation_type="chat")
        result = list_conversations(db, conversation_type="chat; DROP TABLE conversations;")
        assert len(result) == 1  # ignored, returns all
        # Verify table still exists
        rows = db.execute_fetchall("SELECT COUNT(*) as cnt FROM conversations")
        assert rows[0]["cnt"] == 1

    def test_create_conversation_type_injection(self, db: sqlite3.Connection) -> None:
        with pytest.raises(ValueError, match="Invalid conversation type"):
            create_conversation(db, title="Bad", conversation_type="chat'; DROP TABLE conversations;--")

    def test_update_type_injection(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Target")
        with pytest.raises(ValueError, match="Invalid conversation type"):
            update_conversation_type(db, conv["id"], "note'; DROP TABLE messages;--")

    def test_valid_types_are_exactly_three(self, db: sqlite3.Connection) -> None:
        from anteroom.services.storage import VALID_CONVERSATION_TYPES

        assert VALID_CONVERSATION_TYPES == {"chat", "note", "document"}

    def test_invalid_type_filter_ignored_returns_all(self, db: sqlite3.Connection) -> None:
        """Invalid type filter is silently ignored, returning unfiltered results."""
        create_conversation(db, title="Chat 1", conversation_type="chat")
        result = list_conversations(db, conversation_type="not_valid")
        assert len(result) == 1  # filter ignored, returns all

    def test_valid_type_filter_works_correctly(self, db: sqlite3.Connection) -> None:
        create_conversation(db, title="Chat", conversation_type="chat")
        create_conversation(db, title="Note", conversation_type="note")
        create_conversation(db, title="Doc", conversation_type="document")
        for t in ("chat", "note", "document"):
            result = list_conversations(db, conversation_type=t)
            assert len(result) == 1
            assert result[0]["type"] == t


class TestFTSSearchSanitization:
    """Security: verify FTS query sanitization prevents injection."""

    def test_search_with_fts_special_chars(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Test conversation")
        create_message(db, conv["id"], "user", "Hello world")
        results = list_conversations(db, search='hello" OR "')
        assert isinstance(results, list)

    def test_search_with_asterisk(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Test")
        create_message(db, conv["id"], "user", "Hello world")
        results = list_conversations(db, search="hello*")
        assert isinstance(results, list)

    def test_search_with_parentheses(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Test")
        create_message(db, conv["id"], "user", "Hello world")
        results = list_conversations(db, search="hello) OR (1=1")
        assert isinstance(results, list)

    def test_search_with_near_operator(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Test")
        create_message(db, conv["id"], "user", "Hello beautiful world")
        results = list_conversations(db, search="NEAR(hello world)")
        assert isinstance(results, list)


class TestRegisterUser:
    def test_register_new_user(self, db: sqlite3.Connection) -> None:
        register_user(db, "u1", "Alice", "pub-key-pem")
        row = db.execute_fetchone("SELECT * FROM users WHERE user_id = ?", ("u1",))
        assert row is not None
        assert row["display_name"] == "Alice"
        assert row["public_key"] == "pub-key-pem"
        assert row["created_at"]
        assert row["updated_at"]

    def test_register_user_upsert_updates(self, db: sqlite3.Connection) -> None:
        register_user(db, "u1", "Alice", "pub-key-1")
        register_user(db, "u1", "Alice Updated", "pub-key-2")
        row = db.execute_fetchone("SELECT * FROM users WHERE user_id = ?", ("u1",))
        assert row is not None
        assert row["display_name"] == "Alice Updated"
        assert row["public_key"] == "pub-key-2"

    def test_register_multiple_users(self, db: sqlite3.Connection) -> None:
        register_user(db, "u1", "Alice", "pub1")
        register_user(db, "u2", "Bob", "pub2")
        rows = db.execute_fetchall("SELECT * FROM users ORDER BY user_id")
        assert len(rows) == 2
        assert rows[0]["user_id"] == "u1"
        assert rows[1]["user_id"] == "u2"

    def test_register_user_preserves_created_at(self, db: sqlite3.Connection) -> None:
        register_user(db, "u1", "Alice", "pub1")
        row1 = db.execute_fetchone("SELECT created_at FROM users WHERE user_id = ?", ("u1",))
        created_at = row1["created_at"]
        register_user(db, "u1", "Alice v2", "pub2")
        row2 = db.execute_fetchone("SELECT created_at FROM users WHERE user_id = ?", ("u1",))
        assert row2["created_at"] == created_at


class TestDeleteMessage:
    def test_delete_message_removes_it(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Del")
        m1 = create_message(db, conv["id"], "user", "keep")
        m2 = create_message(db, conv["id"], "user", "remove")
        result = delete_message(db, conv["id"], m2["id"])
        assert result is True
        msgs = list_messages(db, conv["id"])
        assert len(msgs) == 1
        assert msgs[0]["id"] == m1["id"]

    def test_delete_message_wrong_conversation(self, db: sqlite3.Connection) -> None:
        conv1 = create_conversation(db, title="Conv1")
        conv2 = create_conversation(db, title="Conv2")
        msg = create_message(db, conv1["id"], "user", "hello")
        result = delete_message(db, conv2["id"], msg["id"])
        assert result is False
        msgs = list_messages(db, conv1["id"])
        assert len(msgs) == 1

    def test_delete_message_nonexistent(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Test")
        result = delete_message(db, conv["id"], "nonexistent-id")
        assert result is False

    def test_delete_message_updates_conversation_timestamp(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Timestamp")
        msg = create_message(db, conv["id"], "user", "hello")
        before = get_conversation(db, conv["id"])
        assert before is not None
        delete_message(db, conv["id"], msg["id"])
        after = get_conversation(db, conv["id"])
        assert after is not None
        assert after["updated_at"] >= before["updated_at"]


class TestReplaceDocumentContent:
    def test_replace_document_content_works(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Doc", conversation_type="document")
        create_message(db, conv["id"], "user", "old content 1")
        create_message(db, conv["id"], "user", "old content 2")
        msg = replace_document_content(db, conv["id"], "new full content")
        assert msg["content"] == "new full content"
        assert msg["position"] == 0
        msgs = list_messages(db, conv["id"])
        assert len(msgs) == 1
        assert msgs[0]["content"] == "new full content"

    def test_replace_document_content_on_empty(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Empty Doc", conversation_type="document")
        msg = replace_document_content(db, conv["id"], "first content")
        assert msg["content"] == "first content"
        msgs = list_messages(db, conv["id"])
        assert len(msgs) == 1

    def test_replace_document_content_updates_timestamp(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Doc TS", conversation_type="document")
        before = get_conversation(db, conv["id"])
        assert before is not None
        replace_document_content(db, conv["id"], "content")
        after = get_conversation(db, conv["id"])
        assert after is not None
        assert after["updated_at"] >= before["updated_at"]

    def test_replace_document_content_preserves_identity(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Doc ID", conversation_type="document")
        msg = replace_document_content(db, conv["id"], "content", user_id="u1", user_display_name="Alice")
        assert msg["user_id"] == "u1"
        assert msg["user_display_name"] == "Alice"

    def test_replace_document_content_cascades_tool_calls(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Doc TC", conversation_type="document")
        msg = create_message(db, conv["id"], "user", "old content")
        create_tool_call(db, msg["id"], "read_file", "local", {"path": "/tmp"})
        tcs = list_tool_calls(db, msg["id"])
        assert len(tcs) == 1
        replace_document_content(db, conv["id"], "new content")
        tcs_after = list_tool_calls(db, msg["id"])
        assert len(tcs_after) == 0

    def test_replace_document_content_multiple_replaces(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Multi Replace", conversation_type="document")
        replace_document_content(db, conv["id"], "v1")
        replace_document_content(db, conv["id"], "v2")
        msg = replace_document_content(db, conv["id"], "v3")
        assert msg["content"] == "v3"
        msgs = list_messages(db, conv["id"])
        assert len(msgs) == 1
        assert msgs[0]["content"] == "v3"


class TestDeleteMessageCascade:
    def test_delete_message_cascades_tool_calls(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Cascade TC")
        msg = create_message(db, conv["id"], "assistant", "I used a tool")
        create_tool_call(db, msg["id"], "bash", "local", {"command": "ls"})
        assert len(list_tool_calls(db, msg["id"])) == 1
        delete_message(db, conv["id"], msg["id"])
        assert len(list_tool_calls(db, msg["id"])) == 0

    def test_delete_message_preserves_other_messages(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Preserve")
        m1 = create_message(db, conv["id"], "user", "first")
        m2 = create_message(db, conv["id"], "assistant", "second")
        m3 = create_message(db, conv["id"], "user", "third")
        delete_message(db, conv["id"], m2["id"])
        msgs = list_messages(db, conv["id"])
        assert len(msgs) == 2
        assert msgs[0]["id"] == m1["id"]
        assert msgs[1]["id"] == m3["id"]

    def test_delete_only_message(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Single")
        msg = create_message(db, conv["id"], "user", "only one")
        result = delete_message(db, conv["id"], msg["id"])
        assert result is True
        msgs = list_messages(db, conv["id"])
        assert len(msgs) == 0

    def test_delete_message_with_multiple_tool_calls(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="Multi TC")
        msg = create_message(db, conv["id"], "assistant", "I ran tools")
        create_tool_call(db, msg["id"], "bash", "local", {"command": "ls"})
        create_tool_call(db, msg["id"], "read_file", "local", {"path": "/tmp"})
        create_tool_call(db, msg["id"], "glob_files", "local", {"pattern": "*.py"})
        assert len(list_tool_calls(db, msg["id"])) == 3
        delete_message(db, conv["id"], msg["id"])
        assert len(list_tool_calls(db, msg["id"])) == 0

    def test_delete_message_sql_injection_in_id(self, db: sqlite3.Connection) -> None:
        conv = create_conversation(db, title="SQLi")
        create_message(db, conv["id"], "user", "safe")
        result = delete_message(db, conv["id"], "'; DROP TABLE messages;--")
        assert result is False
        msgs = list_messages(db, conv["id"])
        assert len(msgs) == 1


class TestSaveAttachment:
    def test_save_text_attachment(self, db: ThreadSafeConnection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Test")
        msg = create_message(db, conv["id"], "user", "See attached")
        result = save_attachment(db, msg["id"], conv["id"], "notes.txt", "text/plain", b"Hello", tmp_path)
        assert result["filename"] == "notes.txt"
        assert result["mime_type"] == "text/plain"
        assert result["size_bytes"] == 5

    def test_save_markdown_attachment(self, db: ThreadSafeConnection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Test")
        msg = create_message(db, conv["id"], "user", "See attached")
        result = save_attachment(db, msg["id"], conv["id"], "readme.md", "text/markdown", b"# Hello", tmp_path)
        assert result["filename"] == "readme.md"

    def test_save_json_attachment(self, db: ThreadSafeConnection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Test")
        msg = create_message(db, conv["id"], "user", "See attached")
        result = save_attachment(db, msg["id"], conv["id"], "data.json", "application/json", b"{}", tmp_path)
        assert result["filename"] == "data.json"

    def test_save_docx_attachment(self, db: ThreadSafeConnection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Test")
        msg = create_message(db, conv["id"], "user", "See attached")
        zip_header = b"PK\x03\x04" + b"\x00" * 26
        mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
        result = save_attachment(db, msg["id"], conv["id"], "doc.docx", mime, zip_header, tmp_path)
        assert result["filename"] == "doc.docx"

    def test_reject_unsupported_type(self, db: ThreadSafeConnection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Test")
        msg = create_message(db, conv["id"], "user", "See attached")
        with pytest.raises(ValueError, match="Unsupported file type"):
            save_attachment(db, msg["id"], conv["id"], "bad.exe", "application/x-executable", b"MZ", tmp_path)

    def test_reject_oversized_file(self, db: ThreadSafeConnection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Test")
        msg = create_message(db, conv["id"], "user", "See attached")
        with pytest.raises(ValueError, match="maximum size"):
            save_attachment(db, msg["id"], conv["id"], "big.txt", "text/plain", b"x" * (11 * 1024 * 1024), tmp_path)

    def test_octet_stream_text_passes(self, db: ThreadSafeConnection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Test")
        msg = create_message(db, conv["id"], "user", "See attached")
        result = save_attachment(
            db, msg["id"], conv["id"], "file.md", "application/octet-stream", b"# Markdown content", tmp_path
        )
        assert result["filename"] == "file.md"

    def test_octet_stream_binary_rejected(self, db: ThreadSafeConnection, tmp_path: Path) -> None:
        conv = create_conversation(db, title="Test")
        msg = create_message(db, conv["id"], "user", "See attached")
        binary_data = bytes(range(256)) * 10
        with pytest.raises(ValueError, match="Cannot verify file content type"):
            save_attachment(db, msg["id"], conv["id"], "file.md", "application/octet-stream", binary_data, tmp_path)


# ---------------------------------------------------------------------------
# Agent Runs (#887)
# ---------------------------------------------------------------------------


def _make_conv(db: ThreadSafeConnection) -> dict:
    return create_conversation(db, title="Test Conversation")


class TestAgentRuns:
    def test_create_agent_run(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        assert run["status"] == "pending"
        assert run["kind"] == "turn"
        assert run["conversation_id"] == conv["id"]
        assert "id" in run
        assert "created_at" in run

    def test_create_with_optional_fields(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(
            db,
            conversation_id=conv["id"],
            kind="deploy",
            title="Deploy to staging",
            space_id="space-1",
            metadata={"target": "staging"},
        )
        assert run["title"] == "Deploy to staging"
        assert run["space_id"] == "space-1"
        assert run["metadata"] == {"target": "staging"}

    def test_get_agent_run(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        fetched = get_agent_run(db, run["id"])
        assert fetched is not None
        assert fetched["id"] == run["id"]
        assert fetched["kind"] == "turn"

    def test_get_agent_run_missing(self, db: ThreadSafeConnection) -> None:
        assert get_agent_run(db, "nonexistent") is None

    def test_get_agent_run_deserializes_metadata(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn", metadata={"key": "value"})
        fetched = get_agent_run(db, run["id"])
        assert fetched is not None
        assert fetched["metadata"] == {"key": "value"}
        assert "metadata_json" not in fetched

    def test_list_agent_runs(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        create_agent_run(db, conversation_id=conv["id"], kind="turn")
        create_agent_run(db, conversation_id=conv["id"], kind="deploy")
        runs = list_agent_runs(db, conv["id"])
        assert len(runs) == 2

    def test_list_agent_runs_filter_by_status(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        r1 = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        create_agent_run(db, conversation_id=conv["id"], kind="turn")
        update_agent_run(db, r1["id"], status="completed")
        pending = list_agent_runs(db, conv["id"], status="pending")
        assert len(pending) == 1
        completed = list_agent_runs(db, conv["id"], status="completed")
        assert len(completed) == 1

    def test_list_agent_runs_filter_by_kind(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        create_agent_run(db, conversation_id=conv["id"], kind="turn")
        create_agent_run(db, conversation_id=conv["id"], kind="deploy")
        turns = list_agent_runs(db, conv["id"], kind="turn")
        assert len(turns) == 1
        assert turns[0]["kind"] == "turn"

    def test_list_agent_runs_filter_by_space_id(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        create_agent_run(db, conversation_id=conv["id"], kind="turn", space_id="s1")
        create_agent_run(db, conversation_id=conv["id"], kind="turn", space_id="s2")
        results = list_agent_runs(db, conv["id"], space_id="s1")
        assert len(results) == 1
        assert results[0]["space_id"] == "s1"

    def test_list_agent_runs_pagination(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        for _ in range(5):
            create_agent_run(db, conversation_id=conv["id"], kind="turn")
        page1 = list_agent_runs(db, conv["id"], limit=2, offset=0)
        page2 = list_agent_runs(db, conv["id"], limit=2, offset=2)
        assert len(page1) == 2
        assert len(page2) == 2
        assert page1[0]["id"] != page2[0]["id"]

    def test_list_agent_runs_ordered_by_created_at_desc(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        r1 = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        r2 = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        runs = list_agent_runs(db, conv["id"])
        assert runs[0]["id"] == r2["id"]
        assert runs[1]["id"] == r1["id"]

    def test_list_agent_runs_scoped_to_conversation(self, db: ThreadSafeConnection) -> None:
        c1 = _make_conv(db)
        c2 = _make_conv(db)
        create_agent_run(db, conversation_id=c1["id"], kind="turn")
        create_agent_run(db, conversation_id=c2["id"], kind="turn")
        assert len(list_agent_runs(db, c1["id"])) == 1
        assert len(list_agent_runs(db, c2["id"])) == 1

    def test_update_agent_run_status(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        updated = update_agent_run(db, run["id"], status="running")
        assert updated is not None
        assert updated["status"] == "running"
        assert updated["updated_at"] > run["updated_at"]

    def test_update_agent_run_rejects_invalid_status(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        with pytest.raises(ValueError, match="Invalid agent run status"):
            update_agent_run(db, run["id"], status="BOGUS")

    def test_update_agent_run_rejects_disallowed_column(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        with pytest.raises(ValueError, match="not in allowed"):
            update_agent_run(db, run["id"], conversation_id="other")

    def test_update_agent_run_multiple_fields(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        updated = update_agent_run(
            db, run["id"], status="completed", completed_at="2025-01-01T00:00:00", duration_ms=1500
        )
        assert updated is not None
        assert updated["status"] == "completed"
        assert updated["completed_at"] == "2025-01-01T00:00:00"
        assert updated["duration_ms"] == 1500

    def test_delete_agent_run(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        assert delete_agent_run(db, run["id"]) is True
        assert get_agent_run(db, run["id"]) is None

    def test_delete_agent_run_nonexistent(self, db: ThreadSafeConnection) -> None:
        assert delete_agent_run(db, "nonexistent") is False

    def test_parent_child_relationship(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        parent = create_agent_run(db, conversation_id=conv["id"], kind="deploy")
        child = create_agent_run(db, conversation_id=conv["id"], kind="subtask", parent_run_id=parent["id"])
        assert child["parent_run_id"] == parent["id"]
        fetched = get_agent_run(db, child["id"])
        assert fetched is not None
        assert fetched["parent_run_id"] == parent["id"]

    def test_cascade_on_conversation_delete(self, db: ThreadSafeConnection, tmp_path: Path) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        append_agent_run_event(db, run_id=run["id"], event_type="started")
        delete_conversation(db, conv["id"], tmp_path)
        assert get_agent_run(db, run["id"]) is None

    def test_count_active_agent_runs(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        create_agent_run(db, conversation_id=conv["id"], kind="turn")
        r2 = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        r3 = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        update_agent_run(db, r2["id"], status="completed")
        update_agent_run(db, r3["id"], status="failed")
        assert count_active_agent_runs(db, conv["id"]) == 1

    def test_cancel_requested_is_active(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        update_agent_run(db, run["id"], status="cancel_requested")
        assert count_active_agent_runs(db, conv["id"]) == 1

    def test_update_metadata(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        updated = update_agent_run(db, run["id"], metadata={"key": "new_value"})
        assert updated is not None
        assert updated["metadata"] == {"key": "new_value"}

    def test_list_rejects_invalid_status(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        with pytest.raises(ValueError, match="Invalid agent run status"):
            list_agent_runs(db, conv["id"], status="BOGUS")

    def test_create_rejects_oversized_metadata(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        big_meta = {"data": "x" * 70000}
        with pytest.raises(ValueError, match="metadata_json exceeds"):
            create_agent_run(db, conversation_id=conv["id"], kind="turn", metadata=big_meta)

    def test_update_rejects_oversized_metadata(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        big_meta = {"data": "x" * 70000}
        with pytest.raises(ValueError, match="metadata_json exceeds"):
            update_agent_run(db, run["id"], metadata=big_meta)


class TestAgentRunEvents:
    def test_append_event(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        evt = append_agent_run_event(db, run_id=run["id"], event_type="token")
        assert evt["event_type"] == "token"
        assert evt["run_id"] == run["id"]
        assert evt["id"] is not None

    def test_append_event_with_payload(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        evt = append_agent_run_event(
            db, run_id=run["id"], event_type="tool_call", payload={"tool": "bash", "args": {"cmd": "ls"}}
        )
        assert evt["payload"] == {"tool": "bash", "args": {"cmd": "ls"}}

    def test_arbitrary_event_type_accepted(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        evt = append_agent_run_event(db, run_id=run["id"], event_type="custom_event_xyz")
        assert evt["event_type"] == "custom_event_xyz"

    def test_list_events_ordered_by_id(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        e1 = append_agent_run_event(db, run_id=run["id"], event_type="start")
        append_agent_run_event(db, run_id=run["id"], event_type="token")
        e3 = append_agent_run_event(db, run_id=run["id"], event_type="done")
        events = list_agent_run_events(db, run["id"])
        assert len(events) == 3
        assert events[0]["id"] == e1["id"]
        assert events[2]["id"] == e3["id"]

    def test_list_events_filter_by_type(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        append_agent_run_event(db, run_id=run["id"], event_type="token")
        append_agent_run_event(db, run_id=run["id"], event_type="token")
        append_agent_run_event(db, run_id=run["id"], event_type="error")
        tokens = list_agent_run_events(db, run["id"], event_type="token")
        assert len(tokens) == 2

    def test_list_events_pagination(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        for i in range(5):
            append_agent_run_event(db, run_id=run["id"], event_type=f"step_{i}")
        page = list_agent_run_events(db, run["id"], limit=2, offset=2)
        assert len(page) == 2
        assert page[0]["event_type"] == "step_2"

    def test_event_payload_deserialization(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        append_agent_run_event(
            db, run_id=run["id"], event_type="tool_result", payload={"status": "ok", "data": [1, 2, 3]}
        )
        events = list_agent_run_events(db, run["id"])
        assert events[0]["payload"] == {"status": "ok", "data": [1, 2, 3]}
        assert "payload_json" not in events[0]

    def test_null_payload(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        append_agent_run_event(db, run_id=run["id"], event_type="heartbeat")
        events = list_agent_run_events(db, run["id"])
        assert events[0]["payload"] is None

    def test_cascade_on_run_delete(self, db: ThreadSafeConnection) -> None:
        conv = _make_conv(db)
        run = create_agent_run(db, conversation_id=conv["id"], kind="turn")
        append_agent_run_event(db, run_id=run["id"], event_type="start")
        append_agent_run_event(db, run_id=run["id"], event_type="done")
        delete_agent_run(db, run["id"])
        events = list_agent_run_events(db, run["id"])
        assert len(events) == 0
