"""Tests for the A360 MCP server: schema compaction, package gating, call pipeline.

We register throwaway tools directly into the registry (no on-disk modules
needed) and drive the server through its public methods. The MCP transport
layer is not exercised here — it is covered by the mcp package's own tests and
is too slow/heavy for unit testing. We test the impl methods directly.
"""

from __future__ import annotations

import os
from unittest.mock import MagicMock

import pytest
from pydantic import BaseModel

from a360_mcp.auth.auth_manager import A360AuthError, AuthManager
from a360_mcp.server import (
    CONFIRM_FIELD,
    DEFAULT_TOOL_PACKAGE,
    ENV_TOOL_PACKAGE,
    A360MCPServer,
    _compact_schema,
    _flatten_anyof_nullable,
    _is_self_explanatory_field,
    _strip_field_filler,
    get_tool_schema,
    is_mutating_tool,
    serialize_tool_output,
)
from a360_mcp.utils import registry
from a360_mcp.utils.registry import clear_registry, register_tool
from a360_mcp.utils.response_budget import DEFAULT_BUDGET_BYTES


@pytest.fixture(autouse=True)
def _clean_registry():
    clear_registry()
    registry.reset_discovery_state()
    yield
    clear_registry()
    registry.reset_discovery_state()


@pytest.fixture(autouse=True)
def _no_package_env(monkeypatch):
    monkeypatch.delenv(ENV_TOOL_PACKAGE, raising=False)
    # These tests exercise server *mechanics* (pipeline, compaction, confirm
    # gate) with ad-hoc tools, not the real package roster. Reset every
    # package to "all tools" so ad-hoc tools are callable; the package-gating
    # tests override specific entries via their own monkeypatch.setitem.
    from a360_mcp import server as server_mod

    for pkg in list(server_mod._PACKAGE_DEFINITIONS):
        monkeypatch.setitem(server_mod._PACKAGE_DEFINITIONS, pkg, None)


# --------------------------------------------------------------------------- #
# Fixtures
# --------------------------------------------------------------------------- #


class EchoParams(BaseModel):
    """Params for the echo test tool."""

    message: str
    limit: int = 10
    dry_run: bool = False
    include_archived: bool = False
    long_default: str = "x" * 200


class WriteParams(BaseModel):
    target: str
    approve: bool = False


def _make_server(server_config, **kwargs):
    """Build a server with a stub auth manager (no real network)."""
    auth = MagicMock(
        spec=["get_headers", "make_request", "invalidate", "logout", "session_summary"]
    )
    return A360MCPServer(server_config, auth_manager=auth, **kwargs)


# --------------------------------------------------------------------------- #
# Schema compaction
# --------------------------------------------------------------------------- #


def test_compact_drops_title():
    schema = {"type": "object", "title": "T", "properties": {}}
    assert "title" not in _compact_schema(schema)


def test_compact_drops_empty_top_level_required():
    schema = {"type": "object", "required": [], "properties": {}}
    out = _compact_schema(schema, _top_level=True)
    assert "required" not in out


def test_compact_keeps_nonempty_required():
    schema = {"type": "object", "required": ["a"], "properties": {}}
    out = _compact_schema(schema, _top_level=True)
    assert out["required"] == ["a"]


def test_compact_drops_additional_properties_false():
    schema = {"type": "object", "additionalProperties": False, "properties": {}}
    out = _compact_schema(schema, _top_level=True)
    assert "additionalProperties" not in out


def test_compact_drops_trivial_defaults():
    schema = {
        "type": "object",
        "properties": {
            "a": {"type": "integer", "default": 0},
            "b": {"type": "boolean", "default": False},
            "c": {"type": "string", "default": ""},
            "d": {"type": "string", "default": "keep"},
        },
    }
    out = _compact_schema(schema)
    assert "default" not in out["properties"]["a"]
    assert "default" not in out["properties"]["b"]
    assert "default" not in out["properties"]["c"]
    assert out["properties"]["d"]["default"] == "keep"


def test_compact_drops_long_string_default_without_truncating():
    long_val = "x" * 200
    schema = {"type": "object", "properties": {"a": {"type": "string", "default": long_val}}}
    out = _compact_schema(schema)
    # The default is dropped entirely, never truncated.
    assert "default" not in out["properties"]["a"]


def test_flatten_anyof_nullable():
    schema = {"anyOf": [{"type": "string", "minLength": 1}, {"type": "null"}]}
    out = _flatten_anyof_nullable(schema)
    assert out["type"] == "string"
    assert out["minLength"] == 1
    assert "anyOf" not in out


def test_flatten_anyof_keeps_non_nullable():
    schema = {"anyOf": [{"type": "string"}, {"type": "integer"}]}
    out = _flatten_anyof_nullable(schema)
    assert "anyOf" in out


