"""MCP Protocol Validator — Validates MCP server protocol compliance.

Checks that MCP server definitions (tools, resources, prompts) follow the
MCP specification correctly. Detects missing descriptions, incomplete
inputSchemas, malformed resources, and invalid prompt templates.

Zero external dependencies — pure Python 3.10+ stdlib.
"""

from __future__ import annotations

import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------

@dataclass
class ProtocolIssue:
    """A single protocol compliance issue."""

    category: str  # tool, resource, prompt, schema
    severity: str  # ERROR, WARNING, INFO
    message: str
    element_name: str = ""
    file: str = ""
    line: int = 0
    field: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "category": self.category,
            "severity": self.severity,
            "message": self.message,
            "element_name": self.element_name,
            "file": self.file,
            "line": self.line,
            "field": self.field,
        }


@dataclass
class ProtocolValidationResult:
    """Result of MCP protocol validation."""

    issues: list[ProtocolIssue] = field(default_factory=list)
    tools_checked: int = 0
    resources_checked: int = 0
    prompts_checked: int = 0
    compliance_score: int = 100
    file: str = ""

    @property
    def exit_code(self) -> int:
        errors = sum(1 for i in self.issues if i.severity == "ERROR")
        if errors > 0:
            return 2
        warnings = sum(1 for i in self.issues if i.severity == "WARNING")
        if warnings > 0:
            return 1
        return 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "issues": [i.to_dict() for i in self.issues],
            "tools_checked": self.tools_checked,
            "resources_checked": self.resources_checked,
            "prompts_checked": self.prompts_checked,
            "compliance_score": self.compliance_score,
            "file": self.file,
            "total_issues": len(self.issues),
            "error_count": sum(1 for i in self.issues if i.severity == "ERROR"),
            "warning_count": sum(1 for i in self.issues if i.severity == "WARNING"),
            "info_count": sum(1 for i in self.issues if i.severity == "INFO"),
        }


# ---------------------------------------------------------------------------
# Tool definition validators
# ---------------------------------------------------------------------------

def _validate_tool_name(name: Any) -> list[ProtocolIssue]:
    """Validate a tool name follows MCP conventions."""
    issues: list[ProtocolIssue] = []
    if not name:
        issues.append(ProtocolIssue(
            category="tool",
            severity="ERROR",
            message="Tool is missing 'name' field",
            field="name",
        ))
        return issues
    if not isinstance(name, str):
        issues.append(ProtocolIssue(
            category="tool",
            severity="ERROR",
            message=f"Tool name must be a string, got {type(name).__name__}",
            element_name=str(name),
            field="name",
        ))
        return issues
    if len(name) == 0:
        issues.append(ProtocolIssue(
            category="tool",
            severity="ERROR",
            message="Tool name is empty",
            field="name",
        ))
    elif len(name) > 64:
        issues.append(ProtocolIssue(
            category="tool",
            severity="WARNING",
            message=f"Tool name '{name}' exceeds 64 characters ({len(name)})",
            element_name=name,
            field="name",
        ))
    if name and not re.match(r"^[a-zA-Z_][a-zA-Z0-9_\-]*$", name):
        issues.append(ProtocolIssue(
            category="tool",
            severity="WARNING",
            message=f"Tool name '{name}' contains unusual characters",
            element_name=name,
            field="name",
        ))
    return issues


def _validate_tool_description(tool: dict[str, Any], name: str) -> list[ProtocolIssue]:
    """Validate tool description."""
    issues: list[ProtocolIssue] = []
    desc = tool.get("description")
    if desc is None or desc == "":
        issues.append(ProtocolIssue(
            category="tool",
            severity="WARNING",
            message=f"Tool '{name}' has no description (bad practice)",
            element_name=name,
            field="description",
        ))
    elif not isinstance(desc, str):
        issues.append(ProtocolIssue(
            category="tool",
            severity="ERROR",
            message=f"Tool '{name}' description must be a string",
            element_name=name,
            field="description",
        ))
    elif len(desc) < 10:
        issues.append(ProtocolIssue(
            category="tool",
            severity="INFO",
            message=f"Tool '{name}' description is very short ({len(desc)} chars)",
            element_name=name,
            field="description",
        ))
    return issues


