"""Tests for the write-classification and policy-guard single source of truth.

Two goals:

1. Verify the classification tables, fail-mode switch, and guard framework
   behave as specified (handoff lines 249-265).
2. **Pin identity** — assert that ``server.py`` imports the *same function
   objects* from this module, so the classification cannot drift by accident.
"""

from __future__ import annotations

from unittest.mock import MagicMock

import pytest

from a360_mcp.policies import write_guards as wg
from a360_mcp.policies.write_guards import (
    CONCURRENT_EDIT_WINDOW_SECONDS,
    ENV_FAIL_MODE,
    ENVIRONMENT_CONFIRM_FIELD,
    FAIL_CLOSED,
    FAIL_OPEN,
    GUARD_META_FIELDS,
    MUTATING_TOOL_NAMES,
    MUTATING_TOOL_PREFIXES,
    PROD_ENVIRONMENT_VALUE,
    PUBLISH_GRADE_TOOL_PREFIXES,
    READ_ONLY_ACTIONS,
    get_fail_mode,
    is_fail_closed,
    is_fail_open,
    is_mutating_tool,
    is_publish_grade_tool,
    is_read_only_call,
    run_post_confirm_guards,
    run_pre_confirm_guards,
)
from a360_mcp.utils.config import A360AuthConfig, ServerConfig


@pytest.fixture(autouse=True)
def _clean_fail_env(monkeypatch):
    monkeypatch.delenv(ENV_FAIL_MODE, raising=False)


def _config(username: str = "svc_mcp") -> ServerConfig:
    return ServerConfig(
        control_room_url="https://cr.example.com",
        auth=A360AuthConfig(username=username, api_key="k"),
    )


def _auth() -> MagicMock:
    auth = MagicMock(spec=["session_summary", "make_request", "get_headers"])
    auth.session_summary.return_value = {"used": 1, "limit": 5, "owners": ["a360-mcp:1"]}
    return auth


# --------------------------------------------------------------------------- #
# Classification tables
# --------------------------------------------------------------------------- #


class TestClassification:
    def test_prefix_match_is_mutating(self):
        assert is_mutating_tool("deploy_bot") is True
        assert is_mutating_tool("create_folder") is True
        assert is_mutating_tool("delete_file") is True

    def test_non_matching_is_read_only(self):
        assert is_mutating_tool("list_executions") is False
        assert is_mutating_tool("get_record") is False
        assert is_mutating_tool("diagnose_execution") is False

    def test_read_only_actions_override_prefix(self):
        # Even if a mutating prefix were added for run_, these carve-outs hold.
        for name in READ_ONLY_ACTIONS:
            assert is_mutating_tool(name) is False, f"{name} should be read-only"

    def test_explicit_mutating_name(self):
        # Phase 9: open_debug_window and close_debug_window are mutating by
        # explicit name (they have physical side effects: a Chromium process
        # appears/disappears, a session slot is claimed/released).
        assert "open_debug_window" in MUTATING_TOOL_NAMES
        assert "close_debug_window" in MUTATING_TOOL_NAMES
        assert is_mutating_tool("open_debug_window") is True
        assert is_mutating_tool("close_debug_window") is True
        # Verify MUTATING_TOOL_NAMES is a frozenset (immutable contract).
        assert isinstance(MUTATING_TOOL_NAMES, frozenset)

    def test_is_read_only_call_is_inverse(self):
        assert is_read_only_call("deploy_bot") is False
        assert is_read_only_call("list_x") is True

    def test_is_read_only_call_accepts_arguments(self):
        # The arguments parameter is accepted but not used for classification
        # today — it exists for future context-sensitive checks.
        assert is_read_only_call("deploy_bot", {"x": 1}) is False

    def test_all_mutating_prefixes_are_lowercase(self):
        for p in MUTATING_TOOL_PREFIXES:
            assert p == p.lower(), f"prefix '{p}' must be lowercase"
            assert p.endswith("_"), f"prefix '{p}' should end with underscore"

    def test_publish_grade_is_subset_of_mutating(self):
        # Every publish-grade prefix must also classify as mutating.
        for p in PUBLISH_GRADE_TOOL_PREFIXES:
            assert any(
                p.startswith(mp) or mp.startswith(p) for mp in MUTATING_TOOL_PREFIXES
            ), f"publish-grade prefix '{p}' is not covered by mutating prefixes"


