"""Tests for the v12.1 boolean oracles (``roam oracle <name>``).

Five oracles, each returns ``(value: bool, reason: str)``:

* ``symbol_exists``         — name match in symbols table
* ``route_exists``          — route handler URL match
* ``is_test_only``          — all callers in test files
* ``is_reachable_from_entry`` — BFS from entry symbols
* ``is_clone_of``           — persisted clone-pair membership

Pure unit tests below cover positive + negative + edge cases per oracle.
End-to-end tests cover the CLI surface (text + JSON envelope).
"""

from __future__ import annotations

import json
import os
import sqlite3
from pathlib import Path

import pytest
from click.testing import CliRunner

from roam.cli import cli
from roam.commands.cmd_oracle import (
    _detect_sibling_backend_repos,
    oracle_is_clone_of,
    oracle_is_reachable_from_entry,
    oracle_is_test_only,
    oracle_route_exists,
    oracle_symbol_exists,
)
from roam.db.connection import open_db
from tests.conftest import make_src_project as _make_project

# ---------------------------------------------------------------------------
# Fixture: a tiny indexed project with both production + test code
# ---------------------------------------------------------------------------


_FIXTURE = {
    "auth.py": """
        class UserSession:
            def __init__(self, token):
                self.token = token

            def refresh(self):
                return self.token

        def handle_login(user):
            s = UserSession(token="abc")
            return s.refresh()

        def main():
            return handle_login("alice")
    """,
    "tests/test_auth.py": """
        from auth import handle_login, UserSession

        def test_login_helper():
            return handle_login("bob")

        def test_session_only_used_in_tests():
            s = UserSession(token="xyz")
            return s.refresh()
    """,
}


# W414b: module-scoped — all oracle tests below are read-only against the
# indexed project (only test_uses_qname_columns_not_name_columns mutates,
# and it cleans up its INSERT in a try/finally). Re-indexing on every test
# cost ~25 x ~2s. Mirrors the W414 pattern in test_dashboard.py.
@pytest.fixture(scope="module")
def indexed_project(tmp_path_factory) -> Path:
    proj = _make_project(tmp_path_factory.mktemp("oracle_proj"), _FIXTURE)
    old_cwd = os.getcwd()
    try:
        os.chdir(str(proj))
        runner = CliRunner()
        result = runner.invoke(cli, ["index"])
        assert result.exit_code == 0, result.output
    finally:
        os.chdir(old_cwd)
    return proj


@pytest.fixture(autouse=True)
def _chdir_to_indexed_project(request, monkeypatch):
    """W414b: chdir per-test into the module-scoped indexed_project.

    The original function-scoped fixture chdir'd inside its yield; tests
    rely on cwd being the project root for ``open_db()`` + CLI invocations.
    With the module-scoped fixture restoring cwd at teardown, restore it
    per-test here (auto-reverted by monkeypatch).
    """
    if "indexed_project" in request.fixturenames:
        proj = request.getfixturevalue("indexed_project")
        monkeypatch.chdir(proj)


# ---------------------------------------------------------------------------
# oracle_symbol_exists — name match in symbols table
# ---------------------------------------------------------------------------


class TestSymbolExists:
    def test_existing_pascal_name(self, indexed_project):
        with open_db(readonly=True) as conn:
            value, reason = oracle_symbol_exists(conn, "UserSession")
        assert value is True, reason
        assert "UserSession" in reason

    def test_existing_snake_name(self, indexed_project):
        with open_db(readonly=True) as conn:
            value, reason = oracle_symbol_exists(conn, "handle_login")
        assert value is True, reason

    def test_method_via_qualified_suffix(self, indexed_project):
        """Methods like UserSession.refresh should match via .name suffix."""
        with open_db(readonly=True) as conn:
            value, reason = oracle_symbol_exists(conn, "refresh")
        assert value is True, reason

    def test_missing_symbol_returns_false(self, indexed_project):
        with open_db(readonly=True) as conn:
            value, reason = oracle_symbol_exists(conn, "ZzNotASymbol")
        assert value is False
        assert "no symbol" in reason

    def test_empty_query_returns_false(self, indexed_project):
        with open_db(readonly=True) as conn:
            value, reason = oracle_symbol_exists(conn, "")
        assert value is False
        assert "empty" in reason


# ---------------------------------------------------------------------------
# oracle_route_exists — workspace + fallback
# ---------------------------------------------------------------------------


