"""Code Intelligence tools -- dependency graph, signal tracing, validation.

These tools help AI agents understand code relationships and impact.
"""

from __future__ import annotations

import re

from mcp.server.fastmcp import Context

from godotiq.license import gate_pro
from godotiq.parsers.gd_parser import GdScript
from godotiq.server import mcp
from godotiq.session import GodotIQSession
from godotiq.tools.output_limit import apply_output_limit


def _compute_impact_rating(
    is_autoload: bool,
    referenced_by_count: int,
    signal_listener_count: int,
    scene_usage_count: int,
) -> tuple[str, str]:
    """Compute impact rating and explanation."""
    score = 0
    reasons: list[str] = []

    if is_autoload:
        score += 3
        reasons.append("autoload singleton (global)")

    if referenced_by_count >= 5:
        score += 3
        reasons.append(f"referenced by {referenced_by_count} scripts")
    elif referenced_by_count >= 2:
        score += 2
        reasons.append(f"referenced by {referenced_by_count} scripts")
    elif referenced_by_count >= 1:
        score += 1
        reasons.append(f"referenced by {referenced_by_count} script")

    if signal_listener_count >= 3:
        score += 2
        reasons.append(f"{signal_listener_count} signal listeners")
    elif signal_listener_count >= 1:
        score += 1
        reasons.append(f"{signal_listener_count} signal listener(s)")

    if scene_usage_count >= 3:
        score += 1
        reasons.append(f"used in {scene_usage_count} scenes")

    if score >= 6:
        rating = "critical"
    elif score >= 4:
        rating = "high"
    elif score >= 2:
        rating = "medium"
    else:
        rating = "low"

    return rating, "; ".join(reasons) if reasons else "isolated file"


def _find_signal_listeners(
    session: GodotIQSession, gd: GdScript, res_path: str
) -> list[dict]:
    """Find listeners for signals emitted/defined by this script."""
    # Collect signal names this file defines or emits
    defined_signals = {s.name for s in gd.signals}
    emitted_signals = {e.signal_name for e in gd.signal_emissions}
    all_signals = defined_signals | emitted_signals

    result: list[dict] = []
    for sig_name in sorted(all_signals):
        listeners: list[dict] = []
        # Search all scripts for connections to this signal
        for other_path, other_gd in session._gd_scripts.items():
            if other_path == res_path:
                continue
            for conn in other_gd.signal_connections:
                # Extract signal name from source (e.g., "self.signal_name" -> "signal_name")
                conn_signal = conn.signal_source.rsplit(".", 1)[-1] if "." in conn.signal_source else conn.signal_source
                if conn_signal == sig_name:
                    listeners.append({
                        "script": other_path,
                        "connection": conn.target,
                    })
        result.append({
            "signal_name": sig_name,
            "listeners": listeners,
        })
    return result


def _find_referenced_by(
    session: GodotIQSession, gd: GdScript, res_path: str, is_autoload: bool
) -> list[str]:
    """Find scripts that reference this file."""
    referenced_by: set[str] = set()

    # 1. Autoload reverse lookup
    if is_autoload:
        idx = session._index
        for name, path in idx.autoloads.items():
            if path == res_path:
                for dep in session.get_autoload_dependents(name):
                    referenced_by.add(dep)

    # 2. Preload/load reverse lookup
    for other_path, other_gd in session._gd_scripts.items():
        if other_path == res_path:
            continue
        for ref in other_gd.preload_refs:
            if ref.path == res_path:
                referenced_by.add(other_path)

    # 3. Signal connection reverse lookup
    defined_signals = {s.name for s in gd.signals}
    for other_path, other_gd in session._gd_scripts.items():
        if other_path == res_path:
            continue
        for conn in other_gd.signal_connections:
            conn_signal = conn.signal_source.rsplit(".", 1)[-1] if "." in conn.signal_source else conn.signal_source
            if conn_signal in defined_signals:
                referenced_by.add(other_path)

    return sorted(referenced_by)