class TestPublishGrade:
    def test_deploy_is_publish_grade(self):
        assert is_publish_grade_tool("deploy_thing") is True

    def test_publish_is_publish_grade(self):
        assert is_publish_grade_tool("publish_bot") is True

    def test_non_publish_mutating_is_not_publish_grade(self):
        assert is_publish_grade_tool("rename_thing") is False
        assert is_publish_grade_tool("delete_file") is False

    def test_read_only_is_not_publish_grade(self):
        assert is_publish_grade_tool("list_x") is False


# --------------------------------------------------------------------------- #
# Fail mode
# --------------------------------------------------------------------------- #


class TestFailMode:
    def test_default_is_fail_open(self):
        assert get_fail_mode() == FAIL_OPEN
        assert is_fail_open() is True
        assert is_fail_closed() is False

    def test_env_closed_flips(self, monkeypatch):
        monkeypatch.setenv(ENV_FAIL_MODE, "closed")
        assert get_fail_mode() == FAIL_CLOSED
        assert is_fail_closed() is True
        assert is_fail_open() is False

    def test_env_unknown_stays_open(self, monkeypatch):
        monkeypatch.setenv(ENV_FAIL_MODE, "maybe")
        assert get_fail_mode() == FAIL_OPEN

    def test_env_case_insensitive(self, monkeypatch):
        monkeypatch.setenv(ENV_FAIL_MODE, "CLOSED")
        assert get_fail_mode() == FAIL_CLOSED


# --------------------------------------------------------------------------- #
# G7: publish double-confirm (fully implemented)
# --------------------------------------------------------------------------- #


class TestG7PublishDoubleConfirm:
    def test_publish_grade_without_environment_blocks(self):
        error = run_pre_confirm_guards(
            "deploy_bot",
            {"approve": True},
            config=_config(),
            auth_manager=_auth(),
        )
        assert error is not None
        assert ENVIRONMENT_CONFIRM_FIELD in error
        assert PROD_ENVIRONMENT_VALUE in error
        assert "approve" in error

    def test_publish_grade_with_environment_passes(self):
        error = run_pre_confirm_guards(
            "deploy_bot",
            {"approve": True, ENVIRONMENT_CONFIRM_FIELD: PROD_ENVIRONMENT_VALUE},
            config=_config(),
            auth_manager=_auth(),
        )
        assert error is None

    def test_non_publish_grade_passes_without_environment(self):
        error = run_pre_confirm_guards(
            "rename_thing",
            {"approve": True},
            config=_config(),
            auth_manager=_auth(),
        )
        assert error is None

    def test_read_only_tool_passes(self):
        error = run_pre_confirm_guards(
            "list_executions",
            {},
            config=_config(),
            auth_manager=_auth(),
        )
        assert error is None

    def test_wrong_environment_value_blocks(self):
        error = run_pre_confirm_guards(
            "deploy_bot",
            {ENVIRONMENT_CONFIRM_FIELD: "staging"},
            config=_config(),
            auth_manager=_auth(),
        )
        assert error is not None


# --------------------------------------------------------------------------- #
# G10: session identity (framework slot — fail-open)
# --------------------------------------------------------------------------- #


class TestG10SessionIdentity:
    def test_passes_with_valid_config(self):
        error = run_pre_confirm_guards(
            "deploy_bot",
            {ENVIRONMENT_CONFIRM_FIELD: PROD_ENVIRONMENT_VALUE},
            config=_config(),
            auth_manager=_auth(),
        )
        # G7 passes (environment set), G10 fails open → None.
        assert error is None

    def test_blocks_on_empty_username(self):
        # A360AuthConfig validates non-empty username at construction, so this
        # path is defensive — test it with a mock config that bypasses validation.
        mock_config = MagicMock()
        mock_config.auth.username = ""
        error = run_pre_confirm_guards(
            "list_x",
            {},
            config=mock_config,
            auth_manager=_auth(),
        )
        assert error is not None
        assert "username" in error.lower()

    def test_fail_closed_when_summary_unavailable(self, monkeypatch):
        monkeypatch.setenv(ENV_FAIL_MODE, "closed")
        auth = _auth()
        auth.session_summary.side_effect = RuntimeError("boom")
        error = run_pre_confirm_guards(
            "list_x",
            {},
            config=_config(),
            auth_manager=auth,
        )
        assert error is not None
        assert "fail-closed" in error.lower() or "cannot" in error.lower()

    def test_fail_open_when_summary_unavailable(self):
        auth = _auth()
        auth.session_summary.side_effect = RuntimeError("boom")
        error = run_pre_confirm_guards(
            "list_x",
            {},
            config=_config(),
            auth_manager=auth,
        )
        assert error is None


