"""validate.py — Validate tool schemas for correctness errors.

Reads tool definitions from JSON (any of 5 supported formats), checks them
for structural and semantic correctness issues, and produces a report.

Different from audit (token cost) and optimize (bloat suggestions) — this
module checks whether schemas are actually *correct*.
"""

import json
import re
import sys
from typing import Any, Dict, List, Optional, Tuple

from .audit import detect_format, _normalize_tool


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_VALID_JSON_SCHEMA_TYPES = {"string", "number", "integer", "boolean", "array", "object", "null"}


# ---------------------------------------------------------------------------
# Issue data structure
# ---------------------------------------------------------------------------

class Issue:
    """A single validation issue."""

    def __init__(
        self,
        tool: str,
        severity: str,
        check: str,
        message: str,
    ) -> None:
        self.tool = tool
        self.severity = severity  # "error" or "warn"
        self.check = check
        self.message = message

    def to_dict(self) -> Dict[str, Any]:
        return {
            "tool": self.tool,
            "severity": self.severity,
            "check": self.check,
            "message": self.message,
        }


# ---------------------------------------------------------------------------
# Raw tool extraction (works before full normalization)
# ---------------------------------------------------------------------------

def _extract_raw_tools(data: Any) -> List[Dict[str, Any]]:
    """Extract raw tool dicts from input data.

    Returns a list of raw tool objects without normalization.
    """
    if isinstance(data, dict):
        return [data]
    elif isinstance(data, list):
        return list(data)
    else:
        return []


def _get_tool_name(obj: Dict[str, Any], fmt: str) -> Optional[str]:
    """Get tool name from a raw tool object given its format."""
    if fmt == "openai":
        fn = obj.get("function", {})
        return fn.get("name")
    if fmt == "json_schema":
        return obj.get("title")
    return obj.get("name")


def _get_tool_description(obj: Dict[str, Any], fmt: str) -> Optional[str]:
    """Get tool description from a raw tool object given its format."""
    if fmt == "openai":
        fn = obj.get("function", {})
        return fn.get("description")
    return obj.get("description")


def _get_tool_schema(obj: Dict[str, Any], fmt: str) -> Optional[Dict[str, Any]]:
    """Get the parameters/input schema from a raw tool object given its format."""
    if fmt == "openai":
        fn = obj.get("function", {})
        return fn.get("parameters")
    if fmt == "anthropic":
        return obj.get("input_schema")
    if fmt == "mcp":
        return obj.get("inputSchema")
    if fmt == "json_schema":
        # The object itself is the schema
        return obj
    # simple
    return obj.get("parameters")


# ---------------------------------------------------------------------------
# Individual validation checks
# ---------------------------------------------------------------------------

def _check_name_present(obj: Dict[str, Any], fmt: str, index: int) -> Optional[Issue]:
    """Check 3: name_present — every tool has a name."""
    name = _get_tool_name(obj, fmt)
    if name is None or (isinstance(name, str) and not name.strip()):
        return Issue(
            tool="tool[{i}]".format(i=index),
            severity="error",
            check="name_present",
            message="tool has no name",
        )
    return None


def _check_name_valid(name: str) -> Optional[Issue]:
    """Check 4: name_valid — name is a valid identifier (alphanumeric + underscore)."""
    if not re.match(r'^[a-zA-Z0-9_]+$', name):
        return Issue(
            tool=name,
            severity="warn",
            check="name_valid",
            message="name contains invalid characters (expected alphanumeric and underscore only)",
        )
    return None


def _check_description_present(name: str, obj: Dict[str, Any], fmt: str) -> Optional[Issue]:
    """Check 5: description_present — every tool has a description."""
    desc = _get_tool_description(obj, fmt)
    if desc is None:
        return Issue(
            tool=name,
            severity="warn",
            check="description_present",
            message="tool has no description field",
        )
    return None


def _check_description_not_empty(name: str, obj: Dict[str, Any], fmt: str) -> Optional[Issue]:
    """Check 6: description_not_empty — description is not empty string."""
    desc = _get_tool_description(obj, fmt)
    if desc is not None and isinstance(desc, str) and not desc.strip():
        return Issue(
            tool=name,
            severity="warn",
            check="description_not_empty",
            message="description is empty",
        )
    return None


_MIN_DESCRIPTION_LENGTH = 20
_MIN_PARAM_DESCRIPTION_LENGTH = 10
_MAX_DESCRIPTION_LENGTH = 500
_MAX_PARAM_DESCRIPTION_LENGTH = 300


def _check_description_too_long(name: str, obj: Dict[str, Any], fmt: str) -> Optional[Issue]:
    """Check 25: tool_description_too_long — tool description over 500 characters.

    A description longer than 500 characters adds significant token overhead before
    any user message is processed. At ~4 chars per token, a 500-char description costs
    ~125 tokens. Across 20 tools, that's 2,500 tokens of description overhead alone.

    Good descriptions are informative but concise — enough to distinguish the tool and
    guide parameter use, not a full API reference. If a description needs more than
    500 characters, consider splitting the tool or moving detail to parameter descriptions.

    Only fires when a description IS present and not empty (checks 5/6 passed).
    """
    desc = _get_tool_description(obj, fmt)
    if desc is None or not isinstance(desc, str):
        return None
    stripped = desc.strip()
    if not stripped:
        return None
    if len(stripped) > _MAX_DESCRIPTION_LENGTH:
        return Issue(
            tool=name,
            severity="warn",
            check="tool_description_too_long",
            message=(
                "description is {n} characters — exceeds {max}-character limit. "
                "Long descriptions add ~{tokens} tokens of overhead per tool call."
            ).format(
                n=len(stripped),
                max=_MAX_DESCRIPTION_LENGTH,
                tokens=len(stripped) // 4,
            ),
        )
    return None


