from typing import Annotated
from unittest.mock import MagicMock

import pytest
from arcade_core.errors import ErrorKind, ToolDefinitionError
from arcade_core.schema import (
    ToolCallError,
    ToolCallOutput,
    ToolCallRequest,
    ToolCallResponse,
    ToolContext,
    ToolReference,
)
from arcade_serve.core import base as base_module
from arcade_serve.core import components as components_module
from arcade_serve.core.base import BaseWorker
from arcade_serve.core.common import RequestData, Router
from arcade_serve.core.components import (
    CallToolComponent,
    CatalogComponent,
    HealthCheckComponent,
)
from arcade_tdk import tool


@tool()
def sample_tool(
    context: ToolContext, a: Annotated[int, "a"], b: Annotated[int, "b"]
) -> Annotated[int, "output"]:
    """Sample tool for testing."""
    return a + b


# Define error tool at module level to avoid indentation issues with getsource
@tool()
def error_tool(context: ToolContext) -> int:
    """This tool always raises an error."""
    raise ValueError("Something went wrong")


class FakeSpan:
    def __init__(self, name: str):
        self.name = name
        self.attributes: dict[str, object] = {}

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        return False

    def set_attribute(self, key: str, value: object) -> None:
        self.attributes[key] = value


class FakeTracer:
    def __init__(self):
        self.spans: list[FakeSpan] = []

    def start_as_current_span(self, name: str) -> FakeSpan:
        span = FakeSpan(name)
        self.spans.append(span)
        return span


@pytest.fixture
def mock_router():
    router = MagicMock(spec=Router)
    router.add_route = MagicMock()
    return router


@pytest.fixture
def base_worker(mock_router, monkeypatch):
    # Set env var temporarily for testing secret loading
    monkeypatch.setenv("ARCADE_WORKER_SECRET", "test_secret_env")
    worker = BaseWorker()
    worker.register_routes(mock_router)  # Register routes using the mock router
    return worker


@pytest.fixture
def base_worker_no_auth():
    return BaseWorker(disable_auth=True)


@pytest.fixture
def fake_tracer():
    return FakeTracer()


# --- BaseWorker Tests ---


def test_base_worker_init_with_secret():
    worker = BaseWorker(secret="explicit_secret")  # noqa: S106
    assert worker.secret == "explicit_secret"  # noqa: S105
    assert not worker.disable_auth


def test_base_worker_init_with_env_secret(monkeypatch):
    monkeypatch.setenv("ARCADE_WORKER_SECRET", "env_secret_value")
    worker = BaseWorker()
    assert worker.secret == "env_secret_value"  # noqa: S105
    assert not worker.disable_auth


def test_base_worker_init_no_secret_raises_error(monkeypatch):
    # Ensure env var is not set
    monkeypatch.delenv("ARCADE_WORKER_SECRET", raising=False)
    with pytest.raises(ValueError, match="No secret provided for worker"):
        BaseWorker()


def test_base_worker_init_disable_auth():
    worker = BaseWorker(disable_auth=True)
    assert worker.secret == ""
    assert worker.disable_auth


def test_register_tool(base_worker_no_auth):
    assert len(base_worker_no_auth.catalog) == 0
    base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
    assert len(base_worker_no_auth.catalog) == 1
    tool_def = base_worker_no_auth.get_catalog()[0]
    assert tool_def.name == "SampleTool"
    assert tool_def.toolkit.name == "TestKit"


def test_get_catalog(base_worker_no_auth):
    base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
    catalog = base_worker_no_auth.get_catalog()
    assert isinstance(catalog, list)
    assert len(catalog) == 1
    assert catalog[0].name == "SampleTool"


def test_health_check(base_worker_no_auth):
    base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
    health = base_worker_no_auth.health_check()
    assert health == {"status": "ok", "tool_count": "1"}


