"""The path grammar, and the walk it drives.

Two things are asserted here that nothing else can: that a path spelled by a
listing is a path `parse()` reads back as the same segments -- which is what
lets an agent walk a catalog by copying a cell out of the last answer -- and
that the walk costs one `fetch_children()` per level and no more.

The catalog is hand-built rather than a database's: what varies between adapters
is how deep it goes and what a level is called, and a fake is the only way to
assert against a shape no bundled adapter has.
"""

from __future__ import annotations

import itertools
from dataclasses import dataclass, field
from typing import Iterator, Sequence

import pytest

from harlequin.adapter import HarlequinConnection, HarlequinCursor
from harlequin.catalog import (
    Catalog,
    CatalogItem,
    CatalogSearchKind,
    CatalogSearchResult,
    InteractiveCatalogItem,
)
from harlequin.exception import HarlequinCatalogPathError, HarlequinQueryError
from harlequin.navigate import (
    CatalogPath,
    list_children,
    resolve,
    search,
    spell,
)

FETCHES: list[str] = []
"""Every `fetch_children()` the walk made, in order. One entry is one round trip."""


@dataclass
class FakeItem(InteractiveCatalogItem["FakeConnection"]):
    """A node that records the times it was asked to fetch."""

    to_fetch: list["FakeItem"] = field(default_factory=list)

    def fetch_children(self) -> Sequence[CatalogItem]:
        FETCHES.append(self.label)
        return self.to_fetch


def item(label: str, *children: FakeItem, type_label: str = "t") -> FakeItem:
    return FakeItem(
        qualified_identifier=label,
        query_name=f'"{label}"',
        label=label,
        type_label=type_label,
        to_fetch=list(children),
    )


class FakeConnection(HarlequinConnection):
    """Just enough connection for the walk: a catalog, and a count of asks."""

    def __init__(self, *items: CatalogItem) -> None:
        super().__init__()
        self.items = list(items)
        self.catalog_calls = 0

    def execute(self, query: str) -> HarlequinCursor | None:
        raise AssertionError("the walk ran SQL")

    def get_catalog(self) -> Catalog:
        self.catalog_calls += 1
        return Catalog(items=list(self.items))


class SearchingConnection(FakeConnection):
    """A connection whose catalog can be searched, and records the asks."""

    def __init__(self, *items: CatalogItem) -> None:
        super().__init__(*items)
        self.searches: list[tuple[str, str]] = []

    def search_catalog(
        self, term: str, kind: CatalogSearchKind = "all"
    ) -> list[CatalogSearchResult]:
        self.searches.append((term, kind))
        return [
            CatalogSearchResult(item=item("orders"), parents=("mydb", "analytics")),
            CatalogSearchResult(item=item("orders"), parents=("otherdb", "public")),
        ]


@pytest.fixture(autouse=True)
def fetches() -> Iterator[list[str]]:
    FETCHES.clear()
    yield FETCHES
    FETCHES.clear()


@pytest.fixture
def connection() -> FakeConnection:
    return FakeConnection(
        item(
            "mydb",
            item(
                "analytics",
                item("orders", item("id", type_label="##")),
                item("order_items"),
                type_label="sch",
            ),
            item("empty", type_label="sch"),
            type_label="db",
        )
    )


# --- the grammar -------------------------------------------------------------


@pytest.mark.parametrize(
    "text,segments",
    [
        (None, ()),
        ("", ()),
        ("   ", ()),
        ("mydb", ("mydb",)),
        ("mydb.analytics", ("mydb", "analytics")),
        ('mydb."my.schema"', ("mydb", "my.schema")),
        ('"quoted ""name"""', ('quoted "name"',)),
        ('mydb."has*star"', ("mydb", "has*star")),
        ('""', ("",)),
    ],
)
def test_a_path_is_dotted_segments(text: str | None, segments: tuple[str, ...]) -> None:
    parsed = CatalogPath.parse(text)
    assert parsed.segments == segments
    assert parsed.glob is None


@pytest.mark.parametrize("text", ["mydb.ord*", "ord*", "mydb.analytics.?d"])
def test_a_trailing_wildcard_is_a_filter_not_a_segment(text: str) -> None:
    parsed = CatalogPath.parse(text)
    assert parsed.glob == text.rsplit(".", 1)[-1]
    assert parsed.segments == tuple(text.split(".")[:-1])