def _validate_input_schema(tool: dict[str, Any], name: str) -> list[ProtocolIssue]:
    """Validate tool inputSchema."""
    issues: list[ProtocolIssue] = []
    schema = tool.get("inputSchema")

    if schema is None:
        issues.append(ProtocolIssue(
            category="schema",
            severity="WARNING",
            message=f"Tool '{name}' has no inputSchema",
            element_name=name,
            field="inputSchema",
        ))
        return issues

    if not isinstance(schema, dict):
        issues.append(ProtocolIssue(
            category="schema",
            severity="ERROR",
            message=f"Tool '{name}' inputSchema must be an object",
            element_name=name,
            field="inputSchema",
        ))
        return issues

    # Check for type field
    schema_type = schema.get("type")
    if schema_type is None:
        issues.append(ProtocolIssue(
            category="schema",
            severity="ERROR",
            message=f"Tool '{name}' inputSchema missing 'type' field",
            element_name=name,
            field="inputSchema.type",
        ))
    elif schema_type != "object":
        issues.append(ProtocolIssue(
            category="schema",
            severity="WARNING",
            message=f"Tool '{name}' inputSchema type should be 'object', got '{schema_type}'",
            element_name=name,
            field="inputSchema.type",
        ))

    # Check for properties field
    properties = schema.get("properties")
    if properties is None and schema_type == "object":
        issues.append(ProtocolIssue(
            category="schema",
            severity="WARNING",
            message=f"Tool '{name}' inputSchema missing 'properties' field",
            element_name=name,
            field="inputSchema.properties",
        ))
    elif properties is not None:
        if not isinstance(properties, dict):
            issues.append(ProtocolIssue(
                category="schema",
                severity="ERROR",
                message=f"Tool '{name}' inputSchema.properties must be an object",
                element_name=name,
                field="inputSchema.properties",
            ))
        else:
            # Validate each property
            for prop_name, prop_def in properties.items():
                if not isinstance(prop_def, dict):
                    issues.append(ProtocolIssue(
                        category="schema",
                        severity="ERROR",
                        message=f"Tool '{name}' property '{prop_name}' must be an object",
                        element_name=name,
                        field=f"inputSchema.properties.{prop_name}",
                    ))
                    continue
                if "type" not in prop_def:
                    issues.append(ProtocolIssue(
                        category="schema",
                        severity="WARNING",
                        message=f"Tool '{name}' property '{prop_name}' missing 'type'",
                        element_name=name,
                        field=f"inputSchema.properties.{prop_name}.type",
                    ))
                if "description" not in prop_def:
                    issues.append(ProtocolIssue(
                        category="schema",
                        severity="INFO",
                        message=f"Tool '{name}' property '{prop_name}' has no description",
                        element_name=name,
                        field=f"inputSchema.properties.{prop_name}.description",
                    ))

    # Check required field validity
    required = schema.get("required")
    if required is not None:
        if not isinstance(required, list):
            issues.append(ProtocolIssue(
                category="schema",
                severity="ERROR",
                message=f"Tool '{name}' inputSchema.required must be an array",
                element_name=name,
                field="inputSchema.required",
            ))
        elif properties and isinstance(properties, dict):
            for req_field in required:
                if req_field not in properties:
                    issues.append(ProtocolIssue(
                        category="schema",
                        severity="ERROR",
                        message=(
                            f"Tool '{name}' required field '{req_field}' "
                            f"not in properties"
                        ),
                        element_name=name,
                        field=f"inputSchema.required.{req_field}",
                    ))

    return issues


def validate_tool(tool: dict[str, Any]) -> list[ProtocolIssue]:
    """Validate a single MCP tool definition.

    Args:
        tool: Tool definition dict.

    Returns:
        List of protocol issues found.
    """
    issues: list[ProtocolIssue] = []
    name = tool.get("name", "<unnamed>")

    issues.extend(_validate_tool_name(tool.get("name")))
    issues.extend(_validate_tool_description(tool, name))
    issues.extend(_validate_input_schema(tool, name))

    return issues


# ---------------------------------------------------------------------------
# Resource definition validators
# ---------------------------------------------------------------------------