@pytest.mark.asyncio
async def test_call_tool_success(base_worker_no_auth):
    base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
    # Create ToolReference WITHOUT version, as register_tool doesn't seem to set it
    tool_ref = ToolReference(toolkit="TestKit", name="SampleTool")
    tool_request = ToolCallRequest(
        execution_id="test_exec_id",
        tool=tool_ref,
        inputs={"a": 5, "b": 3},
    )

    response = await base_worker_no_auth.call_tool(tool_request)

    assert response.success is True
    assert response.output.value == 8
    assert response.output.error is None
    assert response.execution_id == "test_exec_id"


@pytest.mark.asyncio
async def test_call_tool_success_and_error_logs_use_same_tool_identifiers(
    base_worker_no_auth, caplog
):
    """Success and error log lines must use identical tool identifier strings
    so logs can be correlated with a single grep pattern."""
    import logging

    base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
    base_worker_no_auth.register_tool(error_tool, toolkit_name="test_kit")

    success_req = ToolCallRequest(
        execution_id="exec_consistency_ok",
        tool=ToolReference(toolkit="TestKit", name="SampleTool"),
        inputs={"a": 1, "b": 2},
    )
    error_req = ToolCallRequest(
        execution_id="exec_consistency_err",
        tool=ToolReference(toolkit="TestKit", name="ErrorTool"),
        inputs={},
    )

    with caplog.at_level(logging.DEBUG, logger="arcade_serve.core.base"):
        await base_worker_no_auth.call_tool(success_req)
        await base_worker_no_auth.call_tool(error_req)

    success_line = next(
        r
        for r in caplog.records
        if "exec_consistency_ok" in r.getMessage() and "success" in r.getMessage()
    )
    error_line = next(
        r
        for r in caplog.records
        if "exec_consistency_err" in r.getMessage() and "failed:" in r.getMessage()
    )
    # Both must use the bare tool name (".name"), NOT the full ``Toolkit.Tool`` fqname.
    assert "Tool SampleTool " in success_line.getMessage()
    assert "Tool ErrorTool " in error_line.getMessage()
    # Neither line should contain the full-fqname form ``TestKit.SampleTool``.
    assert "TestKit.SampleTool" not in success_line.getMessage()
    assert "TestKit.ErrorTool" not in error_line.getMessage()
    # Both must use the same "version <X>" word — proves the same source
    # (``tool_fqname.toolkit_version``) is read on both paths.
    assert "version " in success_line.getMessage()
    assert "version " in error_line.getMessage()


@pytest.mark.asyncio
async def test_call_tool_execution_error(base_worker_no_auth):
    # Tool is now defined at module level
    try:
        base_worker_no_auth.register_tool(error_tool, toolkit_name="error_kit")
    except ToolDefinitionError as e:
        pytest.fail(f"Failed to register error_tool: {e}")

    # Create ToolReference WITHOUT version
    tool_ref = ToolReference(toolkit="ErrorKit", name="ErrorTool")
    tool_request = ToolCallRequest(
        execution_id="test_exec_error",
        tool=tool_ref,
        inputs={},
    )

    response = await base_worker_no_auth.call_tool(tool_request)

    assert response.success is False
    assert response.output.value is None
    assert response.output.error is not None


@pytest.mark.asyncio
async def test_call_tool_error_records_run_tool_span_attributes(
    base_worker_no_auth, fake_tracer, monkeypatch
):
    monkeypatch.setattr(base_module.trace, "get_tracer", lambda name: fake_tracer)
    base_worker_no_auth.register_tool(error_tool, toolkit_name="error_kit")
    tool_request = ToolCallRequest(
        execution_id="exec_span_attrs",
        tool=ToolReference(toolkit="ErrorKit", name="ErrorTool"),
        inputs={},
    )

    response = await base_worker_no_auth.call_tool(tool_request)

    assert response.success is False
    run_tool_span = next(span for span in fake_tracer.spans if span.name == "RunTool")
    assert run_tool_span.attributes["tool_error_kind"] == "TOOL_RUNTIME_FATAL"
    assert run_tool_span.attributes["tool_error_message"].startswith(
        "[TOOL_RUNTIME_FATAL] FatalToolError"
    )
    assert "ValueError" in run_tool_span.attributes["tool_error_developer_message"]
    assert "Something went wrong" in run_tool_span.attributes["tool_error_developer_message"]


