"""Tests for the snippet location reverse lookup (#2083).

Covers the pure half of the predicate — `location_parts`, `location_matches`,
`location_jsonpath` — plus the parameter plumbing across the three surfaces
(service / REST route / MCP tool), which rule #33 requires to agree on names and
defaults.

The SQL half can only be proved against real Postgres — see
tests/test_integration_snippet_locations.py, which also pins the two dialects to
the same answers on the same rows.
"""
import inspect
import json
from unittest.mock import AsyncMock, patch

import pytest

from scribe.services.knowledge import (
    LOCATION_KEYS,
    location_jsonpath,
    location_matches,
    location_parts,
)


def _data(*locations: dict) -> dict:
    return {"locations": list(locations)}


# --- location_parts -----------------------------------------------------------

def test_location_parts_keeps_only_what_was_asked_for():
    assert location_parts() == {}
    assert location_parts(repo="Scribe") == {"repo": "Scribe"}
    assert location_parts(repo="  Scribe  ", path=" src/x.py ") == {
        "repo": "Scribe", "path": "src/x.py",
    }


def test_location_parts_treats_whitespace_as_absent():
    """An empty query-string arg must not filter the list down to nothing."""
    assert location_parts(repo="   ", path="", symbol=None or "") == {}


def test_location_parts_covers_every_location_key():
    assert set(location_parts(repo="a", path="b", symbol="c")) == set(LOCATION_KEYS)


# --- location_matches ---------------------------------------------------------

def test_no_parts_matches_everything():
    assert location_matches(None, {}) is True
    assert location_matches(_data({"repo": "x", "path": "y", "symbol": "z"}), {}) is True


def test_matches_exact_repo_path_and_symbol():
    data = _data({"repo": "Scribe", "path": "src/scribe/x.py", "symbol": "helper"})
    assert location_matches(data, {"repo": "Scribe"})
    assert location_matches(data, {"repo": "scribe"})   # repo names: case never distinguishes (#2874)
    assert location_matches(data, {"path": "src/scribe/x.py"})
    assert location_matches(data, {"symbol": "helper"})
    assert location_matches(data, {"repo": "Scribe", "symbol": "helper"})


def test_path_matches_as_a_directory_prefix():
    data = _data({"repo": "Scribe", "path": "frontend/src/lib/x.ts", "symbol": ""})
    assert location_matches(data, {"path": "frontend/src"})
    assert location_matches(data, {"path": "frontend/src/"})
    assert location_matches(data, {"path": "frontend/src/lib/x.ts"})


def test_path_prefix_stops_at_a_directory_boundary():
    """`frontend/src` must not match `frontend/srcmap.ts` — a sibling, not a child."""
    data = _data({"repo": "Scribe", "path": "frontend/srcmap.ts", "symbol": ""})
    assert not location_matches(data, {"path": "frontend/src"})


def test_parts_must_all_match_the_same_location():
    """Repo A in one entry and path B in another is not "recorded at A/B"."""
    data = _data(
        {"repo": "Scribe", "path": "src/a.py", "symbol": ""},
        {"repo": "Portal", "path": "src/b.py", "symbol": ""},
    )
    assert location_matches(data, {"repo": "Scribe", "path": "src/a.py"})
    assert not location_matches(data, {"repo": "Scribe", "path": "src/b.py"})


def test_missing_or_empty_data_never_matches():
    assert not location_matches(None, {"repo": "Scribe"})
    assert not location_matches({}, {"repo": "Scribe"})
    assert not location_matches({"locations": []}, {"repo": "Scribe"})
    assert not location_matches({"name": "x"}, {"repo": "Scribe"})


def test_blank_recorded_part_does_not_match_a_requested_one():
    data = _data({"repo": "Scribe", "path": "", "symbol": ""})
    assert not location_matches(data, {"repo": "Scribe", "symbol": "helper"})


# --- location_jsonpath --------------------------------------------------------

def test_jsonpath_filters_within_one_locations_entry():
    expr = location_jsonpath({"repo": "Scribe", "symbol": "helper"})
    assert expr.startswith("$.locations[*] ? (")
    # repo: anchored, case-insensitive (#2874) — mirrors location_matches.
    assert '@.repo like_regex "^Scribe$" flag "i"' in expr
    assert '@.symbol == "helper"' in expr
    assert " && " in expr


