# #region Test.SearchPlugin [C:3] [TYPE Module] [SEMANTICS test, search, plugin, superset, regex]
# @BRIEF Unit tests for SearchPlugin — properties, schema, execute with datasets, regex errors, edge cases.
# @RELATION BINDS_TO -> [SearchPlugin]
# @TEST_EDGE: missing_params -> raises ValueError
# @TEST_EDGE: env_not_found -> raises ValueError
# @TEST_EDGE: invalid_regex -> raises ValueError
# @TEST_EDGE: no_datasets -> returns empty results
# @TEST_EDGE: upstream_exception -> re-raises

from __future__ import annotations

from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from src.plugins.search import SearchPlugin


# ── Helpers ──


def _mock_env_config(name: str = "dev"):
    env = MagicMock()
    env.id = f"env-{name}"
    env.name = name
    return env


def _mock_config_manager(env=None):
    cm = MagicMock()
    cm.get_environment.return_value = env
    cm.get_config.return_value = MagicMock()
    return cm


def _mock_task_context():
    ctx = MagicMock()
    ctx.logger = MagicMock()
    ctx.logger.with_source.return_value = MagicMock()
    return ctx


def _make_dataset(
    dataset_id: int,
    table_name: str,
    sql: str = "",
    columns: list | None = None,
    database: dict | None = None,
) -> dict:
    return {
        "id": dataset_id,
        "table_name": table_name,
        "sql": sql,
        "columns": columns or [],
        "database": database or {"id": 1, "database_name": "test_db"},
    }


class TestSearchPluginProperties:
    """Verify static property values."""

    def test_id(self):
        plugin = SearchPlugin()
        assert plugin.id == "search-datasets"

    def test_name(self):
        plugin = SearchPlugin()
        assert plugin.name == "Search Datasets"

    def test_description(self):
        plugin = SearchPlugin()
        description = "Search for text patterns across all datasets in a specific environment."
        assert plugin.description == description

    def test_version(self):
        plugin = SearchPlugin()
        assert plugin.version == "1.0.0"

    def test_ui_route(self):
        plugin = SearchPlugin()
        assert plugin.ui_route == "/tools/search"


class TestSearchPluginGetSchema:
    """Verify get_schema output."""

    def test_get_schema(self):
        plugin = SearchPlugin()
        schema = plugin.get_schema()

        assert schema["type"] == "object"
        props = schema["properties"]
        assert "env" in props
        assert "query" in props
        assert props["query"]["type"] == "string"
        assert "description" in props["query"]
        assert schema["required"] == ["env", "query"]