def _build_depth_trace(
    session: GodotIQSession, res_path: str
) -> dict[str, dict]:
    """Build one-level depth trace showing each dependency's own deps and dependents."""
    gd = session.get_script(res_path)
    if gd is None:
        return {}

    idx = session._index
    trace: dict[str, dict] = {}

    # Direct dependencies: autoloads and preloads
    dep_paths: set[str] = set()
    for ref in gd.autoload_refs:
        autoload_path = idx.autoloads.get(ref.autoload_name)
        if autoload_path:
            dep_paths.add(autoload_path)
    for ref in gd.preload_refs:
        if ref.path.endswith(".gd"):
            dep_paths.add(ref.path)

    for dep_path in sorted(dep_paths):
        dep_gd = session.get_script(dep_path)
        if dep_gd is None:
            continue

        # This dependency's own deps
        sub_deps: list[str] = []
        for ref in dep_gd.autoload_refs:
            sub_path = idx.autoloads.get(ref.autoload_name)
            if sub_path:
                sub_deps.append(sub_path)
        for ref in dep_gd.preload_refs:
            if ref.path.endswith(".gd"):
                sub_deps.append(ref.path)

        # This dependency's dependents
        is_dep_autoload = dep_path in idx.autoloads.values()
        dep_by = _find_referenced_by(session, dep_gd, dep_path, is_dep_autoload)

        trace[dep_path] = {
            "depends_on": sorted(set(sub_deps)),
            "depended_by": dep_by,
        }

    return trace


def _build_dependency_graph(
    session: GodotIQSession, file_path: str, depth: int = 1,
    detail: str = "normal",
) -> dict:
    """Build dependency graph for a .gd file.

    Args:
        session: Loaded GodotIQSession.
        file_path: res:// path or relative path.
        depth: How many levels deep to trace (1 = direct only).
        detail: "brief", "normal", or "full".

    Returns:
        Dictionary with dependency graph data.
    """
    res_path = session.resolve_file_path(file_path)
    if res_path is None:
        return {"error": "File not found or not a .gd script", "file": file_path}

    gd = session.get_script(res_path)
    if gd is None:
        return {"error": "File not found or not a .gd script", "file": file_path}

    idx = session._index

    # Check autoload status
    is_autoload = res_path in idx.autoloads.values()

    # Signal cross-referencing
    emits_signals = _find_signal_listeners(session, gd, res_path)

    # Listens to
    listens_to = [
        {
            "signal_source": c.signal_source,
            "target": c.target,
            "line": c.line,
        }
        for c in gd.signal_connections
    ]

    # Autoload references
    references_autoloads = [
        {
            "autoload_name": r.autoload_name,
            "access_pattern": r.pattern,
            "line": r.line,
        }
        for r in gd.autoload_refs
    ]

    # Referenced by
    referenced_by = _find_referenced_by(session, gd, res_path, is_autoload)

    # Preloads
    preloads = [r.path for r in gd.preload_refs]

    # Scene usage
    scene_usage = idx.script_to_scenes.get(res_path, [])

    # Signal listener count for impact
    total_listeners = sum(len(s["listeners"]) for s in emits_signals)

    # Impact rating
    impact_rating, impact_details = _compute_impact_rating(
        is_autoload, len(referenced_by), total_listeners, len(scene_usage)
    )

    detail = detail.lower() if detail else "normal"

    if detail == "brief":
        return {
            "file": res_path,
            "is_autoload": is_autoload,
            "emits_signals": [s["signal_name"] for s in emits_signals],
            "referenced_by": referenced_by,
            "impact_rating": impact_rating,
        }

    result: dict = {
        "file": res_path,
        "class_name": gd.class_name,
        "extends": gd.extends,
        "is_autoload": is_autoload,
        "emits_signals": emits_signals,
        "listens_to": listens_to,
        "references_autoloads": references_autoloads,
        "referenced_by": referenced_by,
        "preloads": preloads,
        "scene_usage": scene_usage,
        "impact_rating": impact_rating,
        "impact_details": impact_details,
    }

    # Depth tracing — always included at full, only with depth>1 for normal
    depth = max(1, min(depth, 3))
    if detail == "full" or depth > 1:
        result["depth_trace"] = _build_depth_trace(session, res_path)

    return apply_output_limit(result, detail, list_keys=["emits_signals", "listens_to", "referenced_by"])


