"""Tests for the search index table and message indexing."""

from __future__ import annotations

import json
from pathlib import Path

import pytest


@pytest.fixture
def search_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """Point get_db_path at a temporary directory so tests use an isolated DB."""
    db_path = tmp_path / "sessions.db"
    monkeypatch.setattr("icarus.session.threads.get_db_path", lambda: db_path)
    # Reset module-level flag so each test re-creates the table in its own DB
    monkeypatch.setattr("icarus.session.search._search_table_ensured", set())
    return db_path


class TestEnsureSearchIndexTable:
    """Verify _ensure_search_index_table creates the table and is idempotent."""

    @pytest.mark.asyncio
    async def test_creates_table(self, search_db: Path) -> None:
        """Table should exist after calling _ensure_search_index_table."""
        from icarus.session.search import _ensure_search_index_table
        from icarus.session.threads import _connect, _table_exists

        async with _connect() as conn:
            assert not await _table_exists(conn, "message_search_index")
            await _ensure_search_index_table(conn)
            assert await _table_exists(conn, "message_search_index")

    @pytest.mark.asyncio
    async def test_idempotent(self, search_db: Path) -> None:
        """Calling _ensure_search_index_table twice should not raise."""
        from icarus.session.search import _ensure_search_index_table
        from icarus.session.threads import _connect, _table_exists

        async with _connect() as conn:
            await _ensure_search_index_table(conn)
            await _ensure_search_index_table(conn)
            assert await _table_exists(conn, "message_search_index")

    @pytest.mark.asyncio
    async def test_table_schema(self, search_db: Path) -> None:
        """Table should have the expected columns."""
        from icarus.session.search import _ensure_search_index_table
        from icarus.session.threads import _connect

        async with _connect() as conn:
            await _ensure_search_index_table(conn)
            async with conn.execute("PRAGMA table_info(message_search_index)") as cur:
                columns = {row[1]: row[2] for row in await cur.fetchall()}

        assert columns == {
            "thread_id": "TEXT",
            "message_id": "TEXT",
            "role": "TEXT",
            "content": "TEXT",
            "created_at": "TIMESTAMP",
        }


class TestIndexThreadMessages:
    """Verify index_thread_messages inserts rows correctly."""

    @pytest.mark.asyncio
    async def test_inserts_human_and_ai(self, search_db: Path) -> None:
        """Both human and AI messages should be inserted."""
        from icarus.session.search import index_thread_messages
        from icarus.session.threads import _connect

        await index_thread_messages("thread-1", "Hello there", "Hi! How can I help?")

        async with _connect() as conn:
            async with conn.execute(
                "SELECT thread_id, role, content FROM message_search_index ORDER BY role",
            ) as cur:
                rows = await cur.fetchall()

        assert len(rows) == 2
        # ai comes before human alphabetically
        assert rows[0] == ("thread-1", "ai", "Hi! How can I help?")
        assert rows[1] == ("thread-1", "human", "Hello there")

    @pytest.mark.asyncio
    async def test_message_ids_are_unique(self, search_db: Path) -> None:
        """Each inserted row should have a non-empty message_id."""
        from icarus.session.search import index_thread_messages
        from icarus.session.threads import _connect

        await index_thread_messages("thread-1", "Q1", "A1")

        async with _connect() as conn:
            async with conn.execute(
                "SELECT message_id FROM message_search_index",
            ) as cur:
                ids = [row[0] for row in await cur.fetchall()]

        assert len(ids) == 2
        assert all(ids)  # non-empty
        assert ids[0] != ids[1]  # unique

    @pytest.mark.asyncio
    async def test_truncates_long_content(self, search_db: Path) -> None:
        """Content longer than 2000 chars should be truncated."""
        from icarus.session.search import index_thread_messages
        from icarus.session.threads import _connect

        long_text = "x" * 5000
        await index_thread_messages("thread-1", long_text, "short")

        async with _connect() as conn:
            async with conn.execute(
                "SELECT content FROM message_search_index WHERE role = 'human'",
            ) as cur:
                row = await cur.fetchone()

        assert row is not None
        assert len(row[0]) == 2000

    @pytest.mark.asyncio
    async def test_created_at_is_set(self, search_db: Path) -> None:
        """Each row should have a non-null created_at timestamp."""
        from icarus.session.search import index_thread_messages
        from icarus.session.threads import _connect

        await index_thread_messages("thread-1", "Q", "A")

        async with _connect() as conn:
            async with conn.execute(
                "SELECT created_at FROM message_search_index",
            ) as cur:
                timestamps = [row[0] for row in await cur.fetchall()]

        assert len(timestamps) == 2
        assert all(ts is not None for ts in timestamps)

    @pytest.mark.asyncio
    async def test_multiple_indexing_same_thread(self, search_db: Path) -> None:
        """Indexing multiple exchanges in the same thread should accumulate rows."""
        from icarus.session.search import index_thread_messages
        from icarus.session.threads import _connect

        await index_thread_messages("thread-1", "Q1", "A1")
        await index_thread_messages("thread-1", "Q2", "A2")

        async with _connect() as conn:
            async with conn.execute(
                "SELECT COUNT(*) FROM message_search_index WHERE thread_id = 'thread-1'",
            ) as cur:
                count = (await cur.fetchone())[0]

        assert count == 4

    @pytest.mark.asyncio
    async def test_empty_content_still_inserts(self, search_db: Path) -> None:
        """Empty strings should still create rows (caller decides what to index)."""
        from icarus.session.search import index_thread_messages
        from icarus.session.threads import _connect

        await index_thread_messages("thread-1", "", "")

        async with _connect() as conn:
            async with conn.execute(
                "SELECT COUNT(*) FROM message_search_index",
            ) as cur:
                count = (await cur.fetchone())[0]

        assert count == 2