def _check_description_too_short(name: str, obj: Dict[str, Any], fmt: str) -> Optional[Issue]:
    """Check 20: tool_description_too_short — tool description under 20 characters.

    A one-phrase description like 'Run tests' or 'List pools' gives models almost no
    information about what the tool does, its parameters, or when to use it. Descriptions
    should be long enough to distinguish the tool from others with similar names.

    Only fires when a description IS present (check 5/6 passed) but is too brief.
    """
    desc = _get_tool_description(obj, fmt)
    if desc is None or not isinstance(desc, str):
        return None
    stripped = desc.strip()
    if not stripped:  # Empty is caught by check 6
        return None
    if len(stripped) < _MIN_DESCRIPTION_LENGTH:
        return Issue(
            tool=name,
            severity="warn",
            check="tool_description_too_short",
            message=(
                "description '{desc}' is only {n} characters — too brief for models to "
                "understand the tool's purpose, parameters, or behavior."
            ).format(desc=stripped, n=len(stripped)),
        )
    return None


def _check_param_description_too_short(tool_name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 21: param_description_too_short — parameter descriptions under 10 characters.

    A parameter description like 'ID', 'The value', or 'API key' gives models almost no
    information about what the parameter represents or how to populate it. Descriptions
    should be long enough to convey the parameter's purpose in context.

    Only fires when a description IS present (check 18 passed) but is too brief.
    Fires once per tool that has any such parameters.
    """
    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return []

    short = []
    for param_name, param_def in properties.items():
        if not isinstance(param_def, dict):
            continue
        desc = param_def.get("description", "")
        if not isinstance(desc, str):
            continue
        stripped = desc.strip()
        if not stripped:  # Empty/missing caught by check 18
            continue
        if len(stripped) < _MIN_PARAM_DESCRIPTION_LENGTH:
            short.append((param_name, stripped))

    if not short:
        return []

    count = len(short)
    sample = ", ".join(
        "'{param}' ('{desc}')".format(param=p, desc=d) for p, d in short[:3]
    )
    suffix = " +{n} more".format(n=count - 3) if count > 3 else ""
    return [Issue(
        tool=tool_name,
        severity="warn",
        check="param_description_too_short",
        message=(
            "{count} parameter description{s} too short: {sample}{suffix}. "
            "Descriptions under {min} characters give models almost no context."
        ).format(
            count=count,
            s="s" if count != 1 else "",
            sample=sample,
            suffix=suffix,
            min=_MIN_PARAM_DESCRIPTION_LENGTH,
        ),
    )]


def _check_param_description_too_long(tool_name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 26: param_description_too_long — parameter descriptions over 300 characters.

    A parameter description longer than 300 characters adds token overhead without
    meaningfully improving model comprehension. At ~4 chars per token, a 300-char
    description costs ~75 tokens per parameter. A tool with 5 overlong descriptions
    adds ~375 tokens before any user message.

    Good param descriptions are one sentence that explains what the parameter is,
    its expected format, and any constraints. If a param needs more than 300 chars
    to explain, the parameter design probably needs work.

    Fires once per tool that has any such parameters. Only fires when a description
    IS present (check 18 passed) and not too short (check 21 passed).
    """
    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return []

    long = []
    for param_name, param_def in properties.items():
        if not isinstance(param_def, dict):
            continue
        desc = param_def.get("description", "")
        if not isinstance(desc, str):
            continue
        stripped = desc.strip()
        if not stripped:
            continue
        if len(stripped) > _MAX_PARAM_DESCRIPTION_LENGTH:
            long.append((param_name, len(stripped)))

    if not long:
        return []

    count = len(long)
    sample = ", ".join(
        "'{param}' ({n} chars)".format(param=p, n=n) for p, n in long[:3]
    )
    suffix = " +{n} more".format(n=count - 3) if count > 3 else ""
    return [Issue(
        tool=tool_name,
        severity="warn",
        check="param_description_too_long",
        message=(
            "{count} parameter description{s} too long: {sample}{suffix}. "
            "Descriptions over {max} characters add ~{tokens} tokens of overhead each."
        ).format(
            count=count,
            s="s" if count != 1 else "",
            sample=sample,
            suffix=suffix,
            max=_MAX_PARAM_DESCRIPTION_LENGTH,
            tokens=_MAX_PARAM_DESCRIPTION_LENGTH // 4,
        ),
    )]


def _check_param_type_missing(tool_name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 22: param_type_missing — top-level parameters without a type declaration.

    When a parameter has no ``type`` field (and no ``anyOf``/``oneOf``/``allOf``/
    ``$ref`` alternative), the model must guess the expected type. A string,
    integer, boolean, or object are all equally plausible — leading to silent
    hallucination when the model picks wrong.

    Fires once per tool that has any untyped top-level parameters.
    """
    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return []

    untyped = []
    for param_name, param_def in properties.items():
        if not isinstance(param_def, dict):
            continue
        # Skip if type is explicitly declared
        if "type" in param_def:
            continue
        # Skip if schema combinator is used (anyOf / oneOf / allOf / $ref)
        if any(k in param_def for k in ("anyOf", "oneOf", "allOf", "$ref")):
            continue
        untyped.append(param_name)

    if not untyped:
        return []

    count = len(untyped)
    sample = ", ".join("'{}'".format(p) for p in untyped[:5])
    suffix = " +{n} more".format(n=count - 5) if count > 5 else ""
    return [Issue(
        tool=tool_name,
        severity="warn",
        check="param_type_missing",
        message=(
            "{count} parameter{s} missing type declarations: {sample}{suffix}. "
            "Without a type, models must guess whether the value is a string, "
            "integer, boolean, or object."
        ).format(count=count, s="s" if count != 1 else "", sample=sample, suffix=suffix),
    )]


def _check_nested_param_type_missing(tool_name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 23: nested_param_type_missing — nested object properties without a type declaration.

    Extends check 22 to cover properties inside nested objects and array item
    schemas. When a nested property has no ``type`` field (and no
    ``anyOf``/``oneOf``/``allOf``/``$ref`` alternative), models must guess the
    type from name and context alone.

    Fires once per tool that has any untyped nested properties.
    """
    untyped = []  # type: List[str]

    def _scan(properties: Dict[str, Any], path: str, depth: int = 0) -> None:
        if depth > 5 or not isinstance(properties, dict):
            return
        for prop_name, prop_def in properties.items():
            if not isinstance(prop_def, dict):
                continue
            full_path = "{}.{}".format(path, prop_name) if path else prop_name
            if "type" not in prop_def and not any(
                k in prop_def for k in ("anyOf", "oneOf", "allOf", "$ref")
            ):
                untyped.append(full_path)
            # Recurse into nested object properties
            nested = prop_def.get("properties", {})
            if nested and isinstance(nested, dict):
                _scan(nested, full_path, depth + 1)
            # Recurse into array item properties
            items = prop_def.get("items", {})
            if isinstance(items, dict):
                item_props = items.get("properties", {})
                if item_props and isinstance(item_props, dict):
                    _scan(item_props, "{}[]".format(full_path), depth + 1)

    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return []

    for param_name, param_def in properties.items():
        if not isinstance(param_def, dict):
            continue
        # Only recurse into nested objects and array items — top-level handled by check 22
        nested = param_def.get("properties", {})
        if nested and isinstance(nested, dict):
            _scan(nested, param_name, 0)
        items = param_def.get("items", {})
        if isinstance(items, dict):
            item_props = items.get("properties", {})
            if item_props and isinstance(item_props, dict):
                _scan(item_props, "{}[]".format(param_name), 0)

    if not untyped:
        return []

    count = len(untyped)
    sample = ", ".join("'{}'".format(p) for p in untyped[:5])
    suffix = " +{n} more".format(n=count - 5) if count > 5 else ""
    return [Issue(
        tool=tool_name,
        severity="warn",
        check="nested_param_type_missing",
        message=(
            "{count} nested propert{y} missing type declarations: {sample}{suffix}. "
            "Without a type, models must guess whether nested values are strings, "
            "integers, booleans, or objects."
        ).format(count=count, y="ies" if count != 1 else "y", sample=sample, suffix=suffix),
    )]


def _check_array_items_type_missing(tool_name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 24: array_items_type_missing — array parameters with 'items' but no type in the items schema.

    Check 17 catches arrays with no 'items' at all. This check catches arrays that
    *have* an items schema but that items schema declares no ``type`` (and no
    ``anyOf``/``oneOf``/``allOf``/``$ref`` alternative). Without a type in the items
    schema, models cannot determine what kind of values belong in the array.

    Fires once per tool that has any untyped array items schemas.
    """
    untyped = []  # type: List[str]

    def _scan_props(properties: Dict[str, Any], path: str, depth: int = 0) -> None:
        if depth > 5 or not isinstance(properties, dict):
            return
        for param_name, param_def in properties.items():
            if not isinstance(param_def, dict):
                continue
            full_path = "{}.{}".format(path, param_name) if path else param_name
            ptype = param_def.get("type", "")
            types = ptype if isinstance(ptype, list) else [ptype]
            if "array" in types and "items" in param_def:
                items = param_def["items"]
                if isinstance(items, dict) and not any(
                    k in items for k in ("type", "anyOf", "oneOf", "allOf", "$ref")
                ):
                    untyped.append(full_path)
            # Recurse into nested object properties
            nested = param_def.get("properties", {})
            if nested and isinstance(nested, dict):
                _scan_props(nested, full_path, depth + 1)
            # Recurse into array item object properties
            items = param_def.get("items", {})
            if isinstance(items, dict):
                item_props = items.get("properties", {})
                if item_props and isinstance(item_props, dict):
                    _scan_props(item_props, "{}[]".format(full_path), depth + 1)

    properties = schema.get("properties", {})
    if isinstance(properties, dict):
        _scan_props(properties, "", 0)

    if not untyped:
        return []

    count = len(untyped)
    sample = ", ".join("'{}'".format(p) for p in untyped[:5])
    suffix = " +{n} more".format(n=count - 5) if count > 5 else ""
    return [Issue(
        tool=tool_name,
        severity="warn",
        check="array_items_type_missing",
        message=(
            "{count} array parameter{s} have an 'items' schema without a type: "
            "{sample}{suffix}. "
            "Without a type in the items schema, models cannot determine what "
            "kind of values belong in the array."
        ).format(count=count, s="s" if count != 1 else "", sample=sample, suffix=suffix),
    )]


def _check_name_snake_case(name: str) -> Optional[Issue]:
    """Check 14: name_snake_case — tool name uses snake_case, not camelCase or PascalCase."""
    # Valid snake_case: lowercase letters, digits, underscores only
    if re.match(r'^[a-z][a-z0-9_]*$', name):
        return None
    # camelCase or PascalCase detected (contains uppercase)
    if re.search(r'[A-Z]', name):
        # Convert camelCase/PascalCase to snake_case for suggestion
        snake = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', name)
        snake = re.sub(r'([a-z\d])([A-Z])', r'\1_\2', snake).lower()
        return Issue(
            tool=name,
            severity="warn",
            check="name_snake_case",
            message="name uses camelCase or PascalCase; prefer snake_case (e.g., '{snake}')".format(
                snake=snake,
            ),
        )
    return None


def _check_param_snake_case(tool_name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 15: param_snake_case — parameter names use snake_case, not camelCase or PascalCase."""
    issues = []
    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return issues
    for param_name in properties:
        if not isinstance(param_name, str):
            continue
        if re.match(r'^[a-z][a-z0-9_]*$', param_name):
            continue
        if re.search(r'[A-Z]', param_name):
            snake = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', param_name)
            snake = re.sub(r'([a-z\d])([A-Z])', r'\1_\2', snake).lower()
            issues.append(Issue(
                tool=tool_name,
                severity="warn",
                check="param_snake_case",
                message="parameter '{param}' uses camelCase or PascalCase; prefer snake_case (e.g., '{snake}')".format(
                    param=param_name,
                    snake=snake,
                ),
            ))
    return issues


def _check_nested_param_snake_case(tool_name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 16: nested_param_snake_case — camelCase/PascalCase names in nested object schemas.

    Extends check 15 to catch camelCase parameter names inside nested objects
    and array items, where they are equally important for correct tool use.
    """
    issues = []

    def _check_properties(properties: Dict[str, Any], path: str, depth: int = 0) -> None:
        if depth > 5:
            return
        for param_name, param_schema in properties.items():
            if not isinstance(param_name, str):
                continue
            if re.search(r'[A-Z]', param_name):
                snake = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', param_name)
                snake = re.sub(r'([a-z\d])([A-Z])', r'\1_\2', snake).lower()
                issues.append(Issue(
                    tool=tool_name,
                    severity="warn",
                    check="nested_param_snake_case",
                    message=(
                        "nested parameter '{path}.{param}' uses camelCase or PascalCase; "
                        "prefer snake_case (e.g., '{snake}')"
                    ).format(path=path, param=param_name, snake=snake),
                ))
            if not isinstance(param_schema, dict):
                continue
            # Recurse into nested object properties
            nested_props = param_schema.get("properties", {})
            if nested_props and isinstance(nested_props, dict):
                _check_properties(nested_props, "{path}.{param}".format(path=path, param=param_name), depth + 1)
            # Recurse into array item properties
            items = param_schema.get("items", {})
            if isinstance(items, dict):
                item_props = items.get("properties", {})
                if item_props and isinstance(item_props, dict):
                    _check_properties(item_props, "{path}.{param}[]".format(path=path, param=param_name), depth + 1)

    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return issues

    for param_name, param_schema in properties.items():
        if not isinstance(param_schema, dict):
            continue
        # Check nested object properties (not top-level — those are covered by check 15)
        nested_props = param_schema.get("properties", {})
        if nested_props and isinstance(nested_props, dict):
            _check_properties(nested_props, param_name, 0)
        # Check array item properties
        items = param_schema.get("items", {})
        if isinstance(items, dict):
            item_props = items.get("properties", {})
            if item_props and isinstance(item_props, dict):
                _check_properties(item_props, "{param}[]".format(param=param_name), 0)

    return issues


def _check_array_items_missing(tool_name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 17: array_items_missing — array-type parameters missing an 'items' schema.

    An array parameter without an 'items' definition leaves the model guessing
    about element types, making the parameter unreliable to use correctly.
    """
    issues = []

    def _check_props(properties: Dict[str, Any], path: str, depth: int = 0) -> None:
        if depth > 5:
            return
        for param_name, param_schema in properties.items():
            if not isinstance(param_schema, dict):
                continue
            ptype = param_schema.get("type", "")
            types = ptype if isinstance(ptype, list) else [ptype]
            full_path = "{}.{}".format(path, param_name) if path else param_name
            if "array" in types and "items" not in param_schema:
                issues.append(Issue(
                    tool=tool_name,
                    severity="warn",
                    check="array_items_missing",
                    message=(
                        "array parameter '{path}' has no 'items' schema; "
                        "the model cannot determine element types"
                    ).format(path=full_path),
                ))
            # Recurse into nested objects
            nested = param_schema.get("properties", {})
            if nested and isinstance(nested, dict):
                _check_props(nested, full_path, depth + 1)
            # Recurse into array items
            items = param_schema.get("items", {})
            if isinstance(items, dict):
                item_props = items.get("properties", {})
                if item_props and isinstance(item_props, dict):
                    _check_props(item_props, "{}[]".format(full_path), depth + 1)

    properties = schema.get("properties", {})
    if isinstance(properties, dict):
        _check_props(properties, "", 0)

    return issues


def _check_param_description_missing(tool_name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 18: param_description_missing — parameters without descriptions.

    When a parameter has no description, the model must infer its purpose from the
    name alone. For non-obvious parameters (complex objects, arrays, ambiguous names)
    this increases hallucination risk.

    Fires once per tool that has any top-level parameters with no or empty description.
    """
    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return []

    missing = []
    for param_name, param_def in properties.items():
        if not isinstance(param_def, dict):
            continue
        desc = param_def.get("description", "")
        if not str(desc).strip():
            missing.append(param_name)

    if not missing:
        return []

    count = len(missing)
    sample = ", ".join("'{}'".format(p) for p in missing[:5])
    suffix = " +{n} more".format(n=count - 5) if count > 5 else ""
    return [Issue(
        tool=tool_name,
        severity="warn",
        check="param_description_missing",
        message=(
            "{count} parameter{s} missing descriptions: {sample}{suffix}. "
            "Models must infer purpose from parameter name alone."
        ).format(count=count, s="s" if count != 1 else "", sample=sample, suffix=suffix),
    )]


def _check_nested_param_description_missing(tool_name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 19: nested_param_description_missing — nested object properties without descriptions.

    Extends check 18 to cover nested schemas. When properties inside nested
    objects have no description, models must infer their purpose from field names
    alone — especially problematic for deeply nested request bodies.

    Fires once per tool that has any nested properties with no or empty description.
    """
    missing = []  # type: List[str]

    def _scan(properties: Dict[str, Any], path: str, depth: int = 0) -> None:
        if depth > 5 or not isinstance(properties, dict):
            return
        for prop_name, prop_schema in properties.items():
            if not isinstance(prop_schema, dict):
                continue
            full_path = "{}.{}".format(path, prop_name) if path else prop_name
            desc = prop_schema.get("description", "")
            if not str(desc).strip():
                missing.append(full_path)
            # Recurse into nested object properties
            nested = prop_schema.get("properties", {})
            if nested and isinstance(nested, dict):
                _scan(nested, full_path, depth + 1)
            # Recurse into array item properties
            items = prop_schema.get("items", {})
            if isinstance(items, dict):
                item_props = items.get("properties", {})
                if item_props and isinstance(item_props, dict):
                    _scan(item_props, "{}[]".format(full_path), depth + 1)

    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return []

    for param_name, param_def in properties.items():
        if not isinstance(param_def, dict):
            continue
        # Only recurse into nested objects and array items — top-level handled by check 18
        nested = param_def.get("properties", {})
        if nested and isinstance(nested, dict):
            _scan(nested, param_name, 0)
        items = param_def.get("items", {})
        if isinstance(items, dict):
            item_props = items.get("properties", {})
            if item_props and isinstance(item_props, dict):
                _scan(item_props, "{}[]".format(param_name), 0)

    if not missing:
        return []

    count = len(missing)
    sample = ", ".join("'{}'".format(p) for p in missing[:5])
    suffix = " +{n} more".format(n=count - 5) if count > 5 else ""
    return [Issue(
        tool=tool_name,
        severity="warn",
        check="nested_param_description_missing",
        message=(
            "{count} nested propert{y} missing descriptions: {sample}{suffix}. "
            "Models cannot infer nested field purpose from name alone."
        ).format(count=count, y="ies" if count != 1 else "y", sample=sample, suffix=suffix),
    )]


def _check_no_duplicate_names(names: List[str]) -> List[Issue]:
    """Check 7: no_duplicate_names — no two tools share the same name."""
    seen = {}  # type: Dict[str, int]
    issues = []
    for name in names:
        if name in seen:
            seen[name] += 1
        else:
            seen[name] = 1

    for name, count in seen.items():
        if count > 1:
            issues.append(Issue(
                tool=name,
                severity="error",
                check="no_duplicate_names",
                message="duplicate tool name '{name}' appears {count} times".format(
                    name=name, count=count,
                ),
            ))
    return issues


def _check_parameters_valid_type(name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 8: parameters_valid_type — parameter type is a valid JSON Schema type."""
    issues = []
    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return issues

    for param_name, param_schema in properties.items():
        if not isinstance(param_schema, dict):
            continue
        param_type = param_schema.get("type")
        if param_type is None:
            continue
        # type can be a string or a list of strings
        if isinstance(param_type, str):
            types_to_check = [param_type]
        elif isinstance(param_type, list):
            types_to_check = param_type
        else:
            issues.append(Issue(
                tool=name,
                severity="error",
                check="parameters_valid_type",
                message="param '{param}' has invalid type value: {val}".format(
                    param=param_name, val=repr(param_type),
                ),
            ))
            continue

        for t in types_to_check:
            if t not in _VALID_JSON_SCHEMA_TYPES:
                issues.append(Issue(
                    tool=name,
                    severity="error",
                    check="parameters_valid_type",
                    message="param '{param}' has invalid type '{t}' (valid: {valid})".format(
                        param=param_name,
                        t=t,
                        valid=", ".join(sorted(_VALID_JSON_SCHEMA_TYPES)),
                    ),
                ))
    return issues


def _check_required_params_exist(name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 9: required_params_exist — items in required actually exist in properties."""
    issues = []
    required = schema.get("required", [])
    if not isinstance(required, list):
        return issues
    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        prop_keys = set()
    else:
        prop_keys = set(properties.keys())

    for req in required:
        if req not in prop_keys:
            issues.append(Issue(
                tool=name,
                severity="error",
                check="required_params_exist",
                message="required param '{param}' not found in properties".format(
                    param=req,
                ),
            ))
    return issues


def _check_required_missing(name: str, schema: Dict[str, Any]) -> Optional[Issue]:
    """Check 27: required_missing — tool has parameters but no 'required' field.

    When a tool has ``properties`` but no ``required`` array, all parameters are
    implicitly optional in JSON Schema. This is technically valid, but it means
    the model cannot distinguish mandatory parameters from optional ones.

    A model calling a tool that requires a ``project_id`` but doesn't declare it
    as required may omit it, producing a failed API call. Explicit ``required``
    declarations improve call accuracy.

    Does not fire when:
    - There are no properties (no params → nothing to mark required)
    - ``required`` is present (even as an empty list)
    """
    properties = schema.get("properties", {})
    if not isinstance(properties, dict) or not properties:
        return None
    if "required" in schema:
        return None
    count = len(properties)
    return Issue(
        tool=name,
        severity="warn",
        check="required_missing",
        message=(
            "tool has {count} parameter{s} but no 'required' field — "
            "models cannot distinguish mandatory from optional parameters."
        ).format(count=count, s="s" if count != 1 else ""),
    )


def _check_enum_is_array(name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 10: enum_is_array — enum values are arrays, not scalars."""
    issues = []
    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return issues

    for param_name, param_schema in properties.items():
        if not isinstance(param_schema, dict):
            continue
        enum_val = param_schema.get("enum")
        if enum_val is not None and not isinstance(enum_val, list):
            issues.append(Issue(
                tool=name,
                severity="error",
                check="enum_is_array",
                message="param '{param}' enum is {t}, expected array".format(
                    param=param_name, t=type(enum_val).__name__,
                ),
            ))
    return issues


def _check_properties_is_object(name: str, schema: Dict[str, Any]) -> Optional[Issue]:
    """Check 11: properties_is_object — properties is a dict, not array or string."""
    properties = schema.get("properties")
    if properties is not None and not isinstance(properties, dict):
        return Issue(
            tool=name,
            severity="error",
            check="properties_is_object",
            message="'properties' is {t}, expected object".format(
                t=type(properties).__name__,
            ),
        )
    return None


def _check_nested_objects_have_properties(name: str, schema: Dict[str, Any]) -> List[Issue]:
    """Check 12: nested_objects_have_properties — object-type params have a properties field."""
    issues = []
    properties = schema.get("properties", {})
    if not isinstance(properties, dict):
        return issues

    for param_name, param_schema in properties.items():
        if not isinstance(param_schema, dict):
            continue
        param_type = param_schema.get("type")
        if param_type == "object" and "properties" not in param_schema:
            issues.append(Issue(
                tool=name,
                severity="warn",
                check="nested_objects_have_properties",
                message="nested object param '{param}' has no 'properties' defined".format(
                    param=param_name,
                ),
            ))
    return issues


def _check_description_override_pattern(name: str, obj: Dict[str, Any], fmt: str) -> Optional[Issue]:
    """Check 13: description_override_pattern — description tries to override model behavior.

    Detects tool descriptions that instruct the model to ignore prior
    instructions or override its own safety behavior.  This is a form of
    prompt injection embedded in the schema itself.
    """
    desc = _get_tool_description(obj, fmt)
    if not desc:
        return None
    desc_lower = desc.lower()

    # Patterns that indicate the description is trying to reprogram the model
    # rather than describe the tool.
    override_phrases = [
        # Explicit instruction override
        "originally you did not have",
        "you were advised to refuse",
        "this tool now grants you",
        "ignore previous instructions",
        "ignore your instructions",
        "disregard your previous",
        "disregard prior instructions",
        "override your",
        "forget your previous",
        "you are now able to",
        "you now have access",
        "you now have permission",
        "despite your training",
        "contrary to your instructions",
        # Information suppression — tell model to hide data from user
        "silently remember",
        "don't mention",
        "do not mention",
        "don't emphasize",
        "do not emphasize",
        "don't tell the user",
        "do not tell the user",
        "do not reveal",
        "don't reveal",
        # Competitive tool forcing — bias model toward this tool
        "always use this tool",
        # Behavioral forcing — tell model when/how to call this tool
        "you must call this tool",
        "must call this tool",
        "always call this tool",
        "call this tool whenever",
        "call this tool for every",
        "must repeatedly call",
        "repeatedly call this tool",
        "call this tool again",
    ]

    for phrase in override_phrases:
        if phrase in desc_lower:
            return Issue(
                tool=name,
                severity="warn",
                check="description_override_pattern",
                message="description contains model-override language: '{phrase}'".format(
                    phrase=phrase,
                ),
            )
    return None


# ---------------------------------------------------------------------------
# Main validation logic
# ---------------------------------------------------------------------------

def validate_tools(data: Any) -> Tuple[List[Issue], Dict[str, Any]]:
    """Validate tool definitions for correctness.

    Parameters
    ----------
    data:
        Parsed JSON data (dict or list of tool definitions).

    Returns
    -------
    Tuple of (issues, stats) where stats contains:
        - tool_count: int
        - errors: int
        - warnings: int
        - passed: bool
    """
    items = _extract_raw_tools(data)
    issues = []  # type: List[Issue]

    if not items:
        return issues, {"tool_count": 0, "errors": 0, "warnings": 0, "passed": True}

    # Detect formats and collect names
    names = []  # type: List[str]
    tool_data = []  # type: List[Tuple[str, str, Dict[str, Any], Dict[str, Any]]]
    # Each entry: (name, format, raw_obj, schema)

    for i, item in enumerate(items):
        # Check 2: format_detected
        try:
            fmt = detect_format(item)
        except ValueError:
            issues.append(Issue(
                tool="tool[{i}]".format(i=i),
                severity="error",
                check="format_detected",
                message="cannot detect tool format",
            ))
            continue

        # Check 3: name_present
        issue = _check_name_present(item, fmt, i)
        if issue is not None:
            issues.append(issue)
            name = "tool[{i}]".format(i=i)
        else:
            name = _get_tool_name(item, fmt) or "tool[{i}]".format(i=i)

        names.append(name)

        # Get schema for further checks
        schema = _get_tool_schema(item, fmt) or {}

        tool_data.append((name, fmt, item, schema))

    # Per-tool checks (on successfully detected tools)
    for name, fmt, raw_obj, schema in tool_data:
        # Check 4: name_valid
        issue = _check_name_valid(name)
        if issue is not None:
            issues.append(issue)

        # Check 14: name_snake_case
        issue = _check_name_snake_case(name)
        if issue is not None:
            issues.append(issue)

        # Check 15: param_snake_case
        issues.extend(_check_param_snake_case(name, schema))

        # Check 5: description_present
        issue = _check_description_present(name, raw_obj, fmt)
        if issue is not None:
            issues.append(issue)

        # Check 6: description_not_empty
        issue = _check_description_not_empty(name, raw_obj, fmt)
        if issue is not None:
            issues.append(issue)

        # Check 20: tool_description_too_short
        issue = _check_description_too_short(name, raw_obj, fmt)
        if issue is not None:
            issues.append(issue)

        # Check 25: tool_description_too_long
        issue = _check_description_too_long(name, raw_obj, fmt)
        if issue is not None:
            issues.append(issue)

        # Check 8: parameters_valid_type
        issues.extend(_check_parameters_valid_type(name, schema))

        # Check 9: required_params_exist
        issues.extend(_check_required_params_exist(name, schema))

        # Check 27: required_missing
        issue = _check_required_missing(name, schema)
        if issue is not None:
            issues.append(issue)

        # Check 10: enum_is_array
        issues.extend(_check_enum_is_array(name, schema))

        # Check 11: properties_is_object
        issue = _check_properties_is_object(name, schema)
        if issue is not None:
            issues.append(issue)

        # Check 12: nested_objects_have_properties
        issues.extend(_check_nested_objects_have_properties(name, schema))

        # Check 16: nested_param_snake_case
        issues.extend(_check_nested_param_snake_case(name, schema))

        # Check 17: array_items_missing
        issues.extend(_check_array_items_missing(name, schema))

        # Check 18: param_description_missing
        issues.extend(_check_param_description_missing(name, schema))

        # Check 19: nested_param_description_missing
        issues.extend(_check_nested_param_description_missing(name, schema))

        # Check 21: param_description_too_short
        issues.extend(_check_param_description_too_short(name, schema))

        # Check 26: param_description_too_long
        issues.extend(_check_param_description_too_long(name, schema))

        # Check 22: param_type_missing
        issues.extend(_check_param_type_missing(name, schema))

        # Check 23: nested_param_type_missing
        issues.extend(_check_nested_param_type_missing(name, schema))

        # Check 24: array_items_type_missing
        issues.extend(_check_array_items_type_missing(name, schema))

        # Check 13: description_override_pattern
        issue = _check_description_override_pattern(name, raw_obj, fmt)
        if issue is not None:
            issues.append(issue)

    # Check 7: no_duplicate_names (cross-tool)
    issues.extend(_check_no_duplicate_names(names))

    # Calculate stats
    errors = sum(1 for i in issues if i.severity == "error")
    warnings = sum(1 for i in issues if i.severity == "warn")

    stats = {
        "tool_count": len(items),
        "errors": errors,
        "warnings": warnings,
        "passed": errors == 0,
    }

    return issues, stats


# ---------------------------------------------------------------------------
# Report generation
# ---------------------------------------------------------------------------

def generate_report(
    issues: List[Issue],
    stats: Dict[str, Any],
    *,
    use_color: bool = True,
) -> str:
    """Generate a formatted validation report.

    Returns the report as a string (with ANSI escapes if use_color is True).
    """
    if use_color and sys.stderr.isatty():
        BOLD = "\033[1m"
        CYAN = "\033[36m"
        GREEN = "\033[32m"
        YELLOW = "\033[33m"
        RED = "\033[31m"
        GRAY = "\033[90m"
        RESET = "\033[0m"
    else:
        BOLD = CYAN = GREEN = YELLOW = RED = GRAY = RESET = ""

    lines = []  # type: List[str]
    lines.append("")
    lines.append("{bold}agent-friend validate{reset} — schema correctness report".format(
        bold=BOLD, reset=RESET,
    ))

    tool_count = stats.get("tool_count", 0)
    errors = stats.get("errors", 0)
    warnings = stats.get("warnings", 0)
    passed = stats.get("passed", True)

    if tool_count == 0:
        lines.append("")
        lines.append("  {gray}No tools found in input.{reset}".format(
            gray=GRAY, reset=RESET,
        ))
        lines.append("")
        return "\n".join(lines)

    # Summary header
    if errors == 0 and warnings == 0:
        lines.append("")
        lines.append("  {green}{check} {count} tool{s} validated, 0 errors, 0 warnings{reset}".format(
            green=GREEN,
            check="\u2713",
            count=tool_count,
            s="s" if tool_count != 1 else "",
            reset=RESET,
        ))
    lines.append("")

    # Group issues by tool
    if issues:
        per_tool = {}  # type: Dict[str, List[Issue]]
        for issue in issues:
            if issue.tool not in per_tool:
                per_tool[issue.tool] = []
            per_tool[issue.tool].append(issue)

        for tool_name, tool_issues in per_tool.items():
            lines.append("  {cyan}{name}{reset}:".format(
                cyan=CYAN, name=tool_name, reset=RESET,
            ))
            for issue in tool_issues:
                if issue.severity == "error":
                    tag = "{red}ERROR{reset}".format(red=RED, reset=RESET)
                else:
                    tag = "{yellow}WARN{reset}".format(yellow=YELLOW, reset=RESET)
                lines.append("    {tag}: {msg}".format(tag=tag, msg=issue.message))
            lines.append("")

    # Summary footer
    status = "{red}FAIL{reset}".format(red=RED, reset=RESET) if not passed else "{green}PASS{reset}".format(green=GREEN, reset=RESET)
    lines.append("  Summary: {count} tool{s}, {errors} error{es}, {warnings} warning{ws} — {status}".format(
        count=tool_count,
        s="s" if tool_count != 1 else "",
        errors=errors,
        es="s" if errors != 1 else "",
        warnings=warnings,
        ws="s" if warnings != 1 else "",
        status=status,
    ))
    lines.append("")

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# JSON output
# ---------------------------------------------------------------------------

def generate_json_output(
    issues: List[Issue],
    stats: Dict[str, Any],
) -> str:
    """Generate machine-readable JSON output."""
    output = {
        "tool_count": stats.get("tool_count", 0),
        "errors": stats.get("errors", 0),
        "warnings": stats.get("warnings", 0),
        "passed": stats.get("passed", True),
        "issues": [i.to_dict() for i in issues],
    }
    return json.dumps(output, indent=2)


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------

def run_validate(
    file_path: Optional[str] = None,
    use_color: bool = True,
    json_output: bool = False,
    strict: bool = False,
) -> int:
    """Run the validate command. Returns exit code.

    Exit codes:
        0 = all pass
        1 = errors found
        2 = file read error

    Parameters
    ----------
    file_path:
        Path to a JSON file, or "-" for stdin, or None to read from stdin.
    use_color:
        Whether to use ANSI color codes in output.
    json_output:
        If True, output JSON instead of colored text.
    strict:
        If True, treat warnings as errors.
    """
    # Read input
    try:
        if file_path is None or file_path == "-":
            raw = sys.stdin.read()
        else:
            with open(file_path, "r") as f:
                raw = f.read()
    except FileNotFoundError:
        print("Error: file not found: {path}".format(path=file_path), file=sys.stderr)
        return 2
    except Exception as e:
        print("Error reading input: {err}".format(err=e), file=sys.stderr)
        return 2

    raw = raw.strip()
    if not raw:
        empty_stats = {"tool_count": 0, "errors": 0, "warnings": 0, "passed": True}
        if json_output:
            print(generate_json_output([], empty_stats))
        else:
            print(generate_report([], empty_stats, use_color=use_color))
        return 0

    # Check 1: valid_json
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as e:
        if json_output:
            output = {
                "tool_count": 0,
                "errors": 1,
                "warnings": 0,
                "passed": False,
                "issues": [{
                    "tool": "(input)",
                    "severity": "error",
                    "check": "valid_json",
                    "message": "invalid JSON: {err}".format(err=str(e)),
                }],
            }
            print(json.dumps(output, indent=2))
        else:
            print("Error: invalid JSON: {err}".format(err=e), file=sys.stderr)
        return 1

    # Run validation
    try:
        issues, stats = validate_tools(data)
    except Exception as e:
        print("Error: {err}".format(err=e), file=sys.stderr)
        return 2

    # Apply strict mode: promote warnings to errors
    if strict:
        for issue in issues:
            if issue.severity == "warn":
                issue.severity = "error"
        stats["errors"] = sum(1 for i in issues if i.severity == "error")
        stats["warnings"] = sum(1 for i in issues if i.severity == "warn")
        stats["passed"] = stats["errors"] == 0

    # Output
    if json_output:
        print(generate_json_output(issues, stats))
    else:
        print(generate_report(issues, stats, use_color=use_color))

    # Exit code
    if not stats["passed"]:
        return 1
    return 0