@pytest.mark.asyncio
async def test_call_tool_error_log_text_matches_structured_extras(base_worker_no_auth, caplog):
    """The primary failure warning's f-string must use the same resolved
    ``tool_fqname.name`` / ``tool_fqname.toolkit_version`` values that
    ``log_extra`` exposes — otherwise the human-readable text and the
    Datadog facets disagree on which tool/version produced the error.
    Previously the f-string used ``tool_request.tool.version`` (the *requested*
    version, often ``None``) while the extras used the resolved version."""
    base_worker_no_auth.register_tool(error_tool, toolkit_name="error_kit")
    tool_request = ToolCallRequest(
        execution_id="exec_log_check",
        tool=ToolReference(toolkit="ErrorKit", name="ErrorTool"),
        inputs={},
    )

    with caplog.at_level("WARNING", logger="arcade_serve.core.base"):
        await base_worker_no_auth.call_tool(tool_request)

    primary = next(
        r
        for r in caplog.records
        if "exec_log_check" in r.getMessage() and "failed:" in r.getMessage()
    )
    # Text and structured extra must agree on name + version.
    assert "Tool ErrorTool " in primary.getMessage()
    assert getattr(primary, "tool_name", None) == "ErrorTool"
    extra_version = getattr(primary, "toolkit_version", None)
    assert f"version {extra_version}" in primary.getMessage()


@pytest.mark.asyncio
async def test_call_tool_error_secondary_log_carries_full_exception_content(
    base_worker_no_auth, caplog
):
    """Under the strict data-leak policy, the @tool fallback puts the verbose
    ``str(exception)`` content into ``developer_message`` (server-side only,
    never returned to the MCP client). The secondary ``"Developer message: ..."``
    warning must therefore fire and carry that full content so on-call
    engineers retain debugging context — the channel where leakage WOULD
    matter (agent-facing ``message``) is covered by the dedicated leak tests
    in ``libs/tests/tool/test_error_fallback.py``."""
    base_worker_no_auth.register_tool(error_tool, toolkit_name="error_kit")
    tool_request = ToolCallRequest(
        execution_id="exec_dev_msg",
        tool=ToolReference(toolkit="ErrorKit", name="ErrorTool"),
        inputs={},
    )

    with caplog.at_level("WARNING", logger="arcade_serve.core.base"):
        await base_worker_no_auth.call_tool(tool_request)

    secondary = [
        r
        for r in caplog.records
        if "exec_dev_msg" in r.getMessage() and "Developer message:" in r.getMessage()
    ]
    assert len(secondary) == 1, "secondary 'Developer message:' log should fire once"
    # The full exception content is in the secondary log (and in Datadog facets).
    assert "ValueError" in secondary[0].getMessage()
    assert "Something went wrong" in secondary[0].getMessage()


@pytest.mark.asyncio
async def test_call_tool_not_found(base_worker_no_auth):
    # Use ToolReference without version for lookup consistency
    tool_ref = ToolReference(toolkit="nonexistent", name="nosuchtool")
    tool_request = ToolCallRequest(
        execution_id="test_exec_notfound",
        tool=tool_ref,
        inputs={},
    )

    # Update regex to match actual error format
    with pytest.raises(ValueError):
        await base_worker_no_auth.call_tool(tool_request)


# --- Component Tests (tested via BaseWorker registration) ---


def test_register_routes_registers_default_components(base_worker, mock_router):
    # BaseWorker calls register_routes in its init via the fixture
    assert mock_router.add_route.call_count == len(BaseWorker.default_components)

    calls = mock_router.add_route.call_args_list
    expected_paths = ["tools", "tools/invoke", "health"]
    registered_paths = [
        call[0][0] for call in calls
    ]  # call[0] are positional args, call[0][0] is endpoint_path

    assert sorted(registered_paths) == sorted(expected_paths)

    # Check if components were instantiated and passed to add_route
    assert any(isinstance(call[0][1], CatalogComponent) for call in calls)
    assert any(isinstance(call[0][1], CallToolComponent) for call in calls)
    assert any(isinstance(call[0][1], HealthCheckComponent) for call in calls)


