"""Tests for promptvault.search module."""

from __future__ import annotations

import sqlite3
from pathlib import Path

import pytest

from promptvault.sync import build_database, parse_history
from promptvault.search import (
    SYNONYMS,
    _build_conversation_lines,
    _build_prompt_lines,
    _fts_prepare_query,
    _fts_search,
    _get_tagged_sessions,
    _get_tags_db,
    _short_project,
    _short_title,
    _tag_session,
    _untag_session,
    clean_prompt_text,
    truncate,
)


@pytest.fixture
def db_path(tmp_history: Path, tmp_output: Path) -> Path:
    """Build a test database from synthetic history."""
    sessions = parse_history(tmp_history)
    md_paths = {sid: f"2023/11/{sid}.md" for sid in sessions}
    db = tmp_output / "prompts.db"
    build_database(sessions, md_paths, db)
    return db


class TestFTSSearch:
    def test_search_finds_matching_prompt(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        rows = conn.execute(
            """
            SELECT p.prompt_text
            FROM prompts_fts
            JOIN prompts p ON prompts_fts.rowid = p.id
            WHERE prompts_fts MATCH 'pytest'
            """,
        ).fetchall()
        assert len(rows) == 1
        assert "pytest" in rows[0][0]

    def test_search_with_bm25_ranking(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        rows = conn.execute(
            """
            SELECT p.prompt_text, bm25(prompts_fts) as rank
            FROM prompts_fts
            JOIN prompts p ON prompts_fts.rowid = p.id
            WHERE prompts_fts MATCH 'bug'
            ORDER BY rank
            """,
        ).fetchall()
        assert len(rows) >= 1
        # BM25 scores are negative; ascending order = best match first (least negative)
        ranks = [r[1] for r in rows]
        assert ranks == sorted(ranks), "BM25 ranks should be in ascending order"
        # Verify the search actually found bug-related content
        assert any("bug" in r[0].lower() for r in rows)

    def test_search_no_results(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        rows = conn.execute(
            "SELECT * FROM prompts_fts WHERE prompts_fts MATCH 'xyznonexistent'",
        ).fetchall()
        assert len(rows) == 0

    def test_prefix_search_works(self, db_path: Path):
        """Partial word 'pytes' should match 'pytest' via prefix wildcard."""
        conn = sqlite3.connect(str(db_path))
        fts_q = _fts_prepare_query("pytes")
        rows = conn.execute(
            "SELECT * FROM prompts_fts WHERE prompts_fts MATCH ?",
            (fts_q,),
        ).fetchall()
        assert len(rows) >= 1

    def test_prefix_search_single_char(self, db_path: Path):
        """Single char 'a' should match via prefix."""
        conn = sqlite3.connect(str(db_path))
        fts_q = _fts_prepare_query("a")
        rows = conn.execute(
            "SELECT * FROM prompts_fts WHERE prompts_fts MATCH ?",
            (fts_q,),
        ).fetchall()
        assert len(rows) >= 1

    def test_fts_search_with_prefix(self, db_path: Path):
        """_fts_search should find results for partial words."""
        conn = sqlite3.connect(str(db_path))
        rows = _fts_search(conn, "authenti")  # partial "authentication"
        assert len(rows) >= 1
        assert any("authentication" in r[0].lower() for r in rows)


class TestFtsPrepareQuery:
    def test_adds_wildcard_to_last_word(self):
        assert _fts_prepare_query("super") == "super*"

    def test_adds_wildcard_to_multiword(self):
        assert _fts_prepare_query("api parity") == "api parity*"

    def test_empty_query(self):
        assert _fts_prepare_query("") == ""

    def test_single_word(self):
        assert _fts_prepare_query("docker") == "docker*"


class TestBuildConversationLines:
    def test_returns_lines_without_query(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        lines = _build_conversation_lines(conn)
        # Should return conversations with prompt_count > 0
        assert len(lines) >= 1
        # Each line has tab-separated fields: md_path\tvisible\tsession_id
        for line in lines:
            parts = line.split("\t")
            assert len(parts) == 3
            assert parts[0].endswith(".md")  # md_path

    def test_returns_lines_with_query(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        lines = _build_conversation_lines(conn, "pytest")
        assert len(lines) >= 1

    def test_returns_empty_for_nonsense_query(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        lines = _build_conversation_lines(conn, "xyznonexistent")
        assert len(lines) == 0

    def test_prefix_query_finds_results(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        lines = _build_conversation_lines(conn, "pytes")  # partial "pytest"
        assert len(lines) >= 1

    def test_filters_empty_sessions(self, db_path: Path):
        """Sessions with only slash commands (0 real prompts) should be excluded."""
        conn = sqlite3.connect(str(db_path))
        lines = _build_conversation_lines(conn)
        for line in lines:
            visible = line.split("\t")[1]
            # Should not contain "0p"
            assert " 0p " not in visible


class TestRecentQuery:
    def test_recent_returns_ordered(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        rows = conn.execute(
            "SELECT timestamp FROM prompts ORDER BY timestamp DESC LIMIT 5",
        ).fetchall()
        timestamps = [r[0] for r in rows]
        assert timestamps == sorted(timestamps, reverse=True)


class TestListConversations:
    def test_list_all(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        rows = conn.execute("SELECT * FROM conversations ORDER BY start_ts DESC").fetchall()
        assert len(rows) == 4

    def test_filter_by_project(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        rows = conn.execute(
            "SELECT * FROM conversations WHERE project LIKE ?",
            ("%project-b%",),
        ).fetchall()
        assert len(rows) == 1


class TestStats:
    def test_counts(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        conv_count = conn.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
        prompt_count = conn.execute("SELECT COUNT(*) FROM prompts").fetchone()[0]
        assert conv_count == 4
        assert prompt_count > 0

    def test_display_name_column_exists(self, db_path: Path):
        conn = sqlite3.connect(str(db_path))
        row = conn.execute("SELECT display_name FROM conversations LIMIT 1").fetchone()
        assert row is not None
        assert len(row[0]) > 0


class TestCleanPromptText:
    def test_strips_pasted_text_markers(self):
        assert "analyze" in clean_prompt_text("[Pasted text #1 +3 lines] analyze")

    def test_image_only_returns_label(self):
        assert clean_prompt_text("[Image #14]") == "(image)"

    def test_multiple_images_returns_plural(self):
        assert clean_prompt_text("[Image #1][Image #2]") == "(images)"

    def test_image_with_text_keeps_text(self):
        result = clean_prompt_text("[Image #1] check this screenshot")
        assert "check this screenshot" in result
        assert "[Image" not in result

    def test_empty_after_cleanup(self):
        assert clean_prompt_text("[Pasted text #1]") == ""


class TestTruncate:
    def test_short_text_unchanged(self):
        assert truncate("hello world") == "hello world"

    def test_long_text_truncated(self):
        long = "a" * 200
        result = truncate(long)
        assert len(result) == 123  # 120 + "..."
        assert result.endswith("...")

    def test_empty_returns_label(self):
        assert truncate("") == "(empty)"


class TestShortTitle:
    def test_limits_to_4_words(self):
        result = _short_title("one two three four five six seven")
        words = result.split()
        assert len(words) == 4

    def test_short_text_unchanged(self):
        assert _short_title("hello world") == "hello world"

    def test_max_35_chars(self):
        result = _short_title("verylongword " * 5)
        assert len(result) <= 35

    def test_cleans_markers(self):
        result = _short_title("[Image #1] check this screenshot now please")
        assert "[Image" not in result
        assert len(result.split()) <= 4


class TestBuildPromptLines:
    def test_returns_lines_without_query(self, db_path: Path):
        """No query → returns recent prompts with correct format."""
        conn = sqlite3.connect(str(db_path))
        lines = _build_prompt_lines(conn)
        assert len(lines) >= 1
        for line in lines:
            parts = line.split("\t")
            assert len(parts) == 2, f"Expected tab-separated md_path\\tvisible, got: {line!r}"
            assert parts[0].endswith(".md")

    def test_returns_lines_with_fts_query(self, db_path: Path):
        """FTS query filters results to matching prompts."""
        conn = sqlite3.connect(str(db_path))
        lines = _build_prompt_lines(conn, "pytest")
        assert len(lines) >= 1
        # The visible part should contain something from the matched prompt
        for line in lines:
            parts = line.split("\t")
            assert parts[0].endswith(".md")

    def test_empty_query_returns_recent(self, db_path: Path):
        """Empty string query → returns recent prompts (same as None)."""
        conn = sqlite3.connect(str(db_path))
        lines_none = _build_prompt_lines(conn)
        lines_empty = _build_prompt_lines(conn, "")
        # Both should return the same count (recent prompts)
        assert len(lines_empty) == len(lines_none)

    def test_excludes_slash_commands(self, db_path: Path):
        """Slash commands like /help should not appear in prompt lines."""
        conn = sqlite3.connect(str(db_path))
        lines = _build_prompt_lines(conn)
        for line in lines:
            visible = line.split("\t")[1]
            assert not visible.strip().endswith("/help")

    def test_no_results_for_nonsense(self, db_path: Path):
        """Nonsense query → empty list."""
        conn = sqlite3.connect(str(db_path))
        lines = _build_prompt_lines(conn, "xyznonexistent999")
        assert lines == []

    def test_line_format_contains_date_and_project(self, db_path: Path):
        """Visible part should contain date (MM-DD HH:MM) and project info."""
        conn = sqlite3.connect(str(db_path))
        lines = _build_prompt_lines(conn)
        assert len(lines) >= 1
        # Check first line has date-like pattern in visible part
        visible = lines[0].split("\t")[1]
        # Should contain MM-DD HH:MM pattern
        import re

        assert re.search(r"\d{2}-\d{2} \d{2}:\d{2}", visible), f"No date pattern in: {visible!r}"


class TestShortProject:
    def test_home_dir_returns_tilde(self):
        from pathlib import Path

        home = str(Path.home())
        assert _short_project(home) == "~"

    def test_empty_returns_tilde(self):
        assert _short_project("") == "~"

    def test_short_name_unchanged(self):
        assert _short_project("/Users/test/myproject") == "myproject"

    def test_long_name_truncated(self):
        result = _short_project("/Users/test/a-very-long-project-name-here")
        assert len(result) <= 20
        assert result.endswith("…")


# ---------------------------------------------------------------------------
# A1: Synonym expansion + --scheme=history
# ---------------------------------------------------------------------------


class TestSynonymExpansion:
    def test_synonyms_dict_exists_and_has_entries(self):
        """SYNONYMS must be a dict with at least the documented keys."""
        assert isinstance(SYNONYMS, dict)
        assert "test" in SYNONYMS
        assert "fix" in SYNONYMS
        assert "add" in SYNONYMS

    def test_fts_prepare_query_expands_synonym(self):
        """A single synonym word should expand to OR clause."""
        result = _fts_prepare_query("fix")
        # Must contain OR-expanded synonyms for "fix"
        assert "OR" in result
        for syn in SYNONYMS["fix"]:
            assert syn in result

    def test_fts_prepare_query_preserves_non_synonym_words(self):
        """Words not in SYNONYMS should pass through with prefix wildcard on last."""
        result = _fts_prepare_query("authentication")
        # Not a synonym key, so no OR expansion
        assert "OR" not in result
        assert "authentication*" in result

    def test_fts_prepare_query_mixed_synonym_and_plain(self):
        """Mixed query: synonym word expands, plain word gets wildcard."""
        result = _fts_prepare_query("fix auth")
        assert "OR" in result  # "fix" expanded
        assert "auth*" in result  # last word gets wildcard

    def test_fts_prepare_query_multiple_synonyms(self):
        """Multiple synonym words should each expand."""
        result = _fts_prepare_query("test fix")
        # Both "test" and "fix" should be expanded
        for syn in SYNONYMS["test"]:
            assert syn in result
        for syn in SYNONYMS["fix"]:
            assert syn in result

    def test_fts_prepare_query_strips_special_chars(self):
        """FTS5 special characters are sanitized; hyphens become word separators."""
        # Trailing hyphen (the reported bug) — hyphen stripped, "best" is the last word
        assert _fts_prepare_query("best-") == "best*"
        # Hyphen splits into two words: "pre" AND "commit*"
        assert _fts_prepare_query("pre-commit") == "pre commit*"
        # "best-p" splits into "best" AND "p*" (typeahead on last token)
        assert _fts_prepare_query("best-p") == "best p*"
        # Quotes stripped
        assert _fts_prepare_query('"best"') == "best*"
        # Only special chars → empty
        assert _fts_prepare_query("-") == ""
        # "fix-" → "fix" (synonym expanded), "auth" → wildcard
        result = _fts_prepare_query("fix- auth")
        assert "OR" in result  # "fix" expanded as synonym
        assert "auth*" in result
        # Slash stripped — FTS5 chokes on "/" (syntax error near "/")
        assert _fts_prepare_query("/best-pr") == "best pr*"
        assert _fts_prepare_query("/best") == "best*"
        assert _fts_prepare_query("/") == ""


# ---------------------------------------------------------------------------
# A2: Tags database CRUD
# ---------------------------------------------------------------------------


class TestTagsDatabase:
    def test_get_tags_db_creates_file_and_table(self, tmp_path):
        """_get_tags_db creates tags.db with the tags table."""
        db_path = tmp_path / "prompts.db"
        db_path.touch()  # Simulate existing prompts.db
        conn = _get_tags_db(db_path)
        # Table must exist
        tables = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name='tags'"
        ).fetchall()
        assert len(tables) == 1
        # tags.db must be sibling of prompts.db
        tags_db_path = tmp_path / "tags.db"
        assert tags_db_path.exists()
        conn.close()

    def test_tag_session_adds_tag(self, tmp_path):
        """_tag_session inserts a tag for a session."""
        db_path = tmp_path / "prompts.db"
        db_path.touch()
        conn = _get_tags_db(db_path)
        _tag_session(conn, "sess-1", "bookmarked")
        rows = conn.execute("SELECT * FROM tags WHERE session_id='sess-1'").fetchall()
        assert len(rows) == 1
        assert rows[0][1] == "bookmarked"
        conn.close()

    def test_tag_session_idempotent(self, tmp_path):
        """Tagging the same session+tag twice should not raise or duplicate."""
        db_path = tmp_path / "prompts.db"
        db_path.touch()
        conn = _get_tags_db(db_path)
        _tag_session(conn, "sess-1", "bookmarked")
        _tag_session(conn, "sess-1", "bookmarked")  # duplicate
        rows = conn.execute(
            "SELECT * FROM tags WHERE session_id='sess-1' AND tag='bookmarked'"
        ).fetchall()
        assert len(rows) == 1
        conn.close()

    def test_untag_session_removes_tag(self, tmp_path):
        """_untag_session removes a specific tag."""
        db_path = tmp_path / "prompts.db"
        db_path.touch()
        conn = _get_tags_db(db_path)
        _tag_session(conn, "sess-1", "bookmarked")
        _untag_session(conn, "sess-1", "bookmarked")
        rows = conn.execute("SELECT * FROM tags WHERE session_id='sess-1'").fetchall()
        assert len(rows) == 0
        conn.close()

    def test_untag_nonexistent_is_noop(self, tmp_path):
        """Untagging a non-existent tag should not raise."""
        db_path = tmp_path / "prompts.db"
        db_path.touch()
        conn = _get_tags_db(db_path)
        _untag_session(conn, "sess-1", "nonexistent")  # should not raise
        conn.close()

    def test_get_tagged_sessions_returns_matching(self, tmp_path):
        """_get_tagged_sessions returns session IDs with the given tag."""
        db_path = tmp_path / "prompts.db"
        db_path.touch()
        conn = _get_tags_db(db_path)
        _tag_session(conn, "sess-1", "bookmarked")
        _tag_session(conn, "sess-2", "bookmarked")
        _tag_session(conn, "sess-3", "important")
        result = _get_tagged_sessions(conn, "bookmarked")
        assert set(result) == {"sess-1", "sess-2"}
        conn.close()

    def test_get_tagged_sessions_empty_when_no_tags(self, tmp_path):
        """_get_tagged_sessions returns empty list when no tags exist."""
        db_path = tmp_path / "prompts.db"
        db_path.touch()
        conn = _get_tags_db(db_path)
        result = _get_tagged_sessions(conn, "bookmarked")
        assert result == []
        conn.close()

    def test_tags_persist_across_connections(self, tmp_path):
        """Tags survive closing and reopening the connection (separate DB)."""
        db_path = tmp_path / "prompts.db"
        db_path.touch()
        conn = _get_tags_db(db_path)
        _tag_session(conn, "sess-1", "bookmarked")
        conn.close()
        # Reopen
        conn2 = _get_tags_db(db_path)
        result = _get_tagged_sessions(conn2, "bookmarked")
        assert result == ["sess-1"]
        conn2.close()
