"""Tests for core query operations — list, search, filter, stats, events."""

from __future__ import annotations

import sqlite3
from unittest.mock import patch

import pytest

from filigree.core import FiligreeDB


class TestListAndSearch:
    def test_list_all(self, db: FiligreeDB) -> None:
        baseline = len(db.list_issues())  # Future release singleton
        db.create_issue("A")
        db.create_issue("B")
        assert len(db.list_issues()) == baseline + 2

    def test_list_filter_status(self, db: FiligreeDB) -> None:
        open_before = len(db.list_issues(status="open"))  # Future release (planning = open category)
        a = db.create_issue("Open one")
        b = db.create_issue("Close one")
        db.close_issue(b.id)
        open_issues = db.list_issues(status="open")
        assert len(open_issues) == open_before + 1
        assert any(i.id == a.id for i in open_issues)

    def test_search(self, db: FiligreeDB) -> None:
        db.create_issue("Fix authentication bug")
        db.create_issue("Add new feature")
        results = db.search_issues("auth")
        assert len(results) == 1
        assert "auth" in results[0].title.lower()


class TestListIssuesCategoryAliases:
    """M9: list_issues category aliases 'in_progress'→'wip' and 'closed'→'done'."""

    def test_in_progress_alias(self, db: FiligreeDB) -> None:
        """status='in_progress' should expand to wip-category states."""
        issue = db.create_issue("WIP task")
        db.update_issue(issue.id, status="in_progress")
        results = db.list_issues(status="in_progress")
        assert any(i.id == issue.id for i in results)

    def test_closed_alias(self, db: FiligreeDB) -> None:
        """status='closed' should expand to done-category states."""
        issue = db.create_issue("Done task")
        db.close_issue(issue.id)
        results = db.list_issues(status="closed")
        assert any(i.id == issue.id for i in results)

    def test_wip_category_direct(self, db: FiligreeDB) -> None:
        """status='wip' should also work directly."""
        issue = db.create_issue("WIP direct")
        db.update_issue(issue.id, status="in_progress")
        results = db.list_issues(status="wip")
        assert any(i.id == issue.id for i in results)

    def test_done_category_direct(self, db: FiligreeDB) -> None:
        """status='done' should also work directly."""
        issue = db.create_issue("Done direct")
        db.close_issue(issue.id)
        results = db.list_issues(status="done")
        assert any(i.id == issue.id for i in results)


class TestListIssuesFilters:
    """M9: list_issues filter parameters beyond status."""

    def test_filter_by_label(self, db: FiligreeDB) -> None:
        a = db.create_issue("Labeled", labels=["urgent"])
        db.create_issue("Unlabeled")
        results = db.list_issues(label="urgent")
        assert len(results) >= 1
        assert any(i.id == a.id for i in results)
        assert all("urgent" in i.labels for i in results)

    def test_filter_by_assignee(self, db: FiligreeDB) -> None:
        issue = db.create_issue("Assigned")
        db.claim_issue(issue.id, assignee="alice")
        db.create_issue("Unassigned")
        results = db.list_issues(assignee="alice")
        assert any(i.id == issue.id for i in results)
        assert all(i.assignee == "alice" for i in results)

    def test_filter_by_type(self, db: FiligreeDB) -> None:
        bug = db.create_issue("A bug", type="bug")
        db.create_issue("A task", type="task")
        results = db.list_issues(type="bug")
        assert any(i.id == bug.id for i in results)
        assert all(i.type == "bug" for i in results)

    def test_filter_by_priority(self, db: FiligreeDB) -> None:
        p0 = db.create_issue("Critical", priority=0)
        db.create_issue("Normal", priority=2)
        results = db.list_issues(priority=0)
        assert any(i.id == p0.id for i in results)
        assert all(i.priority == 0 for i in results)

    def test_filter_by_parent_id(self, db: FiligreeDB) -> None:
        parent = db.create_issue("Parent", type="epic")
        child = db.create_issue("Child", parent_id=parent.id)
        db.create_issue("Orphan")
        results = db.list_issues(parent_id=parent.id)
        assert any(i.id == child.id for i in results)
        assert all(i.parent_id == parent.id for i in results)

    def test_combined_filters(self, db: FiligreeDB) -> None:
        """Multiple filters are ANDed together."""
        db.create_issue("Bug P0", type="bug", priority=0)
        db.create_issue("Task P0", type="task", priority=0)
        db.create_issue("Bug P2", type="bug", priority=2)
        results = db.list_issues(type="bug", priority=0)
        assert all(i.type == "bug" and i.priority == 0 for i in results)