def test_is_self_explanatory_field_in_set():
    assert _is_self_explanatory_field("dry_run", {"type": "boolean"}) is True
    assert _is_self_explanatory_field("limit", {"type": "integer"}) is True


def test_is_self_explanatory_field_boolean_toggle():
    assert _is_self_explanatory_field("include_archived", {"type": "boolean"}) is True
    assert _is_self_explanatory_field("logs_only", {"type": "boolean"}) is True


def test_is_self_explanatory_field_bounds():
    assert _is_self_explanatory_field("max_results", {"type": "integer"}) is True
    assert _is_self_explanatory_field("min_offset", {"type": "integer"}) is True


def test_is_self_explanatory_field_negative():
    assert _is_self_explanatory_field("message", {"type": "string"}) is False
    assert _is_self_explanatory_field("target", {"type": "string"}) is False


def test_strip_field_filler_drops_description_for_self_explanatory():
    out = _strip_field_filler("dry_run", {"type": "boolean", "description": "If true..."})
    assert "description" not in out


def test_strip_field_filler_keeps_description_for_named_fields():
    out = _strip_field_filler(
        "message", {"type": "string", "description": "Important routing hint"}
    )
    assert out["description"] == "Important routing hint"


def test_get_tool_schema_caches():
    a = get_tool_schema(EchoParams)
    b = get_tool_schema(EchoParams)
    assert a is b


def test_get_tool_schema_strips_model_docstring():
    schema = get_tool_schema(EchoParams)
    # Top-level description (model docstring) is dropped.
    assert "description" not in schema
    # Nested field descriptions for self-explanatory fields are gone.
    assert "description" not in schema["properties"]["dry_run"]
    # Named field descriptions survive.
    # (EchoParams.message has no description, so just assert the field exists.)
    assert "message" in schema["properties"]


def test_get_tool_schema_drops_long_default():
    schema = get_tool_schema(EchoParams)
    # long_default was 200 chars; must be dropped, not truncated.
    assert "default" not in schema["properties"]["long_default"]


def test_get_tool_schema_drops_defs_in_non_detail_mode():
    get_tool_schema(EchoParams, detail=True)  # ensures detail path does not crash
    schema_plain = get_tool_schema(EchoParams, detail=False)
    # Plain mode never carries $defs.
    assert "$defs" not in schema_plain
    assert "definitions" not in schema_plain


# --------------------------------------------------------------------------- #
# Serialization + budget
# --------------------------------------------------------------------------- #


def test_serialize_string_passthrough():
    assert serialize_tool_output('{"a":1}', tool_name="t") == '{"a":1}'


def test_serialize_dict_applies_budget():
    # Build a result over budget; serialize must shrink it.
    big = {"rows": [{"id": str(i), "content": "x" * 5000} for i in range(50)]}
    text = serialize_tool_output(big, tool_name="t")
    assert len(text.encode("utf-8")) <= DEFAULT_BUDGET_BYTES + 1000  # allow marker overhead


def test_serialize_pydantic_model():
    from pydantic import BaseModel

    class M(BaseModel):
        x: int = 1

    text = serialize_tool_output(M(x=5), tool_name="t")
    assert '"x":5' in text


# --------------------------------------------------------------------------- #
# Mutating-tool heuristic
# --------------------------------------------------------------------------- #


def test_is_mutating_tool_prefix():
    assert is_mutating_tool("deploy_bot") is True
    assert is_mutating_tool("delete_x") is True


def test_is_mutating_tool_readonly():
    assert is_mutating_tool("list_executions") is False
    assert is_mutating_tool("get_execution") is False


# --------------------------------------------------------------------------- #
# Package resolution
# --------------------------------------------------------------------------- #


def test_default_package(server_config):
    srv = _make_server(server_config)
    assert srv.package == DEFAULT_TOOL_PACKAGE


def test_explicit_package(server_config):
    srv = _make_server(server_config, package="diagnose")
    assert srv.package == "diagnose"


def test_env_package(server_config, monkeypatch):
    monkeypatch.setenv(ENV_TOOL_PACKAGE, "full")
    srv = _make_server(server_config)
    assert srv.package == "full"


def test_unknown_env_package_falls_back(server_config, monkeypatch):
    monkeypatch.setenv(ENV_TOOL_PACKAGE, "bogus")
    srv = _make_server(server_config)
    assert srv.package == DEFAULT_TOOL_PACKAGE


# --------------------------------------------------------------------------- #
# list_tools gating
# --------------------------------------------------------------------------- #


