# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT
"""Unit tests for disabled pattern filtering in ManifestInjector.

Tests verify (OMN-1682: FEEDBACK-007):
- Patterns disabled by ID are filtered out
- Patterns disabled by class are filtered out
- Precedence: pattern_id beats pattern_class (specific beats general)
- Default: enabled (no event = enabled)
- Graceful degradation when DB is unavailable
- Filter is skipped when enable_disabled_pattern_filter is False
- Empty disabled list returns all patterns unchanged
"""

from __future__ import annotations

from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch

import pytest

from omniclaude.lib.core.manifest_injector import DisabledPattern, ManifestInjector

pytestmark = pytest.mark.unit


# =============================================================================
# Test Data
# =============================================================================


def _make_pattern(
    name: str = "test-pattern",
    pattern_id: str = "pid-1",
    pattern_type: str = "test_class",
) -> dict:
    """Create a minimal pattern dictionary matching Qdrant output format."""
    return {
        "name": name,
        "pattern_id": pattern_id,
        "pattern_type": pattern_type,
        "description": f"Description for {name}",
        "confidence": 0.9,
        "file_path": "/some/path.py",
    }


def _make_disabled(
    pattern_id: str | None = None,
    pattern_class: str | None = None,
    reason: str = "test kill switch",
) -> DisabledPattern:
    return DisabledPattern(
        pattern_id=pattern_id,
        pattern_class=pattern_class,
        reason=reason,
        event_at=datetime.now(UTC),
        actor="test-actor",
    )


# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture
def injector() -> ManifestInjector:
    """Create ManifestInjector with storage/intelligence disabled for unit tests."""
    return ManifestInjector(
        enable_intelligence=False,
        enable_storage=False,
        enable_cache=False,
    )


# =============================================================================
# Tests: _filter_disabled_patterns
# =============================================================================


class TestFilterDisabledPatterns:
    """Tests for the disabled pattern filtering logic."""

    @pytest.mark.asyncio
    async def test_no_disabled_returns_all(self, injector: ManifestInjector) -> None:
        """When no patterns are disabled, all patterns pass through."""
        patterns = [
            _make_pattern("p1", "id-1", "classA"),
            _make_pattern("p2", "id-2", "classB"),
        ]
        with patch.object(
            injector, "_get_disabled_patterns", new_callable=AsyncMock
        ) as mock_get:
            mock_get.return_value = []
            result = await injector._filter_disabled_patterns(patterns)

        assert len(result) == 2

    @pytest.mark.asyncio
    async def test_disabled_by_id(self, injector: ManifestInjector) -> None:
        """Pattern matching a disabled pattern_id is filtered out."""
        patterns = [
            _make_pattern("keep", "id-1", "classA"),
            _make_pattern("remove", "id-2", "classA"),
        ]
        with patch.object(
            injector, "_get_disabled_patterns", new_callable=AsyncMock
        ) as mock_get:
            mock_get.return_value = [_make_disabled(pattern_id="id-2")]
            result = await injector._filter_disabled_patterns(patterns)

        assert len(result) == 1
        assert result[0]["name"] == "keep"

    @pytest.mark.asyncio
    async def test_disabled_by_class(self, injector: ManifestInjector) -> None:
        """Patterns matching a disabled pattern_class are filtered out."""
        patterns = [
            _make_pattern("keep", "id-1", "classA"),
            _make_pattern("remove1", "id-2", "bad_class"),
            _make_pattern("remove2", "id-3", "bad_class"),
        ]
        with patch.object(
            injector, "_get_disabled_patterns", new_callable=AsyncMock
        ) as mock_get:
            mock_get.return_value = [_make_disabled(pattern_class="bad_class")]
            result = await injector._filter_disabled_patterns(patterns)

        assert len(result) == 1
        assert result[0]["name"] == "keep"

    @pytest.mark.asyncio
    async def test_id_precedence_over_class(self, injector: ManifestInjector) -> None:
        """ID-based and class-based disables work together correctly.

        Verifies that a pattern disabled by ID is removed, a pattern in a
        disabled class is removed, and a pattern matching neither survives.
        """
        patterns = [
            _make_pattern("disabled-by-id", "id-1", "classA"),
            _make_pattern("disabled-by-class", "id-2", "bad_class"),
            _make_pattern("not-disabled", "id-3", "classA"),
        ]
        with patch.object(
            injector, "_get_disabled_patterns", new_callable=AsyncMock
        ) as mock_get:
            mock_get.return_value = [
                _make_disabled(pattern_id="id-1"),
                _make_disabled(pattern_class="bad_class"),
            ]
            result = await injector._filter_disabled_patterns(patterns)

        assert len(result) == 1
        assert result[0]["name"] == "not-disabled"

    @pytest.mark.asyncio
    async def test_filter_disabled_when_setting_off(
        self, injector: ManifestInjector
    ) -> None:
        """When enable_disabled_pattern_filter is False, all patterns pass through."""
        injector.enable_disabled_pattern_filter = False
        patterns = [_make_pattern("p1", "id-1", "classA")]

        # Should NOT call _get_disabled_patterns at all
        with patch.object(
            injector, "_get_disabled_patterns", new_callable=AsyncMock
        ) as mock_get:
            result = await injector._filter_disabled_patterns(patterns)

        mock_get.assert_not_called()
        assert len(result) == 1

    @pytest.mark.asyncio
    async def test_db_failure_returns_all_via_empty(
        self, injector: ManifestInjector
    ) -> None:
        """When _get_disabled_patterns returns empty (DB failure), all patterns pass."""
        patterns = [
            _make_pattern("p1", "id-1", "classA"),
            _make_pattern("p2", "id-2", "classB"),
        ]
        with patch.object(
            injector, "_get_disabled_patterns", new_callable=AsyncMock
        ) as mock_get:
            # _get_disabled_patterns catches exceptions internally and returns []
            mock_get.return_value = []
            result = await injector._filter_disabled_patterns(patterns)

        assert len(result) == 2

    @pytest.mark.asyncio
    async def test_class_disable_is_absolute(self, injector: ManifestInjector) -> None:
        """When a class is disabled, ALL patterns in that class are skipped.

        Even if a specific pattern's ID is not in the disabled set, class
        disables are treated as absolute. This is because the materialized
        view cannot distinguish 'never mentioned' from 're-enabled'.
        """
        patterns = [
            _make_pattern("in-disabled-class", "id-999", "bad_class"),
        ]
        with patch.object(
            injector, "_get_disabled_patterns", new_callable=AsyncMock
        ) as mock_get:
            # Only class disabled, not the specific ID
            mock_get.return_value = [_make_disabled(pattern_class="bad_class")]
            result = await injector._filter_disabled_patterns(patterns)

        assert len(result) == 0

    @pytest.mark.asyncio
    async def test_pattern_without_id_not_matched(
        self, injector: ManifestInjector
    ) -> None:
        """Patterns without pattern_id are not matched by ID-based disables."""
        patterns = [
            _make_pattern("no-id", "", "classA"),
        ]
        with patch.object(
            injector, "_get_disabled_patterns", new_callable=AsyncMock
        ) as mock_get:
            mock_get.return_value = [_make_disabled(pattern_id="id-1")]
            result = await injector._filter_disabled_patterns(patterns)

        assert len(result) == 1

    @pytest.mark.asyncio
    async def test_pattern_without_type_not_matched(
        self, injector: ManifestInjector
    ) -> None:
        """Patterns without pattern_type are not matched by class-based disables."""
        patterns = [
            _make_pattern("no-type", "id-1", ""),
        ]
        with patch.object(
            injector, "_get_disabled_patterns", new_callable=AsyncMock
        ) as mock_get:
            mock_get.return_value = [_make_disabled(pattern_class="bad_class")]
            result = await injector._filter_disabled_patterns(patterns)

        assert len(result) == 1