# --------------------------------------------------------------------------- #
# G3/G8 + G9: framework slots (pass-through until Phase 6)
# --------------------------------------------------------------------------- #


class TestPostConfirmGuards:
    def test_passes_for_any_tool(self):
        error = run_post_confirm_guards(
            "deploy_bot",
            {"target": "x"},
            config=_config(),
            auth_manager=_auth(),
        )
        assert error is None

    def test_passes_for_read_only(self):
        error = run_post_confirm_guards(
            "list_x",
            {},
            config=_config(),
            auth_manager=_auth(),
        )
        assert error is None


# --------------------------------------------------------------------------- #
# Guard meta fields
# --------------------------------------------------------------------------- #


class TestGuardMetaFields:
    def test_environment_field_in_meta(self):
        assert ENVIRONMENT_CONFIRM_FIELD in GUARD_META_FIELDS

    def test_meta_fields_are_frozenset(self):
        assert isinstance(GUARD_META_FIELDS, frozenset)


# --------------------------------------------------------------------------- #
# Identity pin: server.py imports the SAME function objects
# --------------------------------------------------------------------------- #


class TestIdentityPin:
    """Assert server.py re-exports the exact same callables — no drift."""

    def test_server_is_mutating_tool_is_wg_is_mutating_tool(self):
        from a360_mcp.server import is_mutating_tool as srv_mutating

        assert srv_mutating is wg.is_mutating_tool

    def test_server_is_read_only_call_is_wg_is_read_only_call(self):
        from a360_mcp.server import is_read_only_call as srv_readonly

        assert srv_readonly is wg.is_read_only_call

    def test_server_environment_confirm_field_matches(self):
        from a360_mcp.server import ENVIRONMENT_CONFIRM_FIELD as srv_env

        assert srv_env is ENVIRONMENT_CONFIRM_FIELD

    def test_no_local_mutating_tables_in_server(self):
        """server.py must NOT define its own _MUTATING_TOOL_PREFIXES."""
        import a360_mcp.server as srv

        assert not hasattr(srv, "_MUTATING_TOOL_PREFIXES")
        assert not hasattr(srv, "_MUTATING_TOOL_NAMES")


# --------------------------------------------------------------------------- #
# Constants sanity
# --------------------------------------------------------------------------- #


class TestConstants:
    def test_concurrent_edit_window_is_positive(self):
        assert CONCURRENT_EDIT_WINDOW_SECONDS > 0

    def test_prefixes_are_tuples(self):
        assert isinstance(MUTATING_TOOL_PREFIXES, tuple)
        assert isinstance(PUBLISH_GRADE_TOOL_PREFIXES, tuple)

    def test_read_only_actions_is_frozenset(self):
        assert isinstance(READ_ONLY_ACTIONS, frozenset)


# --------------------------------------------------------------------------- #
# Phase 9: browser tool classification
# --------------------------------------------------------------------------- #


class TestBrowserClassification:
    """open/close_debug_window mutate (physical side effects); inspect_* read-only."""

    def test_open_debug_window_is_mutating(self):
        assert is_mutating_tool("open_debug_window") is True

    def test_close_debug_window_is_mutating(self):
        assert is_mutating_tool("close_debug_window") is True

    def test_inspect_console_is_read_only(self):
        assert is_mutating_tool("inspect_console") is False
        assert is_read_only_call("inspect_console") is True

    def test_inspect_network_is_read_only(self):
        assert is_mutating_tool("inspect_network") is False

    def test_screenshot_debug_is_read_only(self):
        assert is_mutating_tool("screenshot_debug") is False

    def test_get_computed_style_is_read_only(self):
        assert is_mutating_tool("get_computed_style") is False

    def test_browser_tools_not_publish_grade(self):
        # Browser tools have physical side effects but do not deploy/publish.
        for name in ("open_debug_window", "close_debug_window", "inspect_console"):
            assert is_publish_grade_tool(name) is False, name

    def test_no_mutating_prefix_matches_browser_tools(self):
        # The browser tools are mutating purely via MUTATING_TOOL_NAMES, not by
        # accident of prefix matching. Verifies intent of the explicit listing.
        for name in ("open_debug_window", "close_debug_window"):
            assert not any(
                name.startswith(prefix) for prefix in MUTATING_TOOL_PREFIXES
            ), f"{name} unexpectedly matched a mutating prefix"