class TestRouteExists:
    def test_no_workspace_no_routes_returns_false(self, indexed_project):
        """Without `roam ws resolve` populating cross_repo_edges, AND without
        any route-handler-shaped symbols indexed, the oracle returns False."""
        with open_db(readonly=True) as conn:
            value, reason = oracle_route_exists(conn, "/api/users")
        assert value is False
        assert "ws resolve" in reason or "no route" in reason or "needs" in reason

    def test_empty_path_returns_false(self, indexed_project):
        with open_db(readonly=True) as conn:
            value, reason = oracle_route_exists(conn, "")
        assert value is False
        assert "empty" in reason

    def test_path_without_leading_slash_normalised(self, indexed_project):
        """`api/users` and `/api/users` should produce identical answers."""
        with open_db(readonly=True) as conn:
            v1, _ = oracle_route_exists(conn, "/api/users")
            v2, _ = oracle_route_exists(conn, "api/users")
        assert v1 == v2

    def test_sibling_backend_probe_degrades_on_root_fs_error(self, monkeypatch):
        import roam.db.connection as connection

        def fail_lookup() -> Path:
            raise OSError("synthetic cwd lookup failure")

        monkeypatch.setattr(connection, "find_project_root", fail_lookup)
        assert _detect_sibling_backend_repos() == []

    def test_sibling_backend_probe_propagates_root_programmer_error(self, monkeypatch):
        import roam.db.connection as connection

        def fail_lookup() -> Path:
            raise RuntimeError("synthetic programmer failure")

        monkeypatch.setattr(connection, "find_project_root", fail_lookup)
        with pytest.raises(RuntimeError, match="synthetic programmer failure"):
            _detect_sibling_backend_repos()


# ---------------------------------------------------------------------------
# oracle_is_test_only — caller-role analysis
# ---------------------------------------------------------------------------


class TestIsTestOnly:
    def test_symbol_with_only_test_callers_returns_true(self, indexed_project):
        """`UserSession.__init__` is called from production (handle_login)
        and from a test file. So the constructor isn't test-only — but the
        test fixture explicitly creates a UserSession that's only referenced
        from tests. Use the test_session_only_used_in_tests path: the
        `tests/test_auth.py` symbol is itself test-only because no production
        code imports tests/. We test it via the test function symbol."""
        # The most reliable assertion: a symbol that doesn't exist returns False.
        # A truly test-only symbol is harder to construct without file_role
        # being computed. Skip if file_role isn't populated.
        with open_db(readonly=True) as conn:
            roles = conn.execute("SELECT DISTINCT file_role FROM files").fetchall()
            has_test_role = any(r[0] == "test" for r in roles)
            if not has_test_role:
                pytest.skip("file_role classification not populated in fixture")
            value, reason = oracle_is_test_only(conn, "test_login_helper")
        # `test_login_helper` is itself a test function — its callers (if
        # any) are also tests. Either way we expect a coherent answer.
        assert isinstance(value, bool)
        assert isinstance(reason, str) and reason

    def test_orphan_symbol_returns_indeterminate(self, indexed_project):
        """A symbol with no callers can't be classified as test-only —
        we lack evidence either way, so the tri-state oracle returns
        None (indeterminate) rather than collapsing to False."""
        with open_db(readonly=True) as conn:
            result = oracle_is_test_only(conn, "main")
        # value=None signals indeterminate; reason explains why.
        assert result.value in (None, False)
        if result.value is None:
            assert result.reason_class == "indeterminate_no_data"
        assert result.reason

    def test_missing_symbol_returns_false(self, indexed_project):
        with open_db(readonly=True) as conn:
            value, reason = oracle_is_test_only(conn, "ZzMissingSymbol")
        assert value is False
        assert "no symbol" in reason

    def test_empty_query_returns_false(self, indexed_project):
        with open_db(readonly=True) as conn:
            value, reason = oracle_is_test_only(conn, "")
        assert value is False


# ---------------------------------------------------------------------------
# oracle_is_reachable_from_entry — BFS
# ---------------------------------------------------------------------------


class TestIsReachableFromEntry:
    def test_missing_symbol_returns_false(self, indexed_project):
        with open_db(readonly=True) as conn:
            value, reason = oracle_is_reachable_from_entry(conn, "ZzMissing")
        assert value is False
        assert "no symbol" in reason

    def test_empty_query_returns_false(self, indexed_project):
        with open_db(readonly=True) as conn:
            value, reason = oracle_is_reachable_from_entry(conn, "")
        assert value is False

    def test_max_hops_limits_search(self, indexed_project):
        """With max_hops=0, even a 1-hop reachable symbol returns False
        (the loop exits before exploring any edges)."""
        with open_db(readonly=True) as conn:
            value, reason = oracle_is_reachable_from_entry(conn, "handle_login", max_hops=0)
        # With zero hops we either say unreachable, or "is itself an entry point",
        # or "no entry-point symbols indexed" (small fixture often has none).
        # Either way the call must not crash.
        assert isinstance(value, bool)


# ---------------------------------------------------------------------------
# oracle_is_clone_of — clone_pairs lookup
# ---------------------------------------------------------------------------