def validate_resource(resource: dict[str, Any]) -> list[ProtocolIssue]:
    """Validate a single MCP resource definition.

    Args:
        resource: Resource definition dict.

    Returns:
        List of protocol issues found.
    """
    issues: list[ProtocolIssue] = []
    uri = resource.get("uri", "")
    name = resource.get("name", "<unnamed>")

    if not uri:
        issues.append(ProtocolIssue(
            category="resource",
            severity="ERROR",
            message=f"Resource '{name}' is missing 'uri' field",
            element_name=name,
            field="uri",
        ))
    elif not isinstance(uri, str):
        issues.append(ProtocolIssue(
            category="resource",
            severity="ERROR",
            message=f"Resource '{name}' uri must be a string",
            element_name=name,
            field="uri",
        ))
    elif "://" not in uri and not uri.startswith("/"):
        issues.append(ProtocolIssue(
            category="resource",
            severity="WARNING",
            message=f"Resource '{name}' URI '{uri}' looks malformed (no scheme or path)",
            element_name=name,
            field="uri",
        ))

    if not name or name == "<unnamed>":
        issues.append(ProtocolIssue(
            category="resource",
            severity="ERROR",
            message="Resource is missing 'name' field",
            field="name",
        ))

    desc = resource.get("description")
    if not desc:
        issues.append(ProtocolIssue(
            category="resource",
            severity="WARNING",
            message=f"Resource '{name}' has no description",
            element_name=name,
            field="description",
        ))

    mime_type = resource.get("mimeType")
    if mime_type is not None:
        if not isinstance(mime_type, str):
            issues.append(ProtocolIssue(
                category="resource",
                severity="ERROR",
                message=f"Resource '{name}' mimeType must be a string",
                element_name=name,
                field="mimeType",
            ))
        elif "/" not in mime_type:
            issues.append(ProtocolIssue(
                category="resource",
                severity="WARNING",
                message=f"Resource '{name}' mimeType '{mime_type}' looks invalid",
                element_name=name,
                field="mimeType",
            ))

    return issues


# ---------------------------------------------------------------------------
# Prompt template validators
# ---------------------------------------------------------------------------

_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")


def validate_prompt(prompt: dict[str, Any]) -> list[ProtocolIssue]:
    """Validate a single MCP prompt template.

    Args:
        prompt: Prompt template definition dict.

    Returns:
        List of protocol issues found.
    """
    issues: list[ProtocolIssue] = []
    name = prompt.get("name", "<unnamed>")

    if not name or name == "<unnamed>":
        issues.append(ProtocolIssue(
            category="prompt",
            severity="ERROR",
            message="Prompt template is missing 'name' field",
            field="name",
        ))

    desc = prompt.get("description")
    if not desc:
        issues.append(ProtocolIssue(
            category="prompt",
            severity="WARNING",
            message=f"Prompt '{name}' has no description",
            element_name=name,
            field="description",
        ))

    # Validate arguments
    arguments = prompt.get("arguments", [])
    if arguments and not isinstance(arguments, list):
        issues.append(ProtocolIssue(
            category="prompt",
            severity="ERROR",
            message=f"Prompt '{name}' arguments must be an array",
            element_name=name,
            field="arguments",
        ))
    elif isinstance(arguments, list):
        arg_names: set[str] = set()
        for arg in arguments:
            if not isinstance(arg, dict):
                issues.append(ProtocolIssue(
                    category="prompt",
                    severity="ERROR",
                    message=f"Prompt '{name}' argument must be an object",
                    element_name=name,
                    field="arguments",
                ))
                continue
            arg_name = arg.get("name", "")
            if not arg_name:
                issues.append(ProtocolIssue(
                    category="prompt",
                    severity="ERROR",
                    message=f"Prompt '{name}' has argument without name",
                    element_name=name,
                    field="arguments.name",
                ))
            elif arg_name in arg_names:
                issues.append(ProtocolIssue(
                    category="prompt",
                    severity="ERROR",
                    message=f"Prompt '{name}' has duplicate argument '{arg_name}'",
                    element_name=name,
                    field=f"arguments.{arg_name}",
                ))
            arg_names.add(arg_name)

            if "description" not in arg:
                issues.append(ProtocolIssue(
                    category="prompt",
                    severity="INFO",
                    message=f"Prompt '{name}' argument '{arg_name}' has no description",
                    element_name=name,
                    field=f"arguments.{arg_name}.description",
                ))

    # Validate template content placeholders
    messages = prompt.get("messages", [])
    if isinstance(messages, list):
        declared_args = set()
        if isinstance(arguments, list):
            for arg in arguments:
                if isinstance(arg, dict):
                    n = arg.get("name", "")
                    if n:
                        declared_args.add(n)

        used_placeholders: set[str] = set()
        for msg in messages:
            if isinstance(msg, dict):
                content = msg.get("content", "")
                if isinstance(content, str):
                    used_placeholders.update(_PLACEHOLDER_RE.findall(content))
                elif isinstance(content, dict):
                    text = content.get("text", "")
                    if isinstance(text, str):
                        used_placeholders.update(_PLACEHOLDER_RE.findall(text))

        # Placeholders used but not declared
        for ph in used_placeholders:
            if ph not in declared_args and declared_args:
                issues.append(ProtocolIssue(
                    category="prompt",
                    severity="WARNING",
                    message=(
                        f"Prompt '{name}' uses placeholder '{{{ph}}}' "
                        f"not declared in arguments"
                    ),
                    element_name=name,
                    field=f"messages.{ph}",
                ))

        # Declared args not used in templates
        for arg_name in declared_args:
            if arg_name not in used_placeholders and messages:
                issues.append(ProtocolIssue(
                    category="prompt",
                    severity="INFO",
                    message=(
                        f"Prompt '{name}' declares argument '{arg_name}' "
                        f"but never uses it in templates"
                    ),
                    element_name=name,
                    field=f"arguments.{arg_name}",
                ))

    return issues