class TestExtractSnippet:
    """Verify _extract_snippet returns context around the first match."""

    def test_short_content_no_ellipsis(self) -> None:
        """When the entire content fits within the radius, no ellipsis added."""
        from icarus.session.search import _extract_snippet

        result = _extract_snippet("hello world", "world")
        assert result == "hello world"
        assert "..." not in result

    def test_match_in_middle_adds_ellipsis(self) -> None:
        """Match in the middle of a long string gets ellipsis on both sides."""
        from icarus.session.search import _extract_snippet

        content = "a" * 60 + "TARGET" + "b" * 60
        result = _extract_snippet(content, "TARGET")
        assert result is not None
        assert "TARGET" in result
        assert result.startswith("...")
        assert result.endswith("...")

    def test_match_at_start_no_prefix_ellipsis(self) -> None:
        """Match at the start should not have a prefix ellipsis."""
        from icarus.session.search import _extract_snippet

        content = "TARGET" + "x" * 100
        result = _extract_snippet(content, "TARGET")
        assert result is not None
        assert "TARGET" in result
        assert not result.startswith("...")
        assert result.endswith("...")

    def test_match_at_end_no_suffix_ellipsis(self) -> None:
        """Match at the end should not have a suffix ellipsis."""
        from icarus.session.search import _extract_snippet

        content = "x" * 100 + "TARGET"
        result = _extract_snippet(content, "TARGET")
        assert result is not None
        assert "TARGET" in result
        assert result.startswith("...")
        assert not result.endswith("...")

    def test_no_match_returns_none(self) -> None:
        """When query is not found, return None."""
        from icarus.session.search import _extract_snippet

        assert _extract_snippet("hello world", "xyz") is None

    def test_case_insensitive(self) -> None:
        """Snippet extraction should be case-insensitive."""
        from icarus.session.search import _extract_snippet

        result = _extract_snippet("Hello World", "hello")
        assert result is not None
        assert "Hello" in result


# ---------------------------------------------------------------------------
# Helpers for search_threads tests
# ---------------------------------------------------------------------------

async def _setup_checkpoints(conn, thread_id: str, updated_at: str) -> None:
    """Create a minimal checkpoints row so search can join on it."""
    import json

    await conn.execute("""
        CREATE TABLE IF NOT EXISTS checkpoints (
            thread_id TEXT,
            checkpoint_ns TEXT DEFAULT '',
            checkpoint_id TEXT DEFAULT '1',
            parent_checkpoint_id TEXT,
            type TEXT,
            checkpoint BLOB,
            metadata TEXT
        )
    """)
    metadata = json.dumps({"updated_at": updated_at})
    await conn.execute(
        "INSERT INTO checkpoints (thread_id, metadata) VALUES (?, ?)",
        (thread_id, metadata),
    )


class TestSearchThreadsByTitle:
    """Verify title-based search returns matching threads."""

    @pytest.mark.asyncio
    async def test_search_threads_by_title(self, search_db: Path) -> None:
        """Thread with a matching custom title should be found."""
        from icarus.session.search import (
            _ensure_search_index_table,
            search_threads,
        )
        from icarus.session.threads import _connect, _ensure_metadata_table

        async with _connect() as conn:
            await _setup_checkpoints(conn, "thread-abc", "2025-01-15T10:00:00")
            await _ensure_metadata_table(conn)
            await conn.execute(
                "INSERT INTO thread_metadata (thread_id, custom_title) VALUES (?, ?)",
                ("thread-abc", "My Deployment Plan"),
            )
            await _ensure_search_index_table(conn)
            await conn.commit()

        result = await search_threads("Deployment")
        results = result["results"]
        assert len(results) >= 1
        match = next(r for r in results if r["thread_id"] == "thread-abc")
        assert match["match_type"] == "title"
        assert match["title"] == "My Deployment Plan"