# =============================================================================
# Tests: _get_disabled_patterns
# =============================================================================


class TestGetDisabledPatterns:
    """Tests for the database query to get disabled patterns."""

    @pytest.mark.asyncio
    async def test_returns_empty_on_import_error(
        self, injector: ManifestInjector
    ) -> None:
        """Returns empty list when psycopg2 is not available."""
        with (
            patch("omniclaude.lib.core.manifest_injector.settings") as mock_settings,
            patch.dict("sys.modules", {"psycopg2": None}),
        ):
            mock_settings.enable_postgres = True
            result = await injector._get_disabled_patterns()
        # Should return empty (graceful degradation)
        assert result == []

    @pytest.mark.asyncio
    async def test_returns_empty_on_connection_error(
        self, injector: ManifestInjector
    ) -> None:
        """Returns empty list when psycopg2 connect raises (graceful degradation)."""
        with patch("omniclaude.lib.core.manifest_injector.settings") as mock_settings:
            mock_settings.enable_postgres = True
            mock_settings.postgres_host = "unreachable-host"
            mock_settings.postgres_port = 5436
            mock_settings.postgres_database = "test_db"
            mock_settings.postgres_user = "postgres"
            mock_settings.get_effective_postgres_password.return_value = "testpw"
            # psycopg2.connect will fail with unreachable host, caught by except
            result = await injector._get_disabled_patterns()
        assert result == []

    @pytest.mark.asyncio
    async def test_returns_empty_on_no_password(
        self, injector: ManifestInjector
    ) -> None:
        """Returns empty list when POSTGRES_PASSWORD is not set."""
        with patch("omniclaude.lib.core.manifest_injector.settings") as mock_settings:
            mock_settings.enable_postgres = True
            mock_settings.postgres_host = "localhost"
            mock_settings.postgres_port = 5436
            mock_settings.postgres_database = "test_db"
            mock_settings.postgres_user = "postgres"
            mock_settings.get_effective_postgres_password.side_effect = ValueError(
                "no password"
            )
            result = await injector._get_disabled_patterns()

        assert result == []


# =============================================================================
# Tests: DisabledPattern dataclass
# =============================================================================


class TestDisabledPattern:
    """Tests for the DisabledPattern dataclass."""

    def test_create_with_pattern_id(self) -> None:
        dp = DisabledPattern(
            pattern_id="abc-123",
            pattern_class=None,
            reason="test",
            event_at=datetime.now(UTC),
            actor="admin",
        )
        assert dp.pattern_id == "abc-123"
        assert dp.pattern_class is None

    def test_create_with_pattern_class(self) -> None:
        dp = DisabledPattern(
            pattern_id=None,
            pattern_class="dangerous_class",
            reason="safety",
            event_at=datetime.now(UTC),
            actor="admin",
        )
        assert dp.pattern_id is None
        assert dp.pattern_class == "dangerous_class"

    def test_create_with_both(self) -> None:
        dp = DisabledPattern(
            pattern_id="abc-123",
            pattern_class="some_class",
            reason="both",
            event_at=datetime.now(UTC),
            actor="admin",
        )
        assert dp.pattern_id == "abc-123"
        assert dp.pattern_class == "some_class"