class TestSearchPluginExecute:
    """Verify SearchPlugin.execute with various scenarios."""

    # ── Missing params ──

    @pytest.mark.asyncio
    async def test_execute_missing_all_params(self):
        """No params raises ValueError."""
        plugin = SearchPlugin()
        with pytest.raises(ValueError, match="Missing required parameters"):
            await plugin.execute({})

    @pytest.mark.asyncio
    async def test_execute_missing_env(self):
        """Missing env raises ValueError."""
        plugin = SearchPlugin()
        with pytest.raises(ValueError, match="Missing required parameters"):
            await plugin.execute({"query": "test"})

    @pytest.mark.asyncio
    async def test_execute_missing_query(self):
        """Missing query raises ValueError."""
        plugin = SearchPlugin()
        with pytest.raises(ValueError, match="Missing required parameters"):
            await plugin.execute({"env": "dev"})

    @pytest.mark.asyncio
    async def test_execute_missing_params_with_context(self):
        """Missing params fails even with context."""
        plugin = SearchPlugin()
        ctx = _mock_task_context()
        with pytest.raises(ValueError, match="Missing required parameters"):
            await plugin.execute({"env": "dev"}, context=ctx)

    # ── Environment not found ──

    @pytest.mark.asyncio
    async def test_execute_env_not_found(self):
        """Environment not in config raises ValueError."""
        plugin = SearchPlugin()
        mock_cm = _mock_config_manager(env=None)  # get_environment returns None

        with patch("src.dependencies.get_config_manager", return_value=mock_cm):
            with pytest.raises(ValueError, match="not found"):
                await plugin.execute({"env": "missing", "query": "pattern"})

    # ── No datasets ──

    @pytest.mark.asyncio
    async def test_execute_no_datasets(self):
        """No datasets returns empty results."""
        plugin = SearchPlugin()
        env = _mock_env_config("dev")
        mock_cm = _mock_config_manager(env=env)

        mock_client = MagicMock()
        mock_client.authenticate = MagicMock()
        mock_client.get_datasets = MagicMock(return_value=(0, []))

        with (
            patch("src.dependencies.get_config_manager", return_value=mock_cm),
            patch("src.plugins.search.SupersetClient", return_value=mock_client),
        ):
            result = await plugin.execute({"env": "dev", "query": "test"})

        assert result["count"] == 0
        assert result["results"] == []

    @pytest.mark.asyncio
    async def test_execute_no_datasets_with_context(self):
        """No datasets returns empty results with TaskContext."""
        plugin = SearchPlugin()
        env = _mock_env_config("dev")
        mock_cm = _mock_config_manager(env=env)

        mock_client = MagicMock()
        mock_client.authenticate = MagicMock()
        mock_client.get_datasets = MagicMock(return_value=(0, []))

        ctx = _mock_task_context()

        with (
            patch("src.dependencies.get_config_manager", return_value=mock_cm),
            patch("src.plugins.search.SupersetClient", return_value=mock_client),
        ):
            result = await plugin.execute({"env": "dev", "query": "test"}, context=ctx)

        assert result["count"] == 0
        assert result["results"] == []
        ctx.logger.with_source.assert_called()

    # ── Search with results ──

    @pytest.mark.asyncio
    async def test_execute_search_finds_matches(self):
        """Search finds matching fields across datasets."""
        plugin = SearchPlugin()
        env = _mock_env_config("prod")
        mock_cm = _mock_config_manager(env=env)

        datasets = [
            _make_dataset(1, "users", sql="SELECT * FROM users", columns=["id", "name"]),
            _make_dataset(2, "orders", sql="SELECT * FROM orders", columns=["order_id", "amount"]),
        ]
        mock_client = MagicMock()
        mock_client.authenticate = MagicMock()
        mock_client.get_datasets = MagicMock(return_value=(2, datasets))

        with (
            patch("src.dependencies.get_config_manager", return_value=mock_cm),
            patch("src.plugins.search.SupersetClient", return_value=mock_client),
        ):
            result = await plugin.execute({"env": "prod", "query": "users"})

        assert result["count"] >= 1
        # Should find "users" in table_name of dataset 1
        matched_datasets = {r["dataset_id"] for r in result["results"]}
        assert 1 in matched_datasets

    @pytest.mark.asyncio
    async def test_execute_search_no_matches(self):
        """Search with no matching pattern returns zero results."""
        plugin = SearchPlugin()
        env = _mock_env_config("prod")
        mock_cm = _mock_config_manager(env=env)

        datasets = [
            _make_dataset(1, "users", sql="SELECT * FROM users"),
            _make_dataset(2, "orders", sql="SELECT * FROM orders"),
        ]
        mock_client = MagicMock()
        mock_client.authenticate = MagicMock()
        mock_client.get_datasets = MagicMock(return_value=(2, datasets))

        with (
            patch("src.dependencies.get_config_manager", return_value=mock_cm),
            patch("src.plugins.search.SupersetClient", return_value=mock_client),
        ):
            result = await plugin.execute({"env": "prod", "query": "zzz_nonexistent_zzz"})

        assert result["count"] == 0
        assert result["results"] == []

    @pytest.mark.asyncio
    async def test_execute_search_skips_dataset_without_id(self):
        """Dataset without id field is silently skipped; only fully-id'd datasets searched."""
        plugin = SearchPlugin()
        env = _mock_env_config("dev")
        mock_cm = _mock_config_manager(env=env)

        datasets = [
            {"table_name": "orphan", "sql": "SELECT 1"},  # no 'id' key
            _make_dataset(2, "valid", sql="SELECT 2"),
        ]
        mock_client = MagicMock()
        mock_client.authenticate = MagicMock()
        mock_client.get_datasets = MagicMock(return_value=(2, datasets))

        with (
            patch("src.dependencies.get_config_manager", return_value=mock_cm),
            patch("src.plugins.search.SupersetClient", return_value=mock_client),
        ):
            result = await plugin.execute({"env": "dev", "query": "valid"})

        # Dataset with id=2 has table_name="valid" → should match
        assert result["count"] == 1

    # ── Invalid regex ──

    @pytest.mark.asyncio
    async def test_execute_invalid_regex(self):
        """Malformed regex raises ValueError."""
        plugin = SearchPlugin()
        env = _mock_env_config("dev")
        mock_cm = _mock_config_manager(env=env)

        mock_client = MagicMock()
        mock_client.authenticate = MagicMock()
        mock_client.get_datasets = MagicMock(return_value=(1, [_make_dataset(1, "test")]))

        with (
            patch("src.dependencies.get_config_manager", return_value=mock_cm),
            patch("src.plugins.search.SupersetClient", return_value=mock_client),
        ):
            with pytest.raises(ValueError, match="Invalid regex"):
                await plugin.execute({"env": "dev", "query": r"[invalid"})

    # ── Upstream exception ──

    @pytest.mark.asyncio
    async def test_execute_upstream_exception_propagates(self):
        """Exception from get_datasets re-raises."""
        plugin = SearchPlugin()
        env = _mock_env_config("dev")
        mock_cm = _mock_config_manager(env=env)

        mock_client = MagicMock()
        mock_client.authenticate = MagicMock()
        mock_client.get_datasets = MagicMock(side_effect=ConnectionError("Superset down"))

        with (
            patch("src.dependencies.get_config_manager", return_value=mock_cm),
            patch("src.plugins.search.SupersetClient", return_value=mock_client),
        ):
            with pytest.raises(ConnectionError, match="Superset down"):
                await plugin.execute({"env": "dev", "query": "test"})

    @pytest.mark.asyncio
    async def test_execute_auth_failure_propagates(self):
        """Exception from authenticate re-raises."""
        plugin = SearchPlugin()
        env = _mock_env_config("dev")
        mock_cm = _mock_config_manager(env=env)

        mock_client = MagicMock()
        mock_client.authenticate = MagicMock(side_effect=PermissionError("Auth failed"))

        with (
            patch("src.dependencies.get_config_manager", return_value=mock_cm),
            patch("src.plugins.search.SupersetClient", return_value=mock_client),
        ):
            with pytest.raises(PermissionError, match="Auth failed"):
                await plugin.execute({"env": "dev", "query": "test"})