def test_jsonpath_path_asks_for_exact_or_prefix():
    expr = location_jsonpath({"path": "frontend/src"})
    assert '@.path == "frontend/src"' in expr
    assert '@.path starts with "frontend/src/"' in expr
    assert " || " in expr


def test_jsonpath_does_not_double_the_prefix_slash():
    assert 'starts with "frontend/src/"' in location_jsonpath({"path": "frontend/src/"})


def test_jsonpath_quotes_values_as_json_literals():
    """A quote in a repo name must stay inside the literal, not end it."""
    nasty = 'we"ird'
    expr = location_jsonpath({"repo": nasty})
    assert json.dumps("^" + nasty + "$") in expr      # the regex is a JSON literal too
    assert '\\"' in expr
    assert json.dumps(nasty) in location_jsonpath({"symbol": nasty})


def test_jsonpath_emits_parts_in_a_fixed_key_order():
    """Stable text keeps the query plan cacheable and the tests readable."""
    a = location_jsonpath({"symbol": "s", "repo": "r", "path": "p"})
    b = location_jsonpath({"repo": "r", "path": "p", "symbol": "s"})
    assert a == b


# --- the SQL dialect renders (shape only; behaviour is the integration test) ---

def test_location_clause_renders_a_jsonpath_containment():
    """Catches an API slip in the fast lane rather than waiting for Postgres —
    the clause has to compile to `data @? :jsonpath` against the pg dialect."""
    from sqlalchemy.dialects import postgresql

    from scribe.services.knowledge import _location_clause

    sql = str(_location_clause({"repo": "Scribe"}).compile(dialect=postgresql.dialect()))
    assert "@?" in sql
    assert "data" in sql


# --- surface plumbing (rule #33) ---------------------------------------------

def test_agent_and_service_surfaces_take_the_same_location_params():
    from scribe.mcp.tools.snippets import list_snippets as mcp_list
    from scribe.services.snippets import list_snippets as svc_list

    for fn in (mcp_list, svc_list):
        params = inspect.signature(fn).parameters
        for key in LOCATION_KEYS:
            assert key in params, f"{fn.__qualname__} is missing {key}"
            assert params[key].default == "", f"{fn.__qualname__}.{key} default drifted"


def test_route_forwards_the_location_args_by_name():
    """The REST surface is the third caller; it must use the same arg names."""
    from scribe.routes import snippets as routes
    src = inspect.getsource(routes)
    for key in LOCATION_KEYS:
        assert f'request.args.get("{key}"' in src
        assert f"{key}={key}" in src


@pytest.mark.asyncio
async def test_mcp_tool_passes_locations_through_to_the_service():
    with patch("scribe.services.snippets.list_snippets",
               new=AsyncMock(return_value=([], 0))) as m, \
         patch("scribe.services.access.label_shared_items",
               new=AsyncMock(return_value=[])):
        from scribe.mcp.tools.snippets import list_snippets
        await list_snippets(repo="Scribe", path="src/scribe", symbol="helper")
    kwargs = m.await_args.kwargs
    assert kwargs["repo"] == "Scribe"
    assert kwargs["path"] == "src/scribe"
    assert kwargs["symbol"] == "helper"


@pytest.mark.asyncio
async def test_service_sends_no_location_filter_when_none_was_asked_for():
    """A plain list must not carry an empty filter — that would exclude every
    row whose `data` has no locations at all."""
    with patch("scribe.services.knowledge.query_knowledge",
               new=AsyncMock(return_value=([], 0))) as m:
        from scribe.services.snippets import list_snippets
        await list_snippets(1)
    assert m.await_args.kwargs["locations"] is None


@pytest.mark.asyncio
async def test_service_builds_the_filter_from_the_parts_given():
    with patch("scribe.services.knowledge.query_knowledge",
               new=AsyncMock(return_value=([], 0))) as m:
        from scribe.services.snippets import list_snippets
        await list_snippets(1, q="debounce", repo="Scribe", path=" src/ ")
    kwargs = m.await_args.kwargs
    assert kwargs["locations"] == {"repo": "Scribe", "path": "src/"}
    # Search and place compose — the location filter must not drop the query.
    assert kwargs["q"] == "debounce"