class TestSearchThreadsByContent:
    """Verify content-based search returns matching threads with snippets."""

    @pytest.mark.asyncio
    async def test_search_threads_by_content(self, search_db: Path) -> None:
        """Thread with matching message content should be found with a snippet."""
        from icarus.session.search import index_thread_messages, search_threads
        from icarus.session.threads import _connect

        async with _connect() as conn:
            await _setup_checkpoints(conn, "thread-xyz", "2025-01-15T12:00:00")
            await conn.commit()

        await index_thread_messages(
            "thread-xyz",
            "How do I configure Kubernetes ingress?",
            "You can use an Ingress resource with annotations.",
        )

        result = await search_threads("Kubernetes")
        results = result["results"]
        assert len(results) >= 1
        match = next(r for r in results if r["thread_id"] == "thread-xyz")
        assert match["match_type"] == "content"
        assert match["snippet"] is not None
        assert "Kubernetes" in match["snippet"]


class TestDeleteSearchIndex:
    """Verify delete_search_index removes all entries for a thread."""

    @pytest.mark.asyncio
    async def test_delete_search_index_removes_entries(self, search_db: Path) -> None:
        """After deletion, no rows should remain for that thread."""
        from icarus.session.search import (
            delete_search_index,
            index_thread_messages,
        )
        from icarus.session.threads import _connect

        await index_thread_messages("thread-del", "Question", "Answer")

        async with _connect() as conn:
            async with conn.execute(
                "SELECT COUNT(*) FROM message_search_index WHERE thread_id = 'thread-del'",
            ) as cur:
                assert (await cur.fetchone())[0] == 2

        await delete_search_index("thread-del")

        async with _connect() as conn:
            async with conn.execute(
                "SELECT COUNT(*) FROM message_search_index WHERE thread_id = 'thread-del'",
            ) as cur:
                assert (await cur.fetchone())[0] == 0