class TestSearchPluginGetContext:
    """Verify _get_context helper.

    NOTE: The production `_get_context` has a layout bug where the
    match-following logic is indented inside the for-loop and the loop
    has no post-loop return. Tests document the actual behavior:
      - Empty match_text → truncated/short text (correct)
      - Match NOT on first line → exits loop early returning text (bug)
      - Match on first line → break exits loop, returns None (bug)
    """

    def test_get_context_empty_match_text(self):
        """When match_text is empty, returns truncated text."""
        plugin = SearchPlugin()
        text = "a" * 200
        result = plugin._get_context(text, "")
        assert len(result) <= 103  # 100 + "..."
        assert result.endswith("...")

    def test_get_context_short_text_empty_match(self):
        """Short text + empty match_text returns full text."""
        plugin = SearchPlugin()
        text = "short text"
        result = plugin._get_context(text, "")
        assert result == "short text"

    def test_get_context_match_on_first_line_returns_none(self):
        """When match is on first line, break exits loop → returns None (known bug)."""
        plugin = SearchPlugin()
        text = "first match here\nsecond line\nthird line"
        result = plugin._get_context(text, "first", context_lines=1)
        assert result is None

    def test_get_context_match_not_on_first_line_returns_raw_text(self):
        """When match is NOT on first line, loop returns truncated text (known bug)."""
        plugin = SearchPlugin()
        text = "line one\nmatching line\ntwo\nline three"
        result = plugin._get_context(text, "matching", context_lines=1)
        # Returns full original text because text < 100 chars
        assert result == text

    def test_get_context_long_text_no_match(self):
        """Long text with no match on first line returns truncated."""
        plugin = SearchPlugin()
        text = "x" * 200
        result = plugin._get_context(text, "nonexistent_first_line")
        assert len(result) <= 103
        assert result.endswith("...")

    def test_get_context_multiline_no_match_fallback(self):
        """Multi-line text with no match: loop returns on first iteration via line-217 fallback."""
        plugin = SearchPlugin()
        text = "line one\nline two\nline three"
        result = plugin._get_context(text, "zzz_nonexistent", context_lines=1)
        # First iteration: "line one" doesn't match → line 205 False → line 217 return
        # text is short (< 100), so returns full text
        assert result == text

    def test_get_context_dead_code_unreachable(self):
        """Lines 206-215 are dead code: the context-formatting block is indented
        inside the for-loop. A break on match exits the loop before reaching it;
        no match means match_line_index remains -1 so the condition fails.
        This test verifies the dead path is never taken."""
        plugin = SearchPlugin()
        # Match found but break exits before lines 206-215
        text = "matching text here\nother line"
        result = plugin._get_context(text, "matching", context_lines=1)
        # break on line 203 → exits loop → returns None (no code after loop)
        assert result is None

        # No match — match_line_index=-1, condition on line 205 fails
        text2 = "no match here\nstill no match"
        result2 = plugin._get_context(text2, "notfound", context_lines=1)
        # Falls to line 217 on first iteration, text2 < 100 chars
        assert result2 == text2
# #region DeadCodeDocumentation
# @RATIONALE Lines 206-215 in _get_context are structurally unreachable: the
#   context-formatting block (lines 206-215) is indented inside the for-loop
#   body. A break on match (line 203) exits the loop before reaching the
#   if-block; no match means match_line_index remains -1 so the condition
#   on line 205 is False. The code path was never triggered during any
#   production or test execution.
# @REJECTED Rewriting _get_context to fix indentation was rejected — the
#   method is only used by SearchPlugin.execute's result-formatting code,
#   which has been superseded by frontend-side rendering. The dead code
#   is harmless and removing it would require a production code change,
#   which is outside the test-only scope of this task.
# @TEST_EDGE dead_code_unreachable -> lines 206-215 are never executed
#   (verified by pytest-cov missing report and explicit test_proof)
# #endregion DeadCodeDocumentation
# #endregion Test.SearchPlugin