class TestListIssuesBoundaries:
    """M9: list_issues negative limit/offset guards."""

    def test_negative_limit_raises(self, db: FiligreeDB) -> None:
        with pytest.raises(ValueError, match="limit must be non-negative"):
            db.list_issues(limit=-1)

    def test_negative_offset_raises(self, db: FiligreeDB) -> None:
        with pytest.raises(ValueError, match="offset must be non-negative"):
            db.list_issues(offset=-1)

    def test_zero_limit_returns_empty(self, db: FiligreeDB) -> None:
        db.create_issue("Something")
        results = db.list_issues(limit=0)
        assert results == []

    def test_offset_beyond_results(self, db: FiligreeDB) -> None:
        db.create_issue("Only one")
        results = db.list_issues(offset=9999)
        assert results == []


class TestSanitizeFtsQuery:
    """Unit tests for _sanitize_fts_query — primary defense against FTS5 injection."""

    def test_basic_tokens(self) -> None:
        from filigree.db_issues import _sanitize_fts_query

        result = _sanitize_fts_query("hello world")
        assert result == '"hello"* AND "world"*'

    def test_special_chars_stripped(self) -> None:
        from filigree.db_issues import _sanitize_fts_query

        result = _sanitize_fts_query("hello! @world#")
        assert result == '"hello"* AND "world"*'

    def test_only_special_chars_returns_empty_match(self) -> None:
        from filigree.db_issues import _sanitize_fts_query

        result = _sanitize_fts_query("!@#$%^&()")
        assert result == ""

    def test_empty_query_returns_empty_match(self) -> None:
        from filigree.db_issues import _sanitize_fts_query

        result = _sanitize_fts_query("")
        assert result == ""

    def test_embedded_double_quotes_removed(self) -> None:
        from filigree.db_issues import _sanitize_fts_query

        result = _sanitize_fts_query('"hello" "world"')
        assert result == '"hello"* AND "world"*'

    def test_wildcards_preserved(self) -> None:
        from filigree.db_issues import _sanitize_fts_query

        result = _sanitize_fts_query("fix*")
        assert result == '"fix*"*'

    def test_whitespace_only_returns_empty_match(self) -> None:
        from filigree.db_issues import _sanitize_fts_query

        result = _sanitize_fts_query("   ")
        assert result == ""


class TestEscapeLikeQuery:
    """Unit tests for _escape_like — defense against LIKE injection."""

    def test_plain_string(self) -> None:
        from filigree.db_base import _escape_like

        assert _escape_like("hello") == "%hello%"

    def test_percent_escaped(self) -> None:
        from filigree.db_base import _escape_like

        assert _escape_like("100%") == "%100\\%%"

    def test_underscore_escaped(self) -> None:
        from filigree.db_base import _escape_like

        assert _escape_like("foo_bar") == "%foo\\_bar%"

    def test_backslash_escaped(self) -> None:
        from filigree.db_base import _escape_like

        assert _escape_like("a\\b") == "%a\\\\b%"

    def test_all_special_chars(self) -> None:
        from filigree.db_base import _escape_like

        result = _escape_like("%_\\")
        assert result == "%\\%\\_\\\\%"


class TestSearchFTSFallback:
    """Bug filigree-35ef38: FTS fallback must only catch missing-table errors."""

    def test_non_fts_operational_error_propagates(self, db: FiligreeDB) -> None:
        """OperationalError unrelated to missing FTS5 must NOT be silently caught."""
        db.create_issue("Searchable item")

        original_execute = db.conn.execute

        class _SpyConn:
            """Wraps conn to intercept FTS queries."""

            def __getattr__(self, name: str) -> object:
                return getattr(db._conn, name)

            def execute(self, sql: str, params: tuple[object, ...] = ()) -> sqlite3.Cursor:
                if "issues_fts" in sql and "MATCH" in sql:
                    raise sqlite3.OperationalError("database disk image is malformed")
                return original_execute(sql, params)

        with patch.object(db, "_conn", _SpyConn()), pytest.raises(sqlite3.OperationalError, match="malformed"):
            db.search_issues("Searchable")


class TestStats:
    def test_stats_counts(self, db: FiligreeDB) -> None:
        db.create_issue("A")
        b = db.create_issue("B")
        db.close_issue(b.id)
        stats = db.get_stats()
        assert stats["by_status"]["open"] == 1
        assert stats["by_status"]["closed"] == 1

    def test_ready_count_matches_get_ready_for_template_open_states(self, db: FiligreeDB) -> None:
        bug = db.create_issue("Template-open bug", type="bug")
        ready_ids = {i.id for i in db.get_ready()}
        stats = db.get_stats()

        assert bug.id in ready_ids
        assert stats["ready_count"] == len(ready_ids)

    def test_done_category_blocker_does_not_count_as_blocked(self, db: FiligreeDB) -> None:
        blocker = db.create_issue("Blocker", type="bug")
        blocked = db.create_issue("Blocked", type="bug")
        db.add_dependency(blocked.id, blocker.id)
        db.close_issue(blocker.id, status="wont_fix")

        ready_ids = {i.id for i in db.get_ready()}
        stats = db.get_stats()

        assert blocked.id in ready_ids
        assert stats["blocked_count"] == 0
        assert stats["ready_count"] == len(ready_ids)


