"""Custom assertion helpers for MCP server testing."""

from __future__ import annotations

import re
from typing import Any

from jsonschema import ValidationError, validate

from mcp_forge._types import ToolInfo, ToolResult


class MCPAssertionError(AssertionError):
    """Rich assertion error with MCP-specific context."""

    pass


def assert_tool_exists(tools: list[ToolInfo], name: str) -> ToolInfo:
    """Assert that a tool with the given name exists in the tool list.

    Returns the matched ToolInfo for further assertions.

    Raises:
        MCPAssertionError: If the tool is not found.
    """
    for tool in tools:
        if tool.name == name:
            return tool

    available = [t.name for t in tools]
    raise MCPAssertionError(
        f"Tool '{name}' not found.\n"
        f"Available tools: {available}"
    )


def assert_tool_count(tools: list[ToolInfo], expected: int) -> None:
    """Assert the server exposes exactly `expected` number of tools."""
    actual = len(tools)
    if actual != expected:
        names = [t.name for t in tools]
        raise MCPAssertionError(
            f"Expected {expected} tools, found {actual}.\n"
            f"Tools: {names}"
        )


def assert_tool_schema(tool: ToolInfo, expected_schema: dict[str, Any]) -> None:
    """Assert a tool's input schema matches the expected structure.

    Checks that all expected properties exist with correct types.
    Does NOT require an exact match — extra properties in the actual
    schema are allowed (forward-compatible testing).

    Args:
        tool: The ToolInfo to validate.
        expected_schema: The expected JSON Schema for the tool's input.
    """
    actual = tool.input_schema

    # Check required fields
    expected_required = set(expected_schema.get("required", []))
    actual_required = set(actual.get("required", []))
    missing_required = expected_required - actual_required
    if missing_required:
        raise MCPAssertionError(
            f"Tool '{tool.name}': missing required fields: {missing_required}\n"
            f"Expected required: {expected_required}\n"
            f"Actual required: {actual_required}"
        )

    # Check properties exist and have correct types
    expected_props = expected_schema.get("properties", {})
    actual_props = actual.get("properties", {})

    for prop_name, prop_schema in expected_props.items():
        if prop_name not in actual_props:
            raise MCPAssertionError(
                f"Tool '{tool.name}': missing property '{prop_name}'\n"
                f"Expected properties: {list(expected_props.keys())}\n"
                f"Actual properties: {list(actual_props.keys())}"
            )

        if "type" in prop_schema:
            actual_type = actual_props[prop_name].get("type")
            expected_type = prop_schema["type"]
            if actual_type != expected_type:
                raise MCPAssertionError(
                    f"Tool '{tool.name}', property '{prop_name}': "
                    f"expected type '{expected_type}', got '{actual_type}'"
                )


def assert_result_matches_schema(result: ToolResult, schema: dict[str, Any]) -> None:
    """Validate a tool result's text content against a JSON Schema.

    Parses the text content as JSON and validates it against the given schema.

    Raises:
        MCPAssertionError: If the content doesn't match the schema.
        json.JSONDecodeError: If the text content isn't valid JSON.
    """
    import json

    text = result.text
    try:
        data = json.loads(text)
    except json.JSONDecodeError as exc:
        raise MCPAssertionError(
            f"Tool result text is not valid JSON: {exc}\n"
            f"Content: {text[:200]}..."
        ) from exc

    try:
        validate(instance=data, schema=schema)
    except ValidationError as exc:
        raise MCPAssertionError(
            f"Tool result does not match schema: {exc.message}\n"
            f"Path: {list(exc.absolute_path)}\n"
            f"Schema path: {list(exc.absolute_schema_path)}"
        ) from exc


def assert_no_error(result: ToolResult) -> None:
    """Assert the tool call didn't return an error.

    Raises:
        MCPAssertionError: If result.is_error is True.
    """
    if result.is_error:
        raise MCPAssertionError(
            f"Tool call returned an error.\n"
            f"Error content: {result.text}"
        )


def assert_is_error(result: ToolResult) -> None:
    """Assert the tool call DID return an error (for negative testing)."""
    if not result.is_error:
        raise MCPAssertionError(
            f"Expected tool call to return an error, but it succeeded.\n"
            f"Content: {result.text[:200]}"
        )


def assert_text_contains(result: ToolResult, substring: str) -> None:
    """Assert the text content contains the given substring."""
    text = result.text
    if substring not in text:
        raise MCPAssertionError(
            f"Expected text to contain '{substring}'.\n"
            f"Actual text: {text[:500]}"
        )


def assert_text_not_contains(result: ToolResult, substring: str) -> None:
    """Assert the text content does NOT contain the given substring."""
    text = result.text
    if substring in text:
        raise MCPAssertionError(
            f"Expected text NOT to contain '{substring}'.\n"
            f"Actual text: {text[:500]}"
        )


def assert_text_matches(result: ToolResult, pattern: str) -> None:
    """Assert the text content matches a regex pattern."""
    text = result.text
    if not re.search(pattern, text):
        raise MCPAssertionError(
            f"Expected text to match pattern '{pattern}'.\n"
            f"Actual text: {text[:500]}"
        )


def assert_text_equals(result: ToolResult, expected: str) -> None:
    """Assert the text content exactly equals the expected string."""
    actual = result.text
    if actual != expected:
        raise MCPAssertionError(
            f"Text mismatch.\n"
            f"Expected: {expected[:200]}\n"
            f"Actual:   {actual[:200]}"
        )


def assert_content_type(result: ToolResult, expected_type: str, index: int = 0) -> None:
    """Assert that a content block at the given index has the expected type."""
    if index >= len(result.content):
        raise MCPAssertionError(
            f"Content block index {index} out of range. "
            f"Result has {len(result.content)} content blocks."
        )

    actual_type = result.content[index].type
    if actual_type != expected_type:
        raise MCPAssertionError(
            f"Content block {index}: expected type '{expected_type}', got '{actual_type}'"
        )