class TestSearchEmptyQuery:
    """Verify empty query returns recent threads."""

    @pytest.mark.asyncio
    async def test_search_empty_query_returns_recent_threads(
        self, search_db: Path, monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Empty query should return recent threads via list_threads."""
        from icarus.session.search import search_threads
        from icarus.session.threads import ThreadInfo

        # Mock list_threads to avoid needing real checkpoint deserialization
        async def fake_list_threads(
            limit: int = 50, include_message_count: bool = False, **kw,
        ) -> list[ThreadInfo]:
            return [
                ThreadInfo(
                    thread_id="recent-1",
                    agent_name=None,
                    updated_at="2025-01-20T10:00:00",
                    title="Recent Thread",
                    message_count=5,
                ),
            ]

        monkeypatch.setattr("icarus.session.search.list_threads", fake_list_threads)

        result = await search_threads("")
        results = result["results"]
        assert len(results) == 1
        assert results[0]["thread_id"] == "recent-1"
        assert results[0]["match_type"] == "title"
        assert results[0]["title"] == "Recent Thread"
        assert results[0]["message_count"] == 5


class TestSearchTitleMatchWinsDedup:
    """Verify deduplication — title match wins over content match."""

    @pytest.mark.asyncio
    async def test_search_title_match_wins_dedup(self, search_db: Path) -> None:
        """When a thread matches both title and content, match_type should be 'title'."""
        from icarus.session.search import index_thread_messages, search_threads
        from icarus.session.threads import _connect, _ensure_metadata_table

        thread_id = "thread-both"

        async with _connect() as conn:
            await _setup_checkpoints(conn, thread_id, "2025-01-15T14:00:00")
            await _ensure_metadata_table(conn)
            await conn.execute(
                "INSERT INTO thread_metadata (thread_id, custom_title) VALUES (?, ?)",
                (thread_id, "Kubernetes Setup Guide"),
            )
            await conn.commit()

        # Also index content that contains the same search term
        await index_thread_messages(
            thread_id,
            "How to set up Kubernetes?",
            "Here is the Kubernetes guide.",
        )

        result = await search_threads("Kubernetes")
        results = result["results"]
        assert len(results) == 1
        assert results[0]["thread_id"] == thread_id
        assert results[0]["match_type"] == "title"


# ---------------------------------------------------------------------------
# Task 3: search.py functions with explicit db_path
# ---------------------------------------------------------------------------

async def _setup_checkpoints_at(db_path: Path, thread_id: str, updated_at: str) -> None:
    """Create a minimal checkpoints row in the given DB file."""
    import aiosqlite

    async with aiosqlite.connect(str(db_path)) as conn:
        await conn.execute("""
            CREATE TABLE IF NOT EXISTS checkpoints (
                thread_id TEXT,
                checkpoint_ns TEXT DEFAULT '',
                checkpoint_id TEXT DEFAULT '1',
                parent_checkpoint_id TEXT,
                type TEXT,
                checkpoint BLOB,
                metadata TEXT
            )
        """)
        metadata = json.dumps({"updated_at": updated_at})
        await conn.execute(
            "INSERT INTO checkpoints (thread_id, metadata) VALUES (?, ?)",
            (thread_id, metadata),
        )
        await conn.commit()


class TestIndexThreadMessagesDbPath:
    """Verify index_thread_messages(db_path=...) writes to the specified DB."""

    @pytest.mark.asyncio
    async def test_index_explicit_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """index_thread_messages(db_path=...) should write to the given file."""
        monkeypatch.setattr("icarus.session.search._search_table_ensured", set())
        from icarus.session.search import index_thread_messages
        from icarus.session.threads import _connect

        db_path = tmp_path / "idx.db"

        await index_thread_messages("t1", "Hello", "World", db_path=db_path)

        async with _connect(db_path=db_path) as conn:
            async with conn.execute(
                "SELECT COUNT(*) FROM message_search_index WHERE thread_id = 't1'",
            ) as cur:
                count = (await cur.fetchone())[0]

        assert count == 2


class TestDeleteSearchIndexDbPath:
    """Verify delete_search_index(db_path=...) targets the specified DB."""

    @pytest.mark.asyncio
    async def test_delete_explicit_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """delete_search_index should remove rows from the explicit DB."""
        monkeypatch.setattr("icarus.session.search._search_table_ensured", set())
        from icarus.session.search import delete_search_index, index_thread_messages
        from icarus.session.threads import _connect

        db_path = tmp_path / "del.db"

        await index_thread_messages("t-del", "Q", "A", db_path=db_path)

        # Reset flag since index_thread_messages set it
        monkeypatch.setattr("icarus.session.search._search_table_ensured", set())
        await delete_search_index("t-del", db_path=db_path)

        async with _connect(db_path=db_path) as conn:
            async with conn.execute(
                "SELECT COUNT(*) FROM message_search_index WHERE thread_id = 't-del'",
            ) as cur:
                assert (await cur.fetchone())[0] == 0


class TestSearchThreadsDbPath:
    """Verify search_threads(db_path=...) queries the specified DB."""

    @pytest.mark.asyncio
    async def test_search_content_explicit_path(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """search_threads(db_path=...) should find indexed content in the given file."""
        monkeypatch.setattr("icarus.session.search._search_table_ensured", set())
        from icarus.session.search import index_thread_messages, search_threads

        db_path = tmp_path / "srch.db"

        # Set up a checkpoint row so the JOIN works
        await _setup_checkpoints_at(db_path, "t-srch", "2025-06-01T10:00:00")

        # Reset flag before indexing
        monkeypatch.setattr("icarus.session.search._search_table_ensured", set())
        await index_thread_messages(
            "t-srch", "Deploy with Terraform", "Sure, here is the plan.",
            db_path=db_path,
        )

        # Reset flag before search
        monkeypatch.setattr("icarus.session.search._search_table_ensured", set())
        result = await search_threads("Terraform", db_path=db_path)
        results = result["results"]
        assert len(results) >= 1
        match = next(r for r in results if r["thread_id"] == "t-srch")
        assert match["match_type"] == "content"
        assert "Terraform" in (match["snippet"] or "")

    @pytest.mark.asyncio
    async def test_search_empty_query_explicit_path(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """search_threads('', db_path=...) should delegate to list_threads with the same db_path."""
        from icarus.session.search import search_threads

        db_path = tmp_path / "empty_q.db"
        await _setup_checkpoints_at(db_path, "t-recent", "2025-06-02T08:00:00")

        result = await search_threads("", db_path=db_path)
        results = result["results"]
        assert len(results) == 1
        assert results[0]["thread_id"] == "t-recent"

    @pytest.mark.asyncio
    async def test_search_isolation(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Content indexed in db_a should not appear when searching db_b."""
        monkeypatch.setattr("icarus.session.search._search_table_ensured", set())
        from icarus.session.search import index_thread_messages, search_threads

        db_a = tmp_path / "a.db"
        db_b = tmp_path / "b.db"

        await _setup_checkpoints_at(db_a, "t-a", "2025-06-01T10:00:00")
        monkeypatch.setattr("icarus.session.search._search_table_ensured", set())
        await index_thread_messages("t-a", "secret keyword alpha", "reply", db_path=db_a)

        # db_b has an empty checkpoints table (realistic for a fresh user DB)
        # but no indexed content — searching for "alpha" should return nothing
        await _setup_checkpoints_at(db_b, "t-unrelated", "2025-07-01T00:00:00")
        monkeypatch.setattr("icarus.session.search._search_table_ensured", set())
        result = await search_threads("alpha", db_path=db_b)
        assert result["results"] == []