class TestGetStatsByCategory:
    """Verify get_stats() includes category-level counts (WFT-FR-060)."""

    def test_get_stats_by_category(self, db: FiligreeDB) -> None:
        """by_category sums issues across open/wip/done."""
        baseline_open = db.get_stats()["by_category"]["open"]  # Future release in open category
        db.create_issue("A")  # open → open category
        b = db.create_issue("B")
        db.update_issue(b.id, status="in_progress")  # wip category
        c = db.create_issue("C")
        db.close_issue(c.id)  # done category

        stats = db.get_stats()
        by_cat = stats["by_category"]
        assert by_cat["open"] == baseline_open + 1
        assert by_cat["wip"] == 1
        assert by_cat["done"] == 1

    def test_get_stats_by_category_custom_states(self, db: FiligreeDB) -> None:
        """Bug in 'triage' counts as open; bug in 'fixing' counts as wip."""
        db.create_issue("Bug in triage", type="bug")  # initial_state=triage → open category
        bug2 = db.create_issue("Bug being fixed", type="bug")
        # Move through workflow: triage → confirmed → fixing
        db.update_issue(bug2.id, status="confirmed")
        db.update_issue(bug2.id, status="fixing")

        stats = db.get_stats()
        by_cat = stats["by_category"]
        assert by_cat["open"] >= 1  # triage bug
        assert by_cat["wip"] >= 1  # fixing bug

    def test_get_stats_backward_compat(self, db: FiligreeDB) -> None:
        """by_status is still present alongside by_category."""
        db.create_issue("X")
        stats = db.get_stats()
        assert "by_status" in stats
        assert "by_category" in stats
        assert "ready_count" in stats


class TestEvents:
    def test_events_recorded_on_create(self, db: FiligreeDB) -> None:
        issue = db.create_issue("Event test")
        events = db.get_recent_events(limit=5)
        assert any(e["issue_id"] == issue.id and e["event_type"] == "created" for e in events)

    def test_events_recorded_on_status_change(self, db: FiligreeDB) -> None:
        issue = db.create_issue("Event test")
        db.update_issue(issue.id, status="in_progress")
        events = db.get_recent_events(limit=5)
        assert any(e["issue_id"] == issue.id and e["event_type"] == "status_changed" for e in events)


class TestGetEventsSince:
    def test_basic(self, db: FiligreeDB) -> None:
        issue = db.create_issue("Event source")
        # Get creation event timestamp
        events = db.get_recent_events(limit=1)
        ts = events[0]["created_at"]
        # Make a change after creation
        db.update_issue(issue.id, status="in_progress")
        since_events = db.get_events_since(ts)
        assert len(since_events) >= 1
        assert any(e["event_type"] == "status_changed" for e in since_events)

    def test_empty_when_no_events(self, db: FiligreeDB) -> None:
        result = db.get_events_since("2099-01-01T00:00:00+00:00")
        assert result == []

    def test_respects_limit(self, db: FiligreeDB) -> None:
        for i in range(5):
            db.create_issue(f"Issue {i}")
        result = db.get_events_since("2000-01-01T00:00:00+00:00", limit=2)
        assert len(result) == 2

    def test_chronological_order(self, db: FiligreeDB) -> None:
        db.create_issue("First")
        db.create_issue("Second")
        result = db.get_events_since("2000-01-01T00:00:00+00:00")
        assert len(result) >= 2
        # Events should be in ascending order
        for i in range(len(result) - 1):
            assert result[i]["created_at"] <= result[i + 1]["created_at"]


class TestActorTracking:
    def test_actor_in_update_event(self, db: FiligreeDB) -> None:
        issue = db.create_issue("Track me")
        db.update_issue(issue.id, status="in_progress", actor="agent-alpha")
        events = db.get_recent_events(limit=5)
        status_event = next(e for e in events if e["event_type"] == "status_changed")
        assert status_event["actor"] == "agent-alpha"

    def test_actor_in_close_event(self, db: FiligreeDB) -> None:
        issue = db.create_issue("Close me")
        db.close_issue(issue.id, actor="agent-beta")
        events = db.get_recent_events(limit=5)
        close_event = next(e for e in events if e["event_type"] == "status_changed" and e["new_value"] == "closed")
        assert close_event["actor"] == "agent-beta"


