"""Architectural invariant: MemoryFact reads must go through FactQuery.

This test guards the architectural decision documented in
``.vibeflow/decisions.md`` (2026-04-09 — "FactQuery as single source of
read queries for MemoryFact").

The invariant is: any file under ``src/arandu/read/`` or
``src/arandu/client.py`` must not contain a ``select(MemoryFact)``
expression — the only allowed entry point for fetching ``MemoryFact``
rows is the ``FactQuery`` builder in ``src/arandu/_fact_query.py``.

Two categories of queries are whitelisted:

1. **Column projections** (e.g. ``select(MemoryFact.entity_key)``): not
   fetching full rows, not ordering-sensitive.
2. **Point-lookups by UUID** (e.g. ``MemoryClient.get(fact_id)``): return
   0 or 1 row, no ordering involved.

Any new ``select(MemoryFact)`` outside this whitelist is a regression —
the next developer is free to refactor it to use ``FactQuery``, add a
whitelist entry with a comment explaining the exception, or have this
test fail loudly in CI.
"""

from __future__ import annotations

import re
from pathlib import Path

# Files covered by the invariant. After Part 4 of the fact-query-abstraction
# refactor, the invariant covers the full ``src/arandu/`` tree where MemoryFact
# fetches happen: read, write and background jobs.
COVERED_DIRS = [
    Path(__file__).parent.parent / "src" / "arandu" / "read",
    Path(__file__).parent.parent / "src" / "arandu" / "write",
    Path(__file__).parent.parent / "src" / "arandu" / "background",
]
COVERED_FILES = [
    Path(__file__).parent.parent / "src" / "arandu" / "client.py",
]

# Whitelist of (file_relative_to_src, allowed function / purpose) pairs.
# Projections of specific columns and UUID point-lookups are exempt.
WHITELIST: dict[str, list[str]] = {
    # Read path projections
    "arandu/read/query_expansion.py": [
        "_fetch_entity_context",  # projection: select(entity_key, value_text)
        "_resolve_by_slug",  # projection: select(entity_key).distinct()
    ],
    "arandu/read/graph_retrieval.py": [
        "_build_edge_dicts",  # projection: select(entity_key, entity_name).distinct()
    ],
    "arandu/read/retrieval_agent.py": [
        "_get_user_schema",  # projection: select(entity_key, attribute_key).distinct()
    ],
    # Client point-lookup by UUID
    "arandu/client.py": [
        "get",  # point-lookup by UUID
    ],
    # Write path point-lookups by UUID — fact mutation helpers that load a
    # single row by matched_fact_id. Ordering is trivial (0 or 1 result).
    "arandu/write/upsert.py": [
        "_update_fact",  # point-lookup by matched_fact_id UUID
        "_confirm_fact",  # point-lookup by matched_fact_id UUID
        "_delete_fact",  # point-lookup by matched_fact_id UUID
    ],
    "arandu/write/correction.py": [
        "detect_and_record_corrections",  # point-lookup by supersedes_fact_id UUID
    ],
}

# Regex that matches ``select(MemoryFact)`` or ``select(MemoryFact,`` — the
# full-row fetch pattern. It intentionally does NOT match
# ``select(MemoryFact.column)`` because that is a column projection.
_FULL_ROW_SELECT = re.compile(r"select\(\s*MemoryFact\s*[,\)]")


def _rel_path(path: Path) -> str:
    """Convert an absolute path to the ``arandu/...`` relative form used in WHITELIST keys.

    The project directory may be named ``arandu`` as well, so we locate the
    *last* ``src`` component and take the path after it (skipping the ``src``).
    """
    parts = path.parts
    try:
        src_idx = len(parts) - 1 - parts[::-1].index("src")
    except ValueError:
        return str(path)
    return "/".join(parts[src_idx + 1 :])


def _iter_python_files() -> list[Path]:
    """Return every Python file in scope of the invariant."""
    files: list[Path] = []
    for d in COVERED_DIRS:
        files.extend(sorted(d.rglob("*.py")))
    files.extend(COVERED_FILES)
    return files


def _matches_in_file(path: Path) -> list[tuple[int, str]]:
    """Find every full-row select(MemoryFact) match in a file.

    Returns:
        List of (line_number, matched_text) tuples. Empty if clean.
    """
    content = path.read_text(encoding="utf-8")
    matches: list[tuple[int, str]] = []
    for lineno, line in enumerate(content.splitlines(), start=1):
        if _FULL_ROW_SELECT.search(line):
            matches.append((lineno, line.strip()))
    return matches


def _is_allowed(rel_path: str, line: str) -> bool:
    """Return True if the matched line belongs to a whitelisted function.

    We check whether any allowed function name appears anywhere in the
    file's textual context around the match — this is a loose heuristic
    because the match is done line-by-line and we don't parse the AST.
    The whitelist is meant to document the exception, not provide a tight
    AST-level guarantee.
    """
    return rel_path in WHITELIST


def _is_allowed_by_context(rel_path: str, path: Path, lineno: int) -> bool:
    """Stricter check: the match line must be inside one of the whitelisted functions.

    We scan backwards from the match line looking for the nearest ``def``
    or ``async def`` and check if its name appears in the whitelist entry
    for this file. This catches the case where a file has both allowed
    projections and an accidentally-added full-row select.
    """
    allowed_functions = WHITELIST.get(rel_path)
    if not allowed_functions:
        return False

    content_lines = path.read_text(encoding="utf-8").splitlines()
    # Walk backwards looking for the enclosing function signature
    for i in range(lineno - 1, -1, -1):
        line = content_lines[i]
        m = re.match(r"\s*(?:async\s+)?def\s+(\w+)\s*\(", line)
        if m:
            func_name = m.group(1)
            return func_name in allowed_functions
    return False


def test_no_direct_memory_fact_select_outside_factquery() -> None:
    """Enforce the architectural invariant.

    Scans all files in scope and fails if any ``select(MemoryFact)`` or
    ``select(MemoryFact, ...)`` expression is found outside a whitelisted
    function.
    """
    violations: list[str] = []

    for py_file in _iter_python_files():
        rel = _rel_path(py_file)
        matches = _matches_in_file(py_file)
        if not matches:
            continue
        for lineno, line in matches:
            if _is_allowed_by_context(rel, py_file, lineno):
                continue
            violations.append(f"{rel}:{lineno}: {line}")

    assert not violations, (
        "Architectural invariant violated: the following files contain "
        "`select(MemoryFact)` outside the FactQuery builder. All full-row "
        "reads of MemoryFact in the read pipeline must go through "
        "`arandu._fact_query.FactQuery`. If the site is a legitimate "
        "exception (column projection or point-lookup by UUID), add it "
        "to the WHITELIST in this test with a comment.\n\n" + "\n".join(f"  - {v}" for v in violations)
    )


def test_whitelist_entries_all_exist() -> None:
    """Sanity check: every whitelisted file exists on disk.

    Catches stale whitelist entries after refactors.
    """
    src_root = Path(__file__).parent.parent / "src"
    for rel_path in WHITELIST:
        abs_path = src_root / rel_path
        assert abs_path.exists(), f"Whitelist entry {rel_path} does not exist on disk"