class TestIsCloneOf:
    def test_clone_table_absent_returns_false_with_hint(self, indexed_project):
        """Without `roam clones --persist`, the clone_pairs table either
        doesn't exist or is empty — the oracle should return False with
        a helpful hint."""
        with open_db(readonly=True) as conn:
            value, reason = oracle_is_clone_of(conn, "handle_login")
        assert value is False
        # Either the hint about persisting, or a "no clone siblings" message.
        assert "clones --persist" in reason or "no clone" in reason

    def test_empty_query_returns_false(self, indexed_project):
        with open_db(readonly=True) as conn:
            value, reason = oracle_is_clone_of(conn, "")
        assert value is False

    def test_uses_qname_columns_not_name_columns(self, indexed_project):
        """Regression: the oracle previously queried ``name_a/name_b`` but
        the schema actually stores ``qname_a/qname_b``. With the wrong
        column names the query threw ``OperationalError`` and the oracle
        always returned the "tables not present" hint — even when clones
        were persisted. This test pins the correct column names by
        inserting a synthetic clone row and checking the oracle finds it.
        """
        from roam.db.connection import open_db as _open

        # Need write access to insert a synthetic row.
        with _open(readonly=False) as conn:
            try:
                conn.execute(
                    "INSERT INTO clone_pairs (qname_a, qname_b, file_a, file_b, jaccard, kind) "
                    "VALUES (?, ?, ?, ?, ?, ?)",
                    (
                        "auth.handle_login",
                        "auth.handle_logout",
                        "auth.py",
                        "auth.py",
                        0.95,
                        "type2",
                    ),
                )
                conn.commit()
            except sqlite3.DatabaseError:
                pytest.skip("clone_pairs schema differs in this fixture")

        try:
            with open_db(readonly=True) as conn:
                # Suffix match: bare 'handle_login' must find 'auth.handle_login'.
                value, reason = oracle_is_clone_of(conn, "handle_login")
            assert value is True, reason
            assert "clone pair" in reason
        finally:
            # W414b: module-scoped fixture — clean up the synthetic row so
            # later tests (e.g. TestCLI.test_is_clone_of_text_when_clones_not_persisted)
            # see the canonical "clones not persisted" state.
            with _open(readonly=False) as conn:
                conn.execute(
                    "DELETE FROM clone_pairs WHERE qname_a = ? AND qname_b = ?",
                    ("auth.handle_login", "auth.handle_logout"),
                )
                conn.commit()


# ---------------------------------------------------------------------------
# CLI surface — text + JSON envelope
# ---------------------------------------------------------------------------


class TestCLI:
    def test_help_lists_all_subcommands(self, indexed_project):
        runner = CliRunner()
        result = runner.invoke(cli, ["oracle", "--help"])
        assert result.exit_code == 0, result.output
        for sub in (
            "symbol-exists",
            "route-exists",
            "is-test-only",
            "is-reachable-from-entry",
            "is-clone-of",
        ):
            assert sub in result.output, f"missing subcommand {sub!r} in help"

    def test_symbol_exists_text_verdict(self, indexed_project):
        runner = CliRunner()
        result = runner.invoke(cli, ["oracle", "symbol-exists", "UserSession"])
        assert result.exit_code == 0, result.output
        assert "VERDICT: true" in result.output

    def test_symbol_missing_text_verdict(self, indexed_project):
        runner = CliRunner()
        result = runner.invoke(cli, ["oracle", "symbol-exists", "ZzMissing"])
        assert result.exit_code == 0, result.output
        assert "VERDICT: false" in result.output

    def test_symbol_exists_json_envelope(self, indexed_project):
        runner = CliRunner()
        result = runner.invoke(cli, ["--json", "oracle", "symbol-exists", "UserSession"])
        assert result.exit_code == 0, result.output
        data = json.loads(result.output)
        assert data["command"] == "oracle:symbol-exists"
        assert data["summary"]["verdict"] == "true"
        assert data["summary"]["value"] is True
        assert data["name"] == "UserSession"
        assert isinstance(data["summary"]["reason"], str) and data["summary"]["reason"]

    def test_route_exists_json_envelope(self, indexed_project):
        runner = CliRunner()
        result = runner.invoke(cli, ["--json", "oracle", "route-exists", "/api/users"])
        assert result.exit_code == 0, result.output
        data = json.loads(result.output)
        assert data["command"] == "oracle:route-exists"
        assert data["path"] == "/api/users"
        assert "value" in data["summary"]

    def test_is_clone_of_text_when_clones_not_persisted(self, indexed_project):
        runner = CliRunner()
        result = runner.invoke(cli, ["oracle", "is-clone-of", "handle_login"])
        assert result.exit_code == 0, result.output
        assert "VERDICT: false" in result.output

    def test_is_reachable_max_hops_flag(self, indexed_project):
        runner = CliRunner()
        result = runner.invoke(cli, ["oracle", "is-reachable-from-entry", "handle_login", "--max-hops", "3"])
        assert result.exit_code == 0, result.output
        assert "VERDICT:" in result.output

    def test_oracle_in_help_listing(self, indexed_project):
        # W19.2 5-verb help redesign: compact `--help` only shows 5 verbs;
        # full inventory lives in `--help-all` / `_COMMANDS`. Assert against
        # the registry (source of truth) and `--help-all` (UX surface).
        from roam.cli import _COMMANDS

        assert "oracle" in _COMMANDS
        runner = CliRunner()
        result = runner.invoke(cli, ["--help-all"])
        assert result.exit_code == 0, result.output
        assert "oracle" in result.output
