"""Per-tool auth scope audit -- ensures every mutating MCP tool has an auth scope.

This is a "no tool left behind" test: if a new tool is added to server.py
that performs write/mutation operations, it MUST call ``_check_auth(scope)``
or ``_check_billing_auth(scope)``.  Read-only / informational tools are
explicitly allowlisted and do not require scopes.

Existing mutating tools that predate the auth scope requirement are tracked
in ``KNOWN_UNSCOPED_MUTATING_TOOLS``.  Each one should be migrated to use
``_check_auth()`` and removed from that set.  The test will fail if:

1. A NEW tool appears without auth AND is not in READ_ONLY_TOOLS.
2. An entry in READ_ONLY_TOOLS gains an auth check (misclassified).
3. A stale entry exists in either allowlist (tool was renamed/removed).
4. A tool in KNOWN_UNSCOPED_MUTATING_TOOLS gains auth (remove from set).

Coverage areas:
- Every @mcp.tool() function in server.py is accounted for
- Mutating tools have a _check_auth or _check_billing_auth call
- No new tool is silently added without scope assignment
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _get_server_source() -> str:
    """Return the full source text of kiln/server.py."""
    server_path = Path(__file__).resolve().parent.parent / "src" / "kiln" / "server.py"
    return server_path.read_text()


def _extract_tools(source: str) -> dict[str, str]:
    """Extract all @mcp.tool() function names and their bodies from source.

    Returns a dict mapping tool function name -> function body text.
    """
    lines = source.split("\n")
    tools: dict[str, str] = {}
    i = 0
    while i < len(lines):
        if "@mcp.tool()" in lines[i]:
            # Find the def line (sync or async)
            j = i + 1
            while j < len(lines) and not (
                lines[j].strip().startswith("def ")
                or lines[j].strip().startswith("async def ")
            ):
                j += 1
            if j < len(lines):
                match = re.match(r"\s*(?:async\s+)?def\s+(\w+)\s*\(", lines[j])
                if match:
                    tool_name = match.group(1)
                    # Collect body until next @mcp.tool() or top-level def/class
                    body_start = j + 1
                    k = body_start
                    while k < len(lines):
                        if "@mcp.tool()" in lines[k]:
                            break
                        if (
                            k > body_start
                            and lines[k].strip()
                            and not lines[k][0].isspace()
                            and (
                                lines[k].startswith("def ")
                                or lines[k].startswith("class ")
                                or lines[k].startswith("@")
                            )
                        ):
                            break
                        k += 1
                    body = "\n".join(lines[body_start:k])
                    tools[tool_name] = body
            i = j + 1 if j > i else i + 1
        else:
            i += 1
    return tools


def _tool_has_auth_check(body: str) -> bool:
    """Return True if the tool body calls _check_auth or _check_billing_auth."""
    return "_check_auth(" in body or "_check_billing_auth(" in body


# ---------------------------------------------------------------------------
# Read-only tools -- intentionally exempt from auth scopes.
#
# These tools only return information and never modify printer state,
# files, jobs, configuration, or any persistent data.
# ---------------------------------------------------------------------------

READ_ONLY_TOOLS: set[str] = {
    # Printer status / info (read-only queries)
    "printer_status",
    "printer_files",
    "analyze_print_file",
    "preflight_check",
    "fleet_status",
    "recent_events",
    "bed_level_status",
    "list_plugins",
    "list_materials",
    "get_material",
    "check_material_match",
    "list_spools",
    "webcam_stream",
    "printer_snapshot",

    # Safety / audit (read-only inspections)
    "get_autonomy_level",
    "check_autonomy",

    # Billing read-only

    # License read-only
    "license_status",

    # Marketplace read-only (searches / metadata)
    "marketplace_info",

    # Slicer discovery (read-only)

    # Fulfillment read-only

    # Monitoring / analysis (read-only)
    "list_webhooks",
    "compare_print_options",
    "analyze_print_failure",
    "render_model_preview",
    "visualize_model",
    "get_feedback_loop_status",
    "list_design_templates",
    "validate_openscad_code",

    # Onboarding / help (no side effects)

    # Discovery / scan (no mutation)

    # Skill manifest (read-only metadata)

    # Utility read-only helpers
    "find_material_substitute",
    "get_best_material_substitute",
    "extract_file_metadata",
    "analyze_print_snapshot",
    "get_fulfillment_quote_cached",
    "print_status_lite",
    "list_snapshots",
    "check_printer_health",

    # Fleet site grouping / cost reporting (read-only)
    "project_cost_summary",
    "client_cost_report",

    # Database / infrastructure status (read-only)

    # Ambient / trend analysis (read-only)
    "check_ambient_conditions",
    "printer_trend_analysis",

    # Orientation / monitoring (read-only)
    "monitor_print",
    "check_orientation",

    # Design generation analysis — Phase 4 (read-only)
    "predict_print_failure",

    # Design generation analysis — Phase 5 (read-only)

    # Design generation analysis — Phase 6 (read-only)

    # Design reasoning engine (read-only structural analysis)

    # Render comparison (read-only)
    "compare_renders",
}


# ---------------------------------------------------------------------------
# Known unscoped mutating tools -- technical debt from before the auth audit.
#
# Each tool here performs a write/mutation operation but does NOT yet call
# _check_auth().  When you add auth to one of these tools, REMOVE it from
# this set so the test tracks progress.  The test will fail if a tool in
# this set gains auth (prompting removal), or if a NEW mutating tool is
# added without being in either allowlist.
# ---------------------------------------------------------------------------

KNOWN_UNSCOPED_MUTATING_TOOLS: set[str] = {"restart_server"}


# ---------------------------------------------------------------------------
# Test class
# ---------------------------------------------------------------------------

class TestAuthScopeAudit:
    """Audit that every mutating MCP tool has an auth scope declared."""

    @pytest.fixture(scope="class")
    def tools(self) -> dict[str, str]:
        source = _get_server_source()
        return _extract_tools(source)

    def test_all_tools_accounted_for(self, tools: dict[str, str]):
        """Every tool must be in one of: has auth, READ_ONLY_TOOLS, or KNOWN_UNSCOPED."""
        unaccounted = []
        for name, body in tools.items():
            has_auth = _tool_has_auth_check(body)
            is_read_only = name in READ_ONLY_TOOLS
            is_known_gap = name in KNOWN_UNSCOPED_MUTATING_TOOLS
            if not has_auth and not is_read_only and not is_known_gap:
                unaccounted.append(name)

        assert not unaccounted, (
            f"New tools missing auth scope: {sorted(unaccounted)}. "
            f"Add _check_auth('scope') to the tool, add it to READ_ONLY_TOOLS "
            f"if truly read-only, or add it to KNOWN_UNSCOPED_MUTATING_TOOLS "
            f"(temporary) in test_auth_scope_audit.py."
        )

    def test_known_unscoped_tools_still_lack_auth(self, tools: dict[str, str]):
        """Tools in KNOWN_UNSCOPED_MUTATING_TOOLS that gain auth should be removed.

        This tracks progress: when you add _check_auth() to a tool, remove
        it from KNOWN_UNSCOPED_MUTATING_TOOLS.
        """
        now_scoped = []
        for name in KNOWN_UNSCOPED_MUTATING_TOOLS:
            if name in tools and _tool_has_auth_check(tools[name]):
                now_scoped.append(name)

        assert not now_scoped, (
            f"Tools in KNOWN_UNSCOPED_MUTATING_TOOLS that now have auth "
            f"(remove them from the set): {sorted(now_scoped)}"
        )

    def test_known_unscoped_count_only_shrinks(self, tools: dict[str, str]):
        """Track the size of the debt backlog -- it should only decrease over time."""
        current_count = len(KNOWN_UNSCOPED_MUTATING_TOOLS)
        # Baseline: 29 tools as of the initial audit.  If this number
        # increases, someone added a mutating tool to the gap set instead
        # of adding proper auth.
        assert current_count <= 29, (
            f"KNOWN_UNSCOPED_MUTATING_TOOLS grew to {current_count} (was 29). "
            f"Add _check_auth() to the new tool instead of adding it to the debt set."
        )

    def test_read_only_tools_have_no_auth(self, tools: dict[str, str]):
        """Tools in READ_ONLY_TOOLS should NOT have auth checks."""
        misclassified = []
        for name in READ_ONLY_TOOLS:
            if name in tools and _tool_has_auth_check(tools[name]):
                misclassified.append(name)

        assert not misclassified, (
            f"Tools in READ_ONLY_TOOLS that now have auth checks "
            f"(remove from allowlist): {sorted(misclassified)}"
        )

    def test_no_stale_read_only_entries(self, tools: dict[str, str]):
        """Every entry in READ_ONLY_TOOLS must correspond to an actual tool."""
        stale = READ_ONLY_TOOLS - set(tools.keys())
        assert not stale, (
            f"READ_ONLY_TOOLS contains entries that are not registered tools "
            f"(remove them): {sorted(stale)}"
        )

    def test_no_stale_known_unscoped_entries(self, tools: dict[str, str]):
        """Every entry in KNOWN_UNSCOPED_MUTATING_TOOLS must be an actual tool."""
        stale = KNOWN_UNSCOPED_MUTATING_TOOLS - set(tools.keys())
        assert not stale, (
            f"KNOWN_UNSCOPED_MUTATING_TOOLS contains entries that are not "
            f"registered tools (remove them): {sorted(stale)}"
        )

    def test_no_overlap_between_sets(self):
        """READ_ONLY_TOOLS and KNOWN_UNSCOPED_MUTATING_TOOLS must not overlap."""
        overlap = READ_ONLY_TOOLS & KNOWN_UNSCOPED_MUTATING_TOOLS
        assert not overlap, (
            f"Tools appear in both READ_ONLY_TOOLS and "
            f"KNOWN_UNSCOPED_MUTATING_TOOLS: {sorted(overlap)}"
        )

    def test_mutating_tools_with_scopes_detected(self, tools: dict[str, str]):
        """Sanity check: at least 50 tools should have auth scopes.

        Catches a broken parser -- if the extractor stops finding auth
        checks, this test will fail.
        """
        auth_count = sum(1 for body in tools.values() if _tool_has_auth_check(body))
        assert auth_count >= 50, (
            f"Expected at least 50 tools with auth scopes, found {auth_count}. "
            f"The source parser may be broken."
        )

    def test_critical_write_tools_not_in_read_only(self):
        """Known write/mutation tools must never appear in READ_ONLY_TOOLS."""
        critical_write_tools = {
            "upload_file",
            "start_print",
            "cancel_print",
            "delete_file",
            "send_gcode",
            "set_temperature",
            "register_printer",
            "submit_job",
            "cancel_job",
            "register_webhook",
            "delete_webhook",
            "generate_model",
            "fulfillment_order",
        }
        overlap = critical_write_tools & READ_ONLY_TOOLS
        assert not overlap, (
            f"Critical write tools found in READ_ONLY_TOOLS: {sorted(overlap)}"
        )