@pytest.mark.parametrize("text", ["*.orders", "mydb.*.orders", "my?b.analytics"])
def test_an_interior_wildcard_is_refused(text: str) -> None:
    """It cannot be answered without fetching every level it could match, so it
    is refused rather than quietly walked."""
    with pytest.raises(HarlequinCatalogPathError, match="wildcard"):
        CatalogPath.parse(text)


@pytest.mark.parametrize("text", ["mydb.", ".mydb", "mydb..analytics"])
def test_an_empty_segment_is_refused(text: str) -> None:
    with pytest.raises(HarlequinCatalogPathError, match="empty path segment"):
        CatalogPath.parse(text)


def test_an_unterminated_quote_is_refused() -> None:
    with pytest.raises(HarlequinCatalogPathError, match="never closes"):
        CatalogPath.parse('mydb."analytics')


@pytest.mark.parametrize("text", ['"a"b', 'a"b"c', 'schema."quoted rel"*'])
def test_a_segment_quoted_in_part_is_refused(text: str) -> None:
    """A segment is quoted whole or not at all.

    Concatenating the two would leave one `quoted` flag describing a segment
    that was half quoted -- and `"rel"*` would then ask for a wildcard the
    quotes have already said is a character.
    """
    with pytest.raises(HarlequinCatalogPathError, match="quotes part of"):
        CatalogPath.parse(text)


@pytest.mark.parametrize(
    "segments",
    [
        ("mydb", "analytics"),
        ("my.db", "analytics"),
        ('a "quoted" one', "b"),
        ("has*star",),
        ("",),
    ],
)
def test_a_spelled_path_parses_back_to_the_same_segments(
    segments: tuple[str, ...],
) -> None:
    """The property the `path` column depends on: a cell from one listing is
    the argument that lists that item's children."""
    assert CatalogPath.parse(spell(segments)).segments == segments


NASTY = ["a", ".", '"', "`", "[", "]", "*", "?", " ", "\\", "'"]
"""Every character that could break a splitter, plus one that could not."""


def test_every_label_a_catalog_can_hand_us_round_trips() -> None:
    """`spell()` and `parse()` are inverses, over labels nobody would type.

    An adapter's label is whatever the database holds, so a label this pair
    disagrees about is a row whose `path` column names something the next
    invocation cannot reach.
    """
    for size in (1, 2, 3):
        for combination in itertools.product(NASTY, repeat=size):
            label = "".join(combination)
            assert CatalogPath.parse(spell([label])).segments == (label,), label


def test_two_such_labels_round_trip_together() -> None:
    """The same, where a dot between the two is the thing being escaped."""
    labels = ["".join(pair) for pair in itertools.product(NASTY, repeat=2)]
    for first, second in itertools.product(labels, repeat=2):
        parsed = CatalogPath.parse(spell([first, second])).segments
        assert parsed == (first, second), (first, second)


@pytest.mark.parametrize("label", ["my`table", "[bracketed]", "'quoted'"])
def test_another_dialects_quoting_is_a_label_like_any_other(label: str) -> None:
    """A backtick or a bracket is a character in a name here, not quoting.

    Treating either as a quote would make a MySQL table named ``my`table``
    unreachable, which is the cost of accepting more than one spelling.
    """
    connection = FakeConnection(item(label))
    listing = list_children(connection, CatalogPath.parse(spell([label])))
    assert listing.parent is not None
    assert listing.parent.label == label


@pytest.mark.parametrize("typed", ["`mydb`", "[mydb]"])
def test_a_path_quoted_for_a_database_says_what_to_type_instead(
    connection: FakeConnection, typed: str
) -> None:
    """So an agent that reached for its dialect's quoting is told the spelling
    this takes, rather than left with a path that names nothing."""
    with pytest.raises(HarlequinCatalogPathError, match="Did you mean mydb"):
        list_children(connection, CatalogPath.parse(typed))


def test_a_plain_segment_is_spelled_without_quotes() -> None:
    """A path a person reads should look like the one they would have typed."""
    assert spell(["mydb", "analytics", "orders"]) == "mydb.analytics.orders"


# --- the walk ----------------------------------------------------------------


def test_the_top_level_is_the_catalog(connection: FakeConnection) -> None:
    listing = list_children(connection, CatalogPath.parse(None))
    assert listing.parent is None
    assert [child.label for child in listing.items] == ["mydb"]
    assert connection.catalog_calls == 1


def test_a_listing_is_one_round_trip_per_segment_plus_one(
    connection: FakeConnection,
) -> None:
    """The cost this module promises, and the reason there is no `--depth`."""
    listing = list_children(connection, CatalogPath.parse("mydb.analytics"))
    assert listing.parent is not None
    assert listing.parent.label == "analytics"
    assert [child.label for child in listing.items] == ["orders", "order_items"]

    # one for the catalog, and one per segment walked past -- and nothing else
    assert connection.catalog_calls == 1
    assert FETCHES == ["mydb", "analytics"]