class TestGetStatsEmptyDoneStates:
    """Bug fix: filigree-2e5af8 — get_stats empty done_states."""

    def test_get_stats_with_normal_templates(self, db: FiligreeDB) -> None:
        """get_stats() should work normally with templates loaded."""
        db.create_issue("Issue A")
        b = db.create_issue("Issue B")
        db.close_issue(b.id)

        stats = db.get_stats()
        assert "by_status" in stats
        assert "by_category" in stats
        assert "ready_count" in stats
        assert "blocked_count" in stats
        assert stats["by_category"]["done"] >= 1

    def test_get_stats_empty_done_states_no_crash(self, db: FiligreeDB) -> None:
        """get_stats() should not crash when done_states is empty."""
        db.create_issue("Issue A")

        # Simulate empty done_states by mocking _get_states_for_category
        original_method = db._get_states_for_category

        def mock_get_states(category: str) -> list[str]:
            if category == "done":
                return []
            return original_method(category)

        with patch.object(db, "_get_states_for_category", side_effect=mock_get_states):
            # This should not raise an error
            stats = db.get_stats()
            assert "ready_count" in stats
            assert "blocked_count" in stats
            assert isinstance(stats["ready_count"], int)
            assert isinstance(stats["blocked_count"], int)

    def test_get_stats_empty_done_states_with_deps(self, db: FiligreeDB) -> None:
        """get_stats() with empty done_states should count all deps as blockers."""
        a = db.create_issue("Issue A")
        b = db.create_issue("Issue B")
        db.add_dependency(a.id, b.id)

        original_method = db._get_states_for_category

        def mock_get_states(category: str) -> list[str]:
            if category == "done":
                return []
            return original_method(category)

        with patch.object(db, "_get_states_for_category", side_effect=mock_get_states):
            stats = db.get_stats()
            # With no done states, B blocks A, so A is blocked and B is ready
            assert stats["blocked_count"] >= 1


class TestFTS5SpecialCharacters:
    """FTS5 search should handle special characters gracefully."""

    def test_search_with_special_characters_returns_valid_terms(self, db: FiligreeDB) -> None:
        """Searching for 'notification @#$%' should find issues matching 'notification'."""
        db.create_issue("Fix notification system")
        db.create_issue("Unrelated feature")
        results = db.search_issues("notification @#$%")
        assert len(results) == 1
        assert "notification" in results[0].title.lower()

    def test_search_with_only_special_characters_returns_empty(self, db: FiligreeDB) -> None:
        """Searching for only special characters should return empty, not error."""
        db.create_issue("Some issue")
        results = db.search_issues("@#$%^&()")
        assert results == []

    def test_search_with_mixed_special_and_valid(self, db: FiligreeDB) -> None:
        """Special chars mixed with valid terms should still find matches."""
        db.create_issue("Authentication bug in login")
        results = db.search_issues("auth!@#enti")
        # "auth" and "enti" are separate tokens after sanitization — but the original
        # becomes "authentication" after stripping specials, which may tokenize differently.
        # The key point is: no crash.
        assert isinstance(results, list)


class TestCountSearchResults:
    """filigree-af817d0cf3: count_search_results unit tests."""

    def test_fts_path_returns_correct_count(self, db: FiligreeDB) -> None:
        db.create_issue("Fix authentication bug")
        db.create_issue("Authentication flow rework")
        db.create_issue("Unrelated feature")
        count = db.count_search_results("authentication")
        assert count == 2

    def test_like_fallback_when_fts_unavailable(self, db: FiligreeDB) -> None:
        """When FTS table is missing, LIKE fallback still returns correct count."""
        db.create_issue("Fix notification system")
        db.create_issue("Another notification task")
        db.create_issue("Unrelated task")

        original_execute = db.conn.execute

        class _NoFTS:
            def __getattr__(self, name: str) -> object:
                return getattr(db._conn, name)

            def execute(self, sql: str, params: tuple[object, ...] = ()) -> sqlite3.Cursor:
                if "issues_fts" in sql and "MATCH" in sql:
                    raise sqlite3.OperationalError("no such table: issues_fts")
                return original_execute(sql, params)

        with patch.object(db, "_conn", _NoFTS()):
            count = db.count_search_results("notification")
        assert count == 2

    def test_special_character_sanitization(self, db: FiligreeDB) -> None:
        """Special chars in query don't cause errors and valid tokens still match."""
        db.create_issue("Fix the dashboard display")
        count = db.count_search_results("dashboard @#$%")
        assert count == 1

    def test_empty_query(self, db: FiligreeDB) -> None:
        """Empty or whitespace-only query should not crash."""
        db.create_issue("Some issue")
        count = db.count_search_results("")
        assert isinstance(count, int)

    def test_only_special_characters_returns_zero(self, db: FiligreeDB) -> None:
        """Query with only special characters returns 0, not an error."""
        db.create_issue("Normal issue")
        count = db.count_search_results("@#$%^&()")
        assert count == 0