@pytest.mark.asyncio
async def test_catalog_component_call(base_worker_no_auth):
    base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
    component = CatalogComponent(base_worker_no_auth)
    # Mock request data - not actually used by this component's __call__
    mock_request = MagicMock(spec=RequestData)
    catalog_response = await component(mock_request)

    assert isinstance(catalog_response, list)
    assert len(catalog_response) == 1
    assert catalog_response[0].name == "SampleTool"


@pytest.mark.asyncio
async def test_call_tool_component_call(base_worker_no_auth):
    base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
    component = CallToolComponent(base_worker_no_auth)

    # Create ToolReference WITHOUT version
    tool_ref = ToolReference(toolkit="TestKit", name="SampleTool")
    request_body = {
        "execution_id": "comp_test_exec",
        "tool": tool_ref.model_dump(),
        "inputs": {"a": 10, "b": 5},
    }
    mock_request = MagicMock(spec=RequestData)
    mock_request.body_json = request_body

    response = await component(mock_request)

    assert isinstance(response, ToolCallResponse)
    assert response.success is True
    assert response.output.value == 15
    assert response.execution_id == "comp_test_exec"


@pytest.mark.asyncio
async def test_call_tool_component_allows_missing_output():
    class OutputlessWorker:
        async def call_tool(self, call_tool_request):
            return ToolCallResponse(
                execution_id="comp_outputless_exec",
                duration=1,
                finished_at="2026-01-01T00:00:00",
                success=False,
                output=None,
            )

    component = CallToolComponent(OutputlessWorker())
    mock_request = MagicMock(spec=RequestData)
    mock_request.body_json = {
        "execution_id": "comp_outputless_exec",
        "tool": ToolReference(toolkit="TestKit", name="SampleTool").model_dump(),
        "inputs": {},
    }

    response = await component(mock_request)

    assert response.success is False
    assert response.output is None


@pytest.mark.asyncio
async def test_call_tool_component_error_records_call_tool_span_attributes(
    fake_tracer, monkeypatch
):
    monkeypatch.setattr(components_module.trace, "get_tracer", lambda name: fake_tracer)

    class ErrorWorker:
        environment = "test"

        async def call_tool(self, call_tool_request):
            return ToolCallResponse(
                execution_id="comp_error_exec",
                duration=1,
                finished_at="2026-01-01T00:00:00",
                success=False,
                output=ToolCallOutput(
                    error=ToolCallError(
                        kind=ErrorKind.TOOL_RUNTIME_FATAL,
                        message="public component failure",
                        developer_message="component developer details",
                    ),
                ),
            )

    component = CallToolComponent(ErrorWorker())
    mock_request = MagicMock(spec=RequestData)
    mock_request.body_json = {
        "execution_id": "comp_error_exec",
        "tool": ToolReference(toolkit="TestKit", name="SampleTool").model_dump(),
        "inputs": {},
    }

    response = await component(mock_request)

    assert response.success is False
    call_tool_span = next(span for span in fake_tracer.spans if span.name == "CallTool")
    assert call_tool_span.attributes["tool_error_kind"] == "TOOL_RUNTIME_FATAL"
    assert call_tool_span.attributes["tool_error_message"] == "public component failure"
    assert (
        call_tool_span.attributes["tool_error_developer_message"] == "component developer details"
    )


@pytest.mark.asyncio
async def test_health_check_component_call(base_worker_no_auth):
    component = HealthCheckComponent(base_worker_no_auth)
    mock_request = MagicMock(spec=RequestData)
    health_response = await component(mock_request)

    assert health_response == {"status": "ok", "tool_count": "0"}