# ---------------------------------------------------------------------------
# MCP definition extraction from source
# ---------------------------------------------------------------------------

_TOOL_DEF_RE = re.compile(
    r"""["']?name["']?\s*[:=]\s*["'](\w+)["']""",
)


def extract_mcp_definitions(source: str) -> dict[str, list[dict[str, Any]]]:
    """Extract MCP tool/resource/prompt definitions from source or JSON.

    Tries JSON parsing first, then falls back to pattern-based extraction.

    Args:
        source: Source code or JSON string.

    Returns:
        Dict with keys 'tools', 'resources', 'prompts'.
    """
    result: dict[str, list[dict[str, Any]]] = {
        "tools": [],
        "resources": [],
        "prompts": [],
    }

    # Try JSON first
    try:
        data = json.loads(source)
        if isinstance(data, dict):
            if "tools" in data and isinstance(data["tools"], list):
                result["tools"] = data["tools"]
            if "resources" in data and isinstance(data["resources"], list):
                result["resources"] = data["resources"]
            if "prompts" in data and isinstance(data["prompts"], list):
                result["prompts"] = data["prompts"]
            # Handle nested structures
            if "server" in data and isinstance(data["server"], dict):
                srv = data["server"]
                if "tools" in srv:
                    result["tools"] = srv["tools"]
                if "resources" in srv:
                    result["resources"] = srv["resources"]
                if "prompts" in srv:
                    result["prompts"] = srv["prompts"]
            return result
        if isinstance(data, list):
            # Heuristic: list of tools
            for item in data:
                if isinstance(item, dict) and "name" in item:
                    if "inputSchema" in item:
                        result["tools"].append(item)
                    elif "uri" in item:
                        result["resources"].append(item)
                    elif "arguments" in item or "messages" in item:
                        result["prompts"].append(item)
            return result
    except (json.JSONDecodeError, TypeError, ValueError):
        pass

    return result


# ---------------------------------------------------------------------------
# Score calculation
# ---------------------------------------------------------------------------

def _compute_compliance_score(
    issues: list[ProtocolIssue],
    total_elements: int,
) -> int:
    """Compute protocol compliance score 0-100.

    Args:
        issues: List of issues found.
        total_elements: Total number of elements checked.

    Returns:
        Compliance score (100 = fully compliant).
    """
    if total_elements == 0:
        return 100

    deductions = {"ERROR": 15, "WARNING": 5, "INFO": 1}
    total_deduction = 0
    for issue in issues:
        total_deduction += deductions.get(issue.severity, 0)

    # Scale by elements checked
    max_possible = total_elements * 20  # reasonable max
    score = max(0, 100 - int((total_deduction / max(max_possible, 1)) * 100))
    return min(100, score)


# ---------------------------------------------------------------------------
# Main validation API
# ---------------------------------------------------------------------------

def validate_protocol(source: str, filename: str = "<string>") -> ProtocolValidationResult:
    """Validate MCP protocol compliance from source/JSON.

    Args:
        source: JSON string or source code with MCP definitions.
        filename: File being validated (for reporting).

    Returns:
        ProtocolValidationResult with all issues found.
    """
    definitions = extract_mcp_definitions(source)
    all_issues: list[ProtocolIssue] = []

    tools = definitions.get("tools", [])
    resources = definitions.get("resources", [])
    prompts = definitions.get("prompts", [])

    for tool in tools:
        if isinstance(tool, dict):
            issues = validate_tool(tool)
            for i in issues:
                i.file = filename
            all_issues.extend(issues)

    for resource in resources:
        if isinstance(resource, dict):
            issues = validate_resource(resource)
            for i in issues:
                i.file = filename
            all_issues.extend(issues)

    for prompt in prompts:
        if isinstance(prompt, dict):
            issues = validate_prompt(prompt)
            for i in issues:
                i.file = filename
            all_issues.extend(issues)

    total_elements = len(tools) + len(resources) + len(prompts)
    score = _compute_compliance_score(all_issues, total_elements)

    return ProtocolValidationResult(
        issues=all_issues,
        tools_checked=len(tools),
        resources_checked=len(resources),
        prompts_checked=len(prompts),
        compliance_score=score,
        file=filename,
    )