@mcp.tool(
    name="godotiq_dependency_graph",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_dependency_graph(
    file: str,
    depth: int = 1,
    detail: str = "normal",
    ctx: Context = None,
) -> dict:
    """Complete dependency graph: what this file emits, who listens, what it imports, who imports it, impact rating. Call before refactoring function signatures or renaming signals. DO NOT grep the codebase — this traces the full graph in one call.

    Args:
        file: Path to .gd file (res:// or relative).
        depth: How many levels deep to trace (1 = direct deps only, 2 = deps of deps).
        detail: "brief" (signal names + refs only), "normal" (+ listeners, autoloads), "full" (+ depth_trace always).
    """
    session = ctx.request_context.lifespan_context["session"]
    if session is None:
        return {"error": "No Godot project loaded"}
    return gate_pro("godotiq_dependency_graph", _build_dependency_graph(session, file, depth, detail=detail))


# ---------------------------------------------------------------------------
# C02: Signal Map
# ---------------------------------------------------------------------------

_MAX_SIGNALS = 30
_MAX_BUSIEST = 10


def _collect_all_defined_signals(session: GodotIQSession) -> set[str]:
    """Build project-wide set of all defined signal names.

    Iterates every parsed GdScript in the session and collects
    signal names from each script's .signals list. Used by the
    find="missing" code path to avoid false positives from the
    event bus pattern.
    """
    result: set[str] = set()
    for gd in session._gd_scripts.values():
        for sig in gd.signals:
            result.add(sig.name)
    return result


def _build_signal_map(
    session: GodotIQSession,
    scope: str = "all",
    find: str = "all",
    detail: str = "normal",
) -> dict:
    """Build project-wide signal wiring map.

    Args:
        session: Loaded GodotIQSession.
        scope: "all", "autoloads", or "file:res://path.gd".
        find: "all", "orphans", "busiest", "missing".

    Returns:
        Dictionary with signal map data.
    """
    idx = session._index
    if idx is None:
        return {"error": "No project index loaded"}

    # Determine which scripts are in scope
    if scope == "autoloads":
        scope_scripts = {p: gd for p, gd in session._gd_scripts.items()
                         if p in idx.autoloads.values()}
    elif scope.startswith("file:"):
        file_path = scope[5:]
        resolved = session.resolve_file_path(file_path)
        if resolved is None:
            return {"error": f"File not found: {file_path}"}
        gd = session.get_script(resolved)
        if gd is None:
            return {"error": f"Script not found: {file_path}"}
        scope_scripts = {resolved: gd}
    else:
        scope_scripts = dict(session._gd_scripts)

    # Collect all definitions, emissions, connections across scope
    definitions: dict[str, list[str]] = {}  # signal_name -> [defining scripts]
    for path, gd in scope_scripts.items():
        for sig in gd.signals:
            definitions.setdefault(sig.name, []).append(path)

    emissions: dict[str, list[str]] = {}  # signal_name -> [emitting scripts]
    for path, gd in scope_scripts.items():
        for em in gd.signal_emissions:
            emissions.setdefault(em.signal_name, []).append(path)

    # Connections: search ALL scripts (listeners can be outside scope)
    listeners: dict[str, list[dict]] = {}  # signal_name -> [{script, handler}]
    for path, gd in session._gd_scripts.items():
        for conn in gd.signal_connections:
            sig_name = conn.signal_source.rsplit(".", 1)[-1] if "." in conn.signal_source else conn.signal_source
            listeners.setdefault(sig_name, []).append({
                "script": path,
                "handler": conn.target,
            })

    # Build unified signal list
    all_signal_names = sorted(set(definitions) | set(emissions))
    total_emissions = sum(len(v) for v in emissions.values())
    total_connections = sum(len(v) for v in listeners.values())

    # Project-wide signal definitions for the missing check (event bus pattern)
    all_defined_signals = _collect_all_defined_signals(session)

    signals_list: list[dict] = []
    orphans_list: list[dict] = []
    missing_list: list[dict] = []

    for name in all_signal_names:
        defs = definitions.get(name, [])
        emits = emissions.get(name, [])
        listens = listeners.get(name, [])
        # Deduplicate emitters
        unique_emitters = sorted(set(emits))

        entry = {
            "name": name,
            "defined_in": sorted(set(defs)),
            "emitted_by": unique_emitters,
            "listened_by": listens,
            "emission_count": len(emits),
            "listener_count": len(listens),
        }
        signals_list.append(entry)

        # Orphan: emitted but no listeners
        if len(emits) > 0 and len(listens) == 0:
            orphans_list.append({
                "name": name,
                "defined_in": sorted(set(defs))[0] if defs else "",
                "emitted_by": unique_emitters,
                "listeners": 0,
                "note": "Signal emitted but nobody listens",
            })

        # Missing: emitted but not defined anywhere in the project
        if name not in all_defined_signals:
            for emitter_path in unique_emitters:
                missing_list.append({
                    "name": name,
                    "emitted_in": emitter_path,
                    "note": "Emitted but no signal definition found in the project",
                })

    # Busiest: sort by listener_count descending
    busiest_list = sorted(signals_list, key=lambda s: s["listener_count"], reverse=True)[:_MAX_BUSIEST]
    busiest_list = [{"name": s["name"], "listeners": s["listener_count"]} for s in busiest_list]

    detail = detail.lower() if detail else "normal"

    # Build result based on find mode
    result: dict = {
        "total_signals_defined": len(definitions),
        "total_emissions": total_emissions,
        "total_connections": total_connections,
        "scope": scope,
        "find": find,
    }

    if detail == "brief":
        # Brief: only orphans and missing — the actionable items
        result["orphans"] = orphans_list
        result["missing"] = missing_list
        return result

    if find == "orphans":
        result["orphans"] = orphans_list
    elif find == "busiest":
        result["busiest"] = busiest_list
    elif find == "missing":
        result["missing"] = missing_list
    else:
        # "all"
        truncated = len(signals_list) > _MAX_SIGNALS
        result["signals"] = signals_list[:_MAX_SIGNALS]
        if truncated:
            result["truncated"] = True
        result["orphans"] = orphans_list
        result["busiest"] = busiest_list
        result["missing"] = missing_list

    return apply_output_limit(result, detail, list_keys=["signals", "orphans", "busiest", "missing"])


@mcp.tool(
    name="godotiq_signal_map",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_signal_map(
    scope: str = "all",
    find: str = "all",
    detail: str = "normal",
    ctx: Context = None,
) -> dict:
    """Project-wide signal wiring: who emits, who listens, orphan/missing signals. DO NOT grep for .connect() calls — this traces the complete signal graph in one call. Use find="orphans" to find dead signals, "missing" to find typos.

    Args:
        scope: "all" for entire project, "autoloads" for autoload scripts only,
               or "file:res://path.gd" for a single file's signals.
        find: "all" (everything), "orphans" (emitted but no listeners), "busiest" (most connected), "missing" (emitted but never defined — likely typos).
        detail: "brief" (orphans + missing only — the actionable items), "normal" (default), "full".
    """
    session = ctx.request_context.lifespan_context["session"]
    if session is None:
        return {"error": "No Godot project loaded"}
    return gate_pro("godotiq_signal_map", _build_signal_map(session, scope, find, detail=detail))


# ---------------------------------------------------------------------------
# C03: Impact Check
# ---------------------------------------------------------------------------


def _build_impact_check(
    session: GodotIQSession,
    file: str,
    action: str,
    target: str = "",
    change_description: str = "",
    detail: str = "normal",
) -> dict:
    """Predict what breaks before making a change.

    Args:
        session: Loaded GodotIQSession.
        file: res:// path to the .gd file being modified.
        action: Type of change - "modify_function", "rename_signal",
                "add_parameter", "remove_function", "change_return_type",
                "rename_class".
        target: The function/signal/class being changed.
        change_description: Free-text description of the intended change.
        detail: Output verbosity — "brief" (summary), "normal" (default), or "full" (everything).

    Returns:
        Dictionary with impact analysis data.
    """
    res_path = session.resolve_file_path(file)
    if res_path is None:
        return {"error": "File not found or not a .gd script", "file": file}

    gd = session.get_script(res_path)
    if gd is None:
        return {"error": "File not found or not a .gd script", "file": file}

    idx = session._index
    is_autoload = res_path in idx.autoloads.values()

    callers: list[dict] = []
    signal_impact: list[dict] = []
    files_to_update_set: set[str] = set()
    safe_alternative = ""

    if action in (
        "modify_function",
        "add_parameter",
        "remove_function",
        "change_return_type",
    ):
        # Find callers via autoload dependents
        if is_autoload:
            for name, path in idx.autoloads.items():
                if path == res_path:
                    dependents = session.get_autoload_dependents(name)
                    for dep_path in dependents:
                        ref_count = 0
                        if target:
                            abs_path = session._resolve_res_path(dep_path)
                            if abs_path.exists():
                                content = abs_path.read_text(encoding="utf-8")
                                ref_count = content.count(target)
                        callers.append({
                            "file": dep_path,
                            "references": ref_count,
                            "note": f"Calls {target} via autoload" if target else "Autoload dependent",
                        })
                        files_to_update_set.add(dep_path)

        # Find callers via preload refs
        for other_path, other_gd in session._gd_scripts.items():
            if other_path == res_path:
                continue
            for ref in other_gd.preload_refs:
                if ref.path == res_path and other_path not in files_to_update_set:
                    ref_count = 0
                    if target:
                        abs_path = session._resolve_res_path(other_path)
                        if abs_path.exists():
                            content = abs_path.read_text(encoding="utf-8")
                            ref_count = content.count(target)
                    callers.append({
                        "file": other_path,
                        "references": ref_count,
                        "note": f"Calls {target} via preload" if target else "Preload dependent",
                    })
                    files_to_update_set.add(other_path)

        # Build safe_alternative
        if action == "add_parameter":
            if change_description:
                safe_alternative = f"Use default value: {target}({change_description} = <default>)"
            else:
                safe_alternative = "Add default value for new parameter"
        elif action == "remove_function":
            if callers:
                caller_files = [c["file"] for c in callers]
                safe_alternative = (
                    f"Warning: {len(callers)} file(s) call {target}: "
                    + ", ".join(caller_files)
                )
            else:
                safe_alternative = f"Safe to remove: no external callers found for {target}"

    elif action == "rename_signal":
        # Find all emissions of target signal across project
        for other_path, other_gd in session._gd_scripts.items():
            for emission in other_gd.signal_emissions:
                if emission.signal_name == target:
                    signal_impact.append({
                        "file": other_path,
                        "type": "emitter",
                        "line": emission.line,
                        "note": f"Emits {target}",
                    })
                    files_to_update_set.add(other_path)

        # Find all connections to target signal
        for other_path, other_gd in session._gd_scripts.items():
            for conn in other_gd.signal_connections:
                conn_signal = (
                    conn.signal_source.rsplit(".", 1)[-1]
                    if "." in conn.signal_source
                    else conn.signal_source
                )
                if conn_signal == target:
                    signal_impact.append({
                        "file": other_path,
                        "type": "listener",
                        "line": conn.line,
                        "note": f"Connects to {target}",
                    })
                    files_to_update_set.add(other_path)

        n = len(files_to_update_set)
        file_list = ", ".join(sorted(files_to_update_set))
        if n > 0:
            safe_alternative = f"Update all {n} files: {file_list}"
        else:
            safe_alternative = f"No external references found for signal {target}"

    elif action == "rename_class":
        # Find autoload_refs referencing the class
        if is_autoload:
            for name, path in idx.autoloads.items():
                if path == res_path:
                    dependents = session.get_autoload_dependents(name)
                    for dep_path in dependents:
                        callers.append({
                            "file": dep_path,
                            "references": 0,
                            "note": f"References class via autoload {name}",
                        })
                        files_to_update_set.add(dep_path)

        # Find preload_refs pointing to this file
        for other_path, other_gd in session._gd_scripts.items():
            if other_path == res_path:
                continue
            for ref in other_gd.preload_refs:
                if ref.path == res_path:
                    if other_path not in files_to_update_set:
                        callers.append({
                            "file": other_path,
                            "references": 0,
                            "note": "References class via preload",
                        })
                        files_to_update_set.add(other_path)

    # Deduplicated file list
    files_to_update = sorted(files_to_update_set)
    total_affected = len(files_to_update)

    # Risk calculation
    if is_autoload and total_affected >= 5:
        risk = "critical"
    elif total_affected >= 3:
        risk = "high"
    elif total_affected >= 1:
        risk = "medium"
    else:
        risk = "low"

    result = {
        "file": res_path,
        "action": action,
        "target": target,
        "change_description": change_description,
        "risk": risk,
        "callers": callers,
        "signal_impact": signal_impact,
        "safe_alternative": safe_alternative,
        "files_to_update": files_to_update,
        "total_files_affected": total_affected,
    }
    return apply_output_limit(result, detail, list_keys=["callers", "signal_impact"])


@mcp.tool(
    name="godotiq_impact_check",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_impact_check(
    file: str,
    action: str,
    target: str = "",
    change_description: str = "",
    detail: str = "normal",
    ctx: Context = None,
) -> dict:
    """Predict what breaks BEFORE making a change. Returns affected files, callers, risk level, and safe alternatives. Call before renaming, removing, or changing any function/signal/class signature. DO NOT manually search for callers — this finds them all.

    Args:
        file: Path to .gd file being modified.
        action: "modify_function", "rename_signal", "add_parameter", "remove_function", "change_return_type", "rename_class".
        target: The function/signal/class being changed.
        change_description: Free-text description of the intended change (used in safe_alternative suggestions).
        detail: Output verbosity — "brief" (summary), "normal" (default), or "full" (everything).
    """
    session = ctx.request_context.lifespan_context["session"]
    if session is None:
        return {"error": "No Godot project loaded"}
    return gate_pro("godotiq_impact_check", _build_impact_check(session, file, action, target, change_description=change_description, detail=detail))


# ---------------------------------------------------------------------------
# C04: Validate
# ---------------------------------------------------------------------------

_SNAKE_CASE_RE = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*\.gd$")


def _build_validate(
    session: GodotIQSession, target: str = "project",
    detail: str = "normal",
) -> dict:
    """Convention check for a file or entire project.

    Args:
        session: Loaded GodotIQSession.
        target: "project" for full scan, or res:// path to a specific .gd file.

    Returns:
        Dictionary with validation results including issues found.
    """
    idx = session._index

    # Determine scripts to check
    if target == "project":
        scripts_to_check = dict(session._gd_scripts)
    else:
        res_path = session.resolve_file_path(target)
        if res_path is None:
            return {"error": "File not found or not a .gd script", "file": target}
        gd = session.get_script(res_path)
        if gd is None:
            return {"error": "File not found or not a .gd script", "file": target}
        scripts_to_check = {res_path: gd}

    # Get disabled rules from config
    disabled_rules = session.config.conventions.get("disabled_rules", [])

    issues: list[dict] = []

    for res_path, gd in scripts_to_check.items():
        is_autoload = res_path in idx.autoloads.values()

        # Rule 1: class_name_missing
        if "class_name_missing" not in disabled_rules:
            if not gd.class_name and not is_autoload:
                issues.append({
                    "severity": "warning",
                    "rule": "class_name_missing",
                    "file": res_path,
                    "detail": "File has no class_name declaration",
                })

        # Rule 2: no_type_hint
        if "no_type_hint" not in disabled_rules:
            for v in gd.variables:
                if v.type_hint == "" and v.scope == "class" and not v.is_const:
                    issues.append({
                        "severity": "warning",
                        "rule": "no_type_hint",
                        "file": res_path,
                        "line": v.line,
                        "detail": f"Variable '{v.name}' has no type hint",
                    })

        # Rule 3: missing_null_check
        if "missing_null_check" not in disabled_rules:
            if len(gd.autoload_refs) > 0 and len(gd.is_instance_valid_lines) == 0:
                issues.append({
                    "severity": "warning",
                    "rule": "missing_null_check",
                    "file": res_path,
                    "detail": "Script references autoloads but has no is_instance_valid() checks",
                })

        # Rule 4: orphan_signal
        if "orphan_signal" not in disabled_rules:
            emitted_signal_names = {e.signal_name for e in gd.signal_emissions}
            defined_signal_names = {s.name for s in gd.signals}
            for sig_name in defined_signal_names & emitted_signal_names:
                has_listener = False
                for other_path, other_gd in session._gd_scripts.items():
                    if other_path == res_path:
                        continue
                    for conn in other_gd.signal_connections:
                        conn_signal = (
                            conn.signal_source.rsplit(".", 1)[-1]
                            if "." in conn.signal_source
                            else conn.signal_source
                        )
                        if conn_signal == sig_name:
                            has_listener = True
                            break
                    if has_listener:
                        break
                if not has_listener:
                    issues.append({
                        "severity": "info",
                        "rule": "orphan_signal",
                        "file": res_path,
                        "detail": f"Signal '{sig_name}' is emitted but has no listeners",
                    })

        # Rule 5: unused_signal
        if "unused_signal" not in disabled_rules:
            emitted_signal_names = {e.signal_name for e in gd.signal_emissions}
            for sig in gd.signals:
                if sig.name not in emitted_signal_names:
                    issues.append({
                        "severity": "info",
                        "rule": "unused_signal",
                        "file": res_path,
                        "detail": f"Signal '{sig.name}' is defined but never emitted",
                    })

        # Rule 6: file_naming
        if "file_naming" not in disabled_rules:
            filename = res_path.rsplit("/", 1)[-1]
            if not _SNAKE_CASE_RE.match(filename):
                issues.append({
                    "severity": "warning",
                    "rule": "file_naming",
                    "file": res_path,
                    "detail": f"Filename '{filename}' does not follow snake_case convention",
                })

    # Sort by severity order: error first, then warning, then info
    severity_order = {"error": 0, "warning": 1, "info": 2}
    issues.sort(key=lambda i: severity_order.get(i["severity"], 99))

    # Cap at 100 issues for project scan
    truncated = len(issues) > 100
    capped_issues = issues[:100]

    # Count by severity
    by_severity: dict[str, int] = {"error": 0, "warning": 0, "info": 0}
    for issue in issues:
        sev = issue["severity"]
        if sev in by_severity:
            by_severity[sev] += 1

    detail = detail.lower() if detail else "normal"

    if detail == "brief":
        return {
            "target": target,
            "total_files_checked": len(scripts_to_check),
            "total_issues": len(issues),
            "by_severity": by_severity,
        }

    result: dict = {
        "target": target,
        "total_files_checked": len(scripts_to_check),
        "total_issues": len(issues),
        "by_severity": by_severity,
        "issues": capped_issues,
    }
    if truncated:
        result["truncated"] = True

    return apply_output_limit(result, detail, list_keys=["issues"])


@mcp.tool(
    name="godotiq_validate",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_validate(
    target: str = "project",
    detail: str = "normal",
    ctx: Context = None,
) -> dict:
    """Convention check: missing type hints, missing class_name, orphan signals, naming violations. Run after every code change. Use on "project" for full scan or target a single file.

    Args:
        target: "project" for full scan, or path to a specific .gd file.
        detail: "brief" (severity counts only), "normal" (+ issue list), "full".
    """
    session = ctx.request_context.lifespan_context["session"]
    if session is None:
        return {"error": "No Godot project loaded"}
    return gate_pro("godotiq_validate", _build_validate(session, target, detail=detail))