def test_list_tools_empty_when_no_tools_registered(server_config):
    @register_tool("echo", params=EchoParams, description="Echo tool")
    def echo(config, auth, params):
        return {"echo": params.message}

    srv = _make_server(server_config, package="core")
    # core maps to None (all tools) under the mechanics-test fixture.
    tools = srv._list_tools_impl()
    assert len(tools) == 1
    assert tools[0].name == "echo"


def test_list_tools_returns_compacted_schema(server_config):
    @register_tool("echo", params=EchoParams, description="Echo tool")
    def echo(config, auth, params):
        return {}

    srv = _make_server(server_config)
    tools = srv._list_tools_impl()
    schema = tools[0].inputSchema
    assert "title" not in schema
    assert "description" not in schema  # top-level dropped
    assert "default" not in schema["properties"]["dry_run"]


def test_list_tools_with_specific_enabled_set(server_config, monkeypatch):
    @register_tool("a", params=EchoParams, description="A")
    def a(config, auth, params):
        return {}

    @register_tool("b", params=EchoParams, description="B")
    def b(config, auth, params):
        return {}

    # Patch the package definition to expose only "a".
    from a360_mcp import server as server_mod

    monkeypatch.setitem(server_mod._PACKAGE_DEFINITIONS, "core", {"a"})
    srv = _make_server(server_config, package="core")
    tools = srv._list_tools_impl()
    names = {t.name for t in tools}
    assert names == {"a"}


# --------------------------------------------------------------------------- #
# call_tool pipeline
# --------------------------------------------------------------------------- #


def test_call_tool_happy_path(server_config):
    @register_tool("echo", params=EchoParams, description="Echo")
    def echo(config, auth, params):
        return {"echoed": params.message}

    srv = _make_server(server_config)
    result = srv._call_tool_impl("echo", {"message": "hi"})
    assert len(result) == 1
    assert '"echoed":"hi"' in result[0].text


def test_call_tool_unknown_tool_returns_structured_error(server_config):
    srv = _make_server(server_config)
    result = srv._call_tool_impl("nope", {})
    assert len(result) == 1
    import orjson

    payload = orjson.loads(result[0].text)
    assert payload["success"] is False
    assert payload["error_type"] == "unknown_tool"
    assert payload["tool"] == "nope"


def test_call_tool_validation_error_returns_structured_error(server_config):
    @register_tool("echo", params=EchoParams, description="Echo")
    def echo(config, auth, params):
        return {}

    srv = _make_server(server_config)
    # Missing required "message".
    result = srv._call_tool_impl("echo", {})
    import orjson

    payload = orjson.loads(result[0].text)
    assert payload["success"] is False
    assert payload["error_type"] == "validation_error"


def test_call_tool_tool_exception_returns_structured_error(server_config):
    @register_tool("boom", params=EchoParams, description="Boom")
    def boom(config, auth, params):
        raise RuntimeError("kaboom")

    srv = _make_server(server_config)
    result = srv._call_tool_impl("boom", {"message": "x"})
    import orjson

    payload = orjson.loads(result[0].text)
    assert payload["success"] is False
    assert payload["error_type"] == "tool_error"
    assert "kaboom" in payload["error"]


def test_call_tool_auth_error_propagates(server_config):
    """A360AuthError must NOT be swallowed into a structured dict."""

    @register_tool("authfail", params=EchoParams, description="Auth fail")
    def authfail(config, auth, params):
        raise A360AuthError("bad token")

    srv = _make_server(server_config)
    with pytest.raises(A360AuthError):
        srv._call_tool_impl("authfail", {"message": "x"})


def test_call_tool_alias_routed(server_config):
    from a360_mcp import server as server_mod

    @register_tool("real_name", params=EchoParams, description="Real")
    def real_name(config, auth, params):
        return {"ok": True}

    server_mod._DEPRECATED_TOOL_ALIASES["old_name"] = "real_name"
    try:
        srv = _make_server(server_config)
        result = srv._call_tool_impl("old_name", {"message": "x"})
        import orjson

        payload = orjson.loads(result[0].text)
        assert payload == {"ok": True}
    finally:
        server_mod._DEPRECATED_TOOL_ALIASES.pop("old_name", None)


def test_call_tool_mutating_blocked_when_readonly(server_config):
    @register_tool("deploy_thing", params=WriteParams, description="Deploy")
    def deploy_thing(config, auth, params):
        return {"deployed": True}

    srv = _make_server(server_config)
    # server_config.allow_writes defaults to False.
    result = srv._call_tool_impl("deploy_thing", {"target": "x"})
    import orjson

    payload = orjson.loads(result[0].text)
    assert payload["success"] is False
    assert payload["error_type"] == "write_blocked"