def validate_protocol_file(path: str) -> ProtocolValidationResult:
    """Validate an MCP definition file.

    Args:
        path: Path to JSON or source file.

    Returns:
        ProtocolValidationResult.
    """
    filepath = Path(path)
    if not filepath.exists():
        return ProtocolValidationResult(
            issues=[ProtocolIssue(
                category="file",
                severity="ERROR",
                message=f"File not found: {path}",
                file=path,
            )],
            file=path,
            compliance_score=0,
        )
    try:
        source = filepath.read_text(encoding="utf-8", errors="replace")
    except OSError as exc:
        return ProtocolValidationResult(
            issues=[ProtocolIssue(
                category="file",
                severity="ERROR",
                message=f"Cannot read file: {exc}",
                file=path,
            )],
            file=path,
            compliance_score=0,
        )
    return validate_protocol(source, filename=str(filepath))


def validate_protocol_directory(dirpath: str) -> ProtocolValidationResult:
    """Validate all JSON files in a directory for MCP protocol compliance.

    Args:
        dirpath: Path to directory.

    Returns:
        Aggregated ProtocolValidationResult.
    """
    target = Path(dirpath)
    if not target.is_dir():
        return validate_protocol_file(dirpath)

    all_issues: list[ProtocolIssue] = []
    total_tools = 0
    total_resources = 0
    total_prompts = 0

    scan_extensions = {".json", ".yaml", ".yml", ".py", ".ts", ".js"}
    for filepath in sorted(target.rglob("*")):
        if filepath.is_file() and filepath.suffix in scan_extensions:
            result = validate_protocol_file(str(filepath))
            all_issues.extend(result.issues)
            total_tools += result.tools_checked
            total_resources += result.resources_checked
            total_prompts += result.prompts_checked

    total = total_tools + total_resources + total_prompts
    score = _compute_compliance_score(all_issues, total)

    return ProtocolValidationResult(
        issues=all_issues,
        tools_checked=total_tools,
        resources_checked=total_resources,
        prompts_checked=total_prompts,
        compliance_score=score,
        file=dirpath,
    )


# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------

def format_protocol_text(result: ProtocolValidationResult) -> str:
    """Format validation result as human-readable text.

    Args:
        result: Validation result.

    Returns:
        Formatted text output.
    """
    lines: list[str] = []
    lines.append("")
    lines.append("=" * 60)
    lines.append("  MCP Protocol Validator — Results")
    lines.append("=" * 60)
    lines.append(f"  Compliance Score: {result.compliance_score}/100")
    lines.append(f"  Tools: {result.tools_checked} | "
                 f"Resources: {result.resources_checked} | "
                 f"Prompts: {result.prompts_checked}")
    lines.append(f"  Issues: {len(result.issues)}")
    lines.append("=" * 60)
    lines.append("")

    if not result.issues:
        lines.append("  No protocol issues detected.")
        lines.append("")
        return "\n".join(lines)

    severity_order = {"ERROR": 0, "WARNING": 1, "INFO": 2}
    sorted_issues = sorted(
        result.issues,
        key=lambda i: severity_order.get(i.severity, 99),
    )

    for issue in sorted_issues:
        icon = {"ERROR": "[!!]", "WARNING": "[!]", "INFO": "[.]"}.get(
            issue.severity, "[?]"
        )
        lines.append(f"  {icon} [{issue.severity}] {issue.message}")
        if issue.element_name:
            lines.append(f"     Element: {issue.element_name}")
        if issue.file and issue.file != "<string>":
            lines.append(f"     File: {issue.file}")
        if issue.field:
            lines.append(f"     Field: {issue.field}")
        lines.append("")

    lines.append("=" * 60)
    return "\n".join(lines)


def format_protocol_json(result: ProtocolValidationResult) -> str:
    """Format validation result as JSON.

    Args:
        result: Validation result.

    Returns:
        JSON string.
    """
    data = {
        "protocol_validator": result.to_dict(),
    }
    return json.dumps(data, indent=2, ensure_ascii=False)