def test_listing_a_relation_is_describing_it(connection: FakeConnection) -> None:
    listing = list_children(connection, CatalogPath.parse("mydb.analytics.orders"))
    assert [(child.label, child.type_label) for child in listing.items] == [
        ("id", "##")
    ]


def test_a_node_with_no_children_lists_nothing(connection: FakeConnection) -> None:
    """Zero rows, not an error: an empty schema is an answer."""
    assert list_children(connection, CatalogPath.parse("mydb.empty")).items == []


def test_a_trailing_wildcard_filters_one_level(connection: FakeConnection) -> None:
    listing = list_children(connection, CatalogPath.parse("mydb.analytics.order*"))
    assert [child.label for child in listing.items] == ["orders", "order_items"]
    # the parent it filtered, not a level below it
    assert listing.parent is not None
    assert listing.parent.label == "analytics"


def test_a_wildcard_that_matches_nothing_lists_nothing(
    connection: FakeConnection,
) -> None:
    assert list_children(connection, CatalogPath.parse("mydb.z*")).items == []


def test_a_segment_that_names_nothing_says_what_is_there(
    connection: FakeConnection,
) -> None:
    """A guessed path is the common case, so the error answers the question."""
    with pytest.raises(HarlequinCatalogPathError) as excinfo:
        list_children(connection, CatalogPath.parse("mydb.analytic"))
    assert "analytic" in excinfo.value.msg
    assert "Did you mean analytics?" in excinfo.value.msg


def test_a_top_level_miss_says_where_it_looked(connection: FakeConnection) -> None:
    with pytest.raises(HarlequinCatalogPathError, match="top of the catalog"):
        list_children(connection, CatalogPath.parse("nope"))


def test_a_miss_under_a_childless_node_says_so(connection: FakeConnection) -> None:
    with pytest.raises(HarlequinCatalogPathError, match="nothing under it"):
        list_children(connection, CatalogPath.parse("mydb.empty.orders"))


def test_resolve_fetches_the_ancestors_and_not_the_level(
    connection: FakeConnection,
) -> None:
    """`resolve()` is the walk without the listing, and costs one call less."""
    found = resolve(connection, CatalogPath.parse("mydb.analytics"))
    assert found is not None and found.label == "analytics"
    assert FETCHES == ["mydb"]


def test_children_already_loaded_are_not_fetched_again(
    connection: FakeConnection,
) -> None:
    """An adapter that hands over a whole subtree is not asked to do it twice."""
    path = CatalogPath.parse("mydb.analytics")
    list_children(connection, path)
    list_children(connection, path)
    assert FETCHES == ["mydb", "analytics"]


def test_a_plain_catalog_item_has_no_children_to_fetch() -> None:
    """The degradation path: an adapter whose items are not interactive."""
    connection = FakeConnection(
        CatalogItem(
            qualified_identifier="db", query_name='"db"', label="db", type_label="db"
        )
    )
    assert list_children(connection, CatalogPath.parse("db")).items == []


# --- the search, which is the call a walk cannot serve ------------------------


def test_search_asks_the_adapter_once_and_walks_nothing() -> None:
    connection = SearchingConnection()
    found = search(connection, "orders", CatalogPath(), "columns")
    assert connection.searches == [("orders", "columns")]
    assert FETCHES == []
    assert len(found) == 2


def test_a_scope_narrows_the_answer_to_one_subtree(
    connection: FakeConnection,
) -> None:
    searching = SearchingConnection(*connection.items)
    found = search(searching, "orders", CatalogPath.parse("mydb"))
    assert [result.parents for result in found] == [("mydb", "analytics")]


def test_a_scope_that_names_nothing_says_so_rather_than_finding_nothing() -> None:
    """The same error `--catalog --path` gives, since it is the same question
    about the same path."""
    searching = SearchingConnection()
    with pytest.raises(HarlequinCatalogPathError, match="top of the catalog"):
        search(searching, "orders", CatalogPath.parse("nope"))
    assert searching.searches == []


def test_an_adapter_that_declared_a_search_it_lacks_is_an_error() -> None:
    """A declaration can be wrong, and the failure is a message rather than a
    traceback."""
    connection = FakeConnection()
    with pytest.raises(HarlequinQueryError, match="does not implement it"):
        search(connection, "orders", CatalogPath())