def test_call_tool_mutating_requires_confirm(server_config):
    """A mutating (non-publish-grade) tool requires approve=true."""
    server_config.allow_writes = True

    @register_tool("rename_thing", params=WriteParams, description="Rename")
    def rename_thing(config, auth, params):
        return {"renamed": True}

    srv = _make_server(server_config)
    # Without confirm.
    result = srv._call_tool_impl("rename_thing", {"target": "x"})
    import orjson

    payload = orjson.loads(result[0].text)
    assert payload["success"] is False
    assert payload["error_type"] == "confirmation_required"
    assert CONFIRM_FIELD in payload["error"]


def test_call_tool_mutating_succeeds_with_confirm(server_config):
    """A mutating (non-publish-grade) tool succeeds once approve=true is set."""
    server_config.allow_writes = True

    @register_tool("rename_thing", params=WriteParams, description="Rename")
    def rename_thing(config, auth, params):
        return {"renamed": params.target}

    srv = _make_server(server_config)
    result = srv._call_tool_impl("rename_thing", {"target": "x", "approve": True})
    import orjson

    payload = orjson.loads(result[0].text)
    assert payload == {"renamed": "x"}


def test_call_tool_publish_grade_requires_environment_confirm(server_config):
    """Publish-grade tools (deploy_/publish_) need confirm_environment='prod'."""
    server_config.allow_writes = True

    @register_tool("deploy_thing", params=WriteParams, description="Deploy")
    def deploy_thing(config, auth, params):
        return {"deployed": True}

    srv = _make_server(server_config)
    # approve=true alone is not enough — G7 fires in pre-confirm (before the
    # generic confirm gate) and demands confirm_environment='prod' too.
    result = srv._call_tool_impl("deploy_thing", {"target": "x", "approve": True})
    import orjson

    payload = orjson.loads(result[0].text)
    assert payload["success"] is False
    assert payload["error_type"] == "write_guard_blocked"
    assert "confirm_environment" in payload["error"]
    assert "approve" in payload["error"]


def test_call_tool_publish_grade_succeeds_with_both_confirms(server_config):
    """Publish-grade tool succeeds with approve=true AND confirm_environment=prod."""
    server_config.allow_writes = True

    @register_tool("deploy_thing", params=WriteParams, description="Deploy")
    def deploy_thing(config, auth, params):
        return {"deployed": params.target}

    srv = _make_server(server_config)
    result = srv._call_tool_impl(
        "deploy_thing",
        {"target": "x", "approve": True, "confirm_environment": "prod"},
    )
    import orjson

    payload = orjson.loads(result[0].text)
    assert payload == {"deployed": "x"}


def test_call_tool_not_enabled_when_known_in_other_package(server_config):
    @register_tool("echo", params=EchoParams, description="Echo")
    def echo(config, auth, params):
        return {}

    from a360_mcp import server as server_mod

    # Pin a specific allow-set that excludes "echo".
    server_mod._PACKAGE_DEFINITIONS["core"] = {"something_else"}
    srv = _make_server(server_config, package="core")
    try:
        result = srv._call_tool_impl("echo", {"message": "x"})
        import orjson

        payload = orjson.loads(result[0].text)
        assert payload["success"] is False
        assert payload["error_type"] == "not_enabled"
    finally:
        server_mod._PACKAGE_DEFINITIONS["core"] = set()


# --------------------------------------------------------------------------- #
# Full suite regression check
# --------------------------------------------------------------------------- #


def test_server_construction_does_not_block_on_network(server_config):
    """Building the server must not trigger auth or HTTP."""
    srv = _make_server(server_config)
    # The mock auth_manager's make_request must never have been called.
    srv.auth_manager.make_request.assert_not_called()


class TestAuthManagerOwner:
    """Each server process must own its own ledger slot.

    ``AuthManager`` defaults ``owner`` to ``a360-mcp:<pid>`` precisely so two
    processes on one account are counted as two sessions. Overriding that with
    a constant collapses them into one slot: the second process renews the
    first's slot (keeping the first's pid), and whichever exits first either
    logs the other out or leaves a dead pid that reads as a free slot.
    """

    def test_default_owner_is_pid_tagged(self, server_config):
        server = A360MCPServer(server_config)
        assert server.auth_manager._owner == f"a360-mcp:{os.getpid()}"

    def test_two_servers_do_not_share_a_ledger_slot(self, server_config):
        server = A360MCPServer(server_config)
        other = AuthManager(server_config)  # a stand-in for a sibling process
        assert server.auth_manager._owner == other._owner  # same pid, same label
        # ...but a different pid must produce a different label.
        assert server.auth_manager._owner != "a360-mcp"

    def test_explicit_auth_manager_is_still_honoured(self, server_config):
        injected = MagicMock(spec=AuthManager)
        server = A360MCPServer(server_config, auth_manager=injected)
        assert server.auth_manager is injected
