"""Spatial Intelligence tools -- scene maps, transforms, spatial queries.

These tools provide spatial understanding of Godot scenes.
"""

from __future__ import annotations

import math

from mcp.server.fastmcp import Context

from godotiq.license import gate_pro
from godotiq.server import mcp
from godotiq.session import GodotIQSession
from godotiq.parsers.scene_resolver import (
    WorldNode,
    find_nodes_in_radius,
)
from godotiq.tools.output_limit import apply_output_limit


def _compute_distance(a: tuple, b: tuple) -> float:
    """Euclidean distance between two 3D points."""
    return math.sqrt(sum((ai - bi) ** 2 for ai, bi in zip(a, b)))


def _compute_direction(from_pos: tuple, to_pos: tuple) -> str:
    """Compute cardinal direction from one position to another.

    Uses Godot's coordinate system:
    - X+ = east, X- = west
    - Y+ = up, Y- = down
    - Z+ = south (toward camera in default view), Z- = north
    """
    dx = to_pos[0] - from_pos[0]
    dy = to_pos[1] - from_pos[1]
    dz = to_pos[2] - from_pos[2]

    # Check vertical first
    if abs(dy) > abs(dx) and abs(dy) > abs(dz):
        return "above" if dy > 0 else "below"

    # Check horizontal
    dist_xz = math.sqrt(dx * dx + dz * dz)
    if dist_xz < 0.5:
        return "nearby"

    # Determine primary horizontal direction
    if abs(dx) > abs(dz):
        return "east" if dx > 0 else "west"
    else:
        return "north" if dz < 0 else "south"


def _compute_bounds(nodes: list[WorldNode]) -> dict:
    """Compute axis-aligned bounding box for a list of world nodes."""
    if not nodes:
        return {"min": [0, 0, 0], "max": [0, 0, 0]}
    positions = [wn.world_position for wn in nodes]
    return {
        "min": [min(p[i] for p in positions) for i in range(3)],
        "max": [max(p[i] for p in positions) for i in range(3)],
    }


def _node_to_brief(wn: WorldNode) -> dict:
    """Build brief node representation."""
    return {
        "name": wn.node.name,
        "type": wn.node.type,
        "position": list(wn.world_position),
        "depth": wn.depth,
    }


def _node_to_normal(wn: WorldNode) -> dict:
    """Build normal-detail node representation."""
    return {
        "name": wn.node.name,
        "type": wn.node.type,
        "full_path": wn.full_path,
        "position": list(wn.world_position),
        "rotation_deg": [math.degrees(r) for r in wn.world_rotation],
        "scale": list(wn.world_scale),
        "depth": wn.depth,
        "is_instance": wn.is_instance_root,
        "instance_source": wn.instance_source,
        "groups": list(wn.node.groups),
        "script": wn.node.script,
    }


def _node_to_full(wn: WorldNode) -> dict:
    """Build full-detail node representation."""
    d = _node_to_normal(wn)
    d["properties"] = dict(wn.node.properties)
    d["metadata"] = dict(wn.node.metadata)
    d["children_count"] = len(wn.node.children)
    return d


def _find_focus_node(
    world_nodes: list[WorldNode], focus: str
) -> tuple[WorldNode | None, list[str] | None]:
    """Find focus node using 4-tier search with alternatives tracking.

    Search priority:
    1. Exact match on full_path
    2. Exact match on node.name
    3. Case-insensitive match on node.name
    4. Substring match on node.name (focus is substring of name)

    Returns:
        Tuple of (matched node or None, alternatives list or None).
    """
    # Tier 1: exact full_path
    for wn in world_nodes:
        if wn.full_path == focus:
            return wn, None

    # Tier 2: exact node.name
    for wn in world_nodes:
        if wn.node.name == focus:
            return wn, None

    # Tier 3: case-insensitive node.name
    focus_lower = focus.lower()
    ci_matches = [wn for wn in world_nodes if wn.node.name.lower() == focus_lower]
    if ci_matches:
        alts = [wn.full_path or wn.node.name for wn in ci_matches[1:6]]
        return ci_matches[0], alts if alts else None

    # Tier 4: substring match (focus is substring of node.name, case-insensitive)
    sub_matches = [wn for wn in world_nodes if focus_lower in wn.node.name.lower()]
    if sub_matches:
        alts = [wn.full_path or wn.node.name for wn in sub_matches[1:6]]
        return sub_matches[0], alts if alts else None

    return None, None


def _filter_by_include(
    world_nodes: list[WorldNode], include: list[str]
) -> list[WorldNode]:
    """Filter nodes whose type or any group matches an include entry."""
    include_set = set(include)
    return [
        wn
        for wn in world_nodes
        if wn.node.type in include_set
        or bool(include_set & set(wn.node.groups))
    ]


def _build_scene_map(
    session: GodotIQSession,
    scene_path: str,
    focus: str | None = None,
    radius: float = 5.0,
    include: list[str] | None = None,
    detail: str = "normal",
) -> dict:
    """Build spatial map of a scene.

    Args:
        session: Loaded GodotIQSession.
        scene_path: res:// path to .tscn file.
        focus: Optional node name/path to center the view on.
        radius: Distance filter around focus node (only with focus).
        include: Optional list of node types or groups to include.
        detail: "brief", "normal", or "full".

    Returns:
        Dictionary with spatial map data.
    """
    spatial = session.resolve_scene_spatial(scene_path)
    if spatial is None:
        return {"error": "Scene not found", "scene": scene_path}

    resolved, world_nodes = spatial
    detail = detail.lower()

    # Save full list for summaries and subtree counts
    all_resolved_nodes = world_nodes

    # Find focus node in unfiltered list before applying include filter
    focus_node: WorldNode | None = None
    focus_alternatives: list[str] | None = None
    if focus:
        focus_node, focus_alternatives = _find_focus_node(world_nodes, focus)

    # Apply include filter
    if include:
        world_nodes = _filter_by_include(world_nodes, include)

    # Focus mode: filter by radius around focus node
    nearby_total_before_cap = 0
    nearby_capped = False
    if focus_node is not None:
        world_nodes = find_nodes_in_radius(
            world_nodes, focus_node.world_position, radius
        )
        # Ensure focus node itself is in results even if filtered by include
        if focus_node not in world_nodes:
            world_nodes.insert(0, focus_node)
        # Sort by distance to focus
        world_nodes.sort(
            key=lambda wn: _compute_distance(
                focus_node.world_position, wn.world_position
            )
        )
        # Detail-dependent cap
        cap = _MAX_NEARBY.get(detail, 20)
        nearby_total_before_cap = len(world_nodes)
        nearby_capped = nearby_total_before_cap > cap
        world_nodes = world_nodes[:cap]
    else:
        # No focus: top-level only (root + direct children), cap at 50
        world_nodes = [wn for wn in world_nodes if wn.depth <= 1]
        world_nodes = world_nodes[:_MAX_NODES_NO_FOCUS]

    # Build node representations based on detail level
    if focus_node is not None:
        # Focus mode: compact representations
        node_dicts = []
        for wn in world_nodes:
            dist = _compute_distance(focus_node.world_position, wn.world_position)
            nd: dict = {
                "name": wn.node.name,
                "type": wn.node.type,
                "distance_to_focus": round(dist, 4),
                "direction_from_focus": _compute_direction(
                    focus_node.world_position, wn.world_position
                ),
            }
            if detail == "normal":
                nd["groups"] = list(wn.node.groups)
                nd["has_script"] = wn.node.script is not None
            elif detail == "full":
                nd["full_path"] = wn.full_path
                nd["position"] = list(wn.world_position)
                nd["rotation_deg"] = [math.degrees(r) for r in wn.world_rotation]
                nd["scale"] = list(wn.world_scale)
                nd["groups"] = list(wn.node.groups)
                nd["script"] = wn.node.script
                nd["is_instance"] = wn.is_instance_root
                nd["instance_source"] = wn.instance_source
                nd["properties"] = dict(wn.node.properties)
                nd["metadata"] = dict(wn.node.metadata)
            node_dicts.append(nd)
    else:
        if detail == "full":
            node_dicts = [_node_to_full(wn) for wn in world_nodes]
        elif detail == "brief":
            node_dicts = [_node_to_brief(wn) for wn in world_nodes]
        else:
            node_dicts = [_node_to_normal(wn) for wn in world_nodes]

    # Add non-focus subtree counts
    if focus_node is None:
        for nd, wn in zip(node_dicts, world_nodes):
            if wn.full_path == "":
                # Root node: all other nodes are descendants
                nd["subtree_count"] = len(all_resolved_nodes) - 1
            else:
                prefix = wn.full_path + "/"
                nd["subtree_count"] = sum(
                    1
                    for other in all_resolved_nodes
                    if (other.full_path or "").startswith(prefix)
                )

    # Build result
    root_type = resolved.root.type if resolved.root else ""
    result: dict = {
        "scene": scene_path,
        "root_type": root_type,
        "total_nodes": resolved.total_nodes,
        "nodes": node_dicts,
    }

    # Token hints
    result["token_hint"] = detail
    if focus_node is not None:
        result["nearby_count"] = len(node_dicts)
        result["nearby_capped"] = nearby_capped
        if nearby_capped:
            result["nearby_total_in_radius"] = nearby_total_before_cap

    # Add focus metadata
    if focus_node is not None:
        result["focus_node"] = focus_node.full_path or focus_node.node.name
        result["focus_position"] = list(focus_node.world_position)
        if focus_alternatives:
            result["focus_alternatives"] = focus_alternatives
    elif focus:
        result["warning"] = f"Focus node '{focus}' not found in scene"

    # Normal+ fields: use all nodes for summaries when no focus
    if detail in ("normal", "full"):
        summary_nodes = all_resolved_nodes if focus_node is None else world_nodes
        result["spatial_summary"] = {
            "bounds": _compute_bounds(summary_nodes),
            "center": _compute_center(summary_nodes),
        }
        result["groups_summary"] = _compute_groups_summary(
            all_resolved_nodes if focus_node is None else world_nodes
        )

    # Full-only fields
    if detail == "full":
        result["distance_matrix"] = _compute_distance_matrix(world_nodes)
        result["type_summary"] = _compute_type_summary(
            all_resolved_nodes if focus_node is None else world_nodes
        )

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


def _compute_center(nodes: list[WorldNode]) -> list[float]:
    """Compute center point of all nodes."""
    if not nodes:
        return [0.0, 0.0, 0.0]
    positions = [wn.world_position for wn in nodes]
    n = len(positions)
    return [sum(p[i] for p in positions) / n for i in range(3)]


def _compute_groups_summary(nodes: list[WorldNode]) -> dict[str, int]:
    """Count nodes per group."""
    groups: dict[str, int] = {}
    for wn in nodes:
        for g in wn.node.groups:
            groups[g] = groups.get(g, 0) + 1
    return groups


def _compute_type_summary(nodes: list[WorldNode]) -> dict[str, int]:
    """Count nodes per type."""
    types: dict[str, int] = {}
    for wn in nodes:
        t = wn.node.type or "Unknown"
        types[t] = types.get(t, 0) + 1
    return types


_MAX_NODES_NO_FOCUS = 50
_MAX_NEARBY: dict[str, int] = {"brief": 10, "normal": 20, "full": 30}
_MAX_DISTANCE_PAIRS = 50


def _compute_distance_matrix(nodes: list[WorldNode]) -> list[dict]:
    """Compute pairwise distances between key nodes (capped at 50 pairs)."""
    key_nodes = [
        wn
        for wn in nodes
        if wn.node.script or wn.node.groups or wn.is_instance_root
    ]
    matrix: list[dict] = []
    for i, a in enumerate(key_nodes):
        for b in key_nodes[i + 1 :]:
            matrix.append(
                {
                    "from": a.full_path or a.node.name,
                    "to": b.full_path or b.node.name,
                    "distance": round(
                        _compute_distance(a.world_position, b.world_position), 4
                    ),
                }
            )
            if len(matrix) >= _MAX_DISTANCE_PAIRS:
                return matrix
    return matrix


@mcp.tool(
    name="godotiq_scene_map",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_scene_map(
    scene: str,
    focus: str | None = None,
    radius: float = 5.0,
    include: list[str] | None = None,
    detail: str = "normal",
    ctx: Context = None,
) -> dict:
    """Spatial understanding of a .tscn scene: positions, distances, directions, bounds. ALWAYS call before placing or moving 3D objects — never guess positions. DO NOT read .tscn files directly — they are unreadable resource format. Use focus + radius to zoom into an area.

    Args:
        scene: Path to .tscn file (res:// or relative).
        focus: Node name or path to center the view on. Returns nearby nodes sorted by distance.
        radius: Distance filter around focus node (meters, default 5.0). Only used with focus.
        include: List of node types or groups to include (e.g. ["MeshInstance3D", "enemies"]).
        detail: "brief" (name/type/position only), "normal" (+ groups, scripts, bounds), "full" (+ properties, distance matrix).
    """
    session = ctx.request_context.lifespan_context["session"]
    if session is None:
        return {"error": "No Godot project loaded"}
    return gate_pro("godotiq_scene_map", _build_scene_map(session, scene, focus, radius, include, detail))


# ---------------------------------------------------------------------------
# Spatial Audit
# ---------------------------------------------------------------------------

_LIGHT_TYPES = {"OmniLight3D", "SpotLight3D", "DirectionalLight3D", "Light3D"}
_CAMERA_TYPES = {"Camera3D", "Camera2D"}
_EXEMPT_FLOAT_TYPES = _LIGHT_TYPES | _CAMERA_TYPES
_MESH_EXTENSIONS = {".glb", ".gltf", ".obj"}

# Types with spatial relevance in 3D — used to filter overlap checks.
# Nodes whose type is NOT in this set (e.g. Control, Node, CanvasLayer) are
# excluded from overlap detection because they sit at the origin by design.
_SPATIAL_3D_TYPES = {
    "Node3D", "MeshInstance3D", "MultiMeshInstance3D",
    "StaticBody3D", "RigidBody3D", "CharacterBody3D", "AnimatableBody3D", "Area3D",
    "CollisionShape3D", "CollisionPolygon3D",
    "Marker3D", "Position3D",
    "Camera3D", "Light3D", "DirectionalLight3D", "OmniLight3D", "SpotLight3D",
    "NavigationRegion3D", "NavigationObstacle3D",
    "CSGBox3D", "CSGSphere3D", "CSGCylinder3D", "CSGCombiner3D",
    "GPUParticles3D", "CPUParticles3D",
    "AudioStreamPlayer3D",
    "RayCast3D", "ShapeCast3D",
    "Skeleton3D", "BoneAttachment3D",
    "Sprite3D",
    "Path3D", "PathFollow3D",
    "Decal", "FogVolume",
}

_FLOATING_THRESHOLD = 100.0
_SCALE_RATIO_THRESHOLD = 2.5
_Z_FIGHTING_DISTANCE = 0.01
_EXTREME_DISTANCE = 1000.0
_OVERLAP_DISTANCE = 0.01
_MAX_AUDIT_ISSUES = 50
_SEVERITY_ORDER: dict[str, int] = {"critical": 0, "warning": 1, "info": 2}

ALL_CHECKS = [
    "floating_objects",
    "scale_mismatch",
    "z_fighting",
    "empty_markers",
    "overlapping",
    "extreme_positions",
]


def _check_floating_objects(world_nodes: list[WorldNode]) -> list[dict]:
    """Detect nodes with high Y that are not lights or cameras."""
    issues: list[dict] = []
    for wn in world_nodes:
        if wn.node.type in _EXEMPT_FLOAT_TYPES:
            continue
        y = wn.world_position[1]
        if abs(y) > _FLOATING_THRESHOLD:
            issues.append({
                "check": "floating_objects",
                "node": wn.node.name,
                "position": list(wn.world_position),
                "severity": "warning",
                "detail": f"Y={y:.1f} exceeds threshold ({_FLOATING_THRESHOLD})",
                "suggestion": "Verify this node should be at this height",
            })
    return issues


def _check_scale_mismatch(world_nodes: list[WorldNode]) -> list[dict]:
    """Detect nodes with extreme scale relative to siblings."""
    issues: list[dict] = []
    # Group nodes by parent path
    by_parent: dict[str, list[WorldNode]] = {}
    for wn in world_nodes:
        if wn.depth == 0:
            continue
        parent = "/".join(wn.full_path.split("/")[:-1]) if "/" in wn.full_path else ""
        by_parent.setdefault(parent, []).append(wn)

    for _parent, siblings in by_parent.items():
        if len(siblings) < 2:
            continue
        # Compute uniform scale magnitude for each sibling
        magnitudes = []
        for s in siblings:
            mag = (abs(s.world_scale[0]) + abs(s.world_scale[1]) + abs(s.world_scale[2])) / 3.0
            magnitudes.append(mag)
        median_mag = sorted(magnitudes)[len(magnitudes) // 2]
        if median_mag < 1e-9:
            continue
        for s, mag in zip(siblings, magnitudes):
            ratio = mag / median_mag if median_mag > 0 else 0
            if ratio > _SCALE_RATIO_THRESHOLD or (ratio > 0 and ratio < 1.0 / _SCALE_RATIO_THRESHOLD):
                issues.append({
                    "check": "scale_mismatch",
                    "node": s.node.name,
                    "scale": list(s.world_scale),
                    "severity": "warning",
                    "detail": f"Scale ratio {ratio:.2f}x vs sibling median",
                    "suggestion": "Check if scale is intentional",
                })
    return issues


def _is_mesh_geometry(wn: WorldNode) -> bool:
    """Check if a world node represents mesh geometry (not an organizational container).

    A node is mesh geometry if its type contains "MeshInstance" or its
    instance_source ends with .glb/.gltf/.obj.  Pure Node3D/Marker3D at
    (0,0,0) are organizational containers, not renderable geometry.
    """
    if wn.node.type and "MeshInstance" in wn.node.type:
        return True
    if wn.instance_source:
        src = wn.instance_source.lower()
        for ext in _MESH_EXTENSIONS:
            if src.endswith(ext):
                return True
    return False


def _parent_path(full_path: str) -> str:
    """Extract parent path from full_path (e.g., 'A/B/C' -> 'A/B').

    Root-level nodes (no '/' in path) return empty string.
    """
    if "/" in full_path:
        return full_path.rsplit("/", 1)[0]
    return ""


def _check_z_fighting(world_nodes: list[WorldNode]) -> list[dict]:
    """Detect mesh-geometry nodes at nearly identical positions."""
    # Filter to mesh-only nodes — Node3D/Marker3D containers at origin are not geometry
    mesh_nodes = [wn for wn in world_nodes if _is_mesh_geometry(wn)]
    issues: list[dict] = []
    seen: set[str] = set()
    for i, a in enumerate(mesh_nodes):
        for b in mesh_nodes[i + 1:]:
            pair_key = f"{a.node.name}|{b.node.name}"
            if pair_key in seen:
                continue
            dist = _compute_distance(a.world_position, b.world_position)
            if dist < _Z_FIGHTING_DISTANCE and a.node.name != b.node.name:
                seen.add(pair_key)
                severity = "info"  # z-fighting is always cosmetic, never a gameplay bug
                issues.append({
                    "check": "z_fighting",
                    "node": a.node.name,
                    "other_node": b.node.name,
                    "position": list(a.world_position),
                    "severity": severity,
                    "detail": f"Distance {dist:.4f} to {b.node.name}",
                    "suggestion": "Offset nodes slightly to avoid z-fighting",
                })
    return issues


def _check_empty_markers(world_nodes: list[WorldNode]) -> list[dict]:
    """Detect Marker3D with groups but no children."""
    issues: list[dict] = []
    for wn in world_nodes:
        if wn.node.type == "Marker3D" and wn.node.groups and not wn.node.children:
            issues.append({
                "check": "empty_markers",
                "node": wn.node.name,
                "severity": "info",
                "detail": f"Marker3D with groups {list(wn.node.groups)} but no children",
                "suggestion": "Add children or remove group assignment",
            })
    return issues


def _is_spatial_3d(wn: WorldNode) -> bool:
    """Return True if node has 3D spatial relevance for overlap checks."""
    if wn.node.type in _SPATIAL_3D_TYPES:
        return True
    # .glb/.gltf/.obj instances are spatial even with empty type
    if wn.instance_source:
        dot = wn.instance_source.rfind(".")
        if dot != -1 and wn.instance_source[dot:].lower() in _MESH_EXTENSIONS:
            return True
    return False


def _check_overlapping(world_nodes: list[WorldNode]) -> list[dict]:
    """Detect instance roots at the same position."""
    issues: list[dict] = []
    instances = [wn for wn in world_nodes if wn.is_instance_root and _is_spatial_3d(wn)]
    seen: set[str] = set()
    for i, a in enumerate(instances):
        for b in instances[i + 1:]:
            pair_key = f"{a.node.name}|{b.node.name}"
            if pair_key in seen:
                continue
            dist = _compute_distance(a.world_position, b.world_position)
            if dist < _OVERLAP_DISTANCE:
                seen.add(pair_key)
                issues.append({
                    "check": "overlapping",
                    "node": a.node.name,
                    "other_node": b.node.name,
                    "position": list(a.world_position),
                    "severity": "info",
                    "detail": f"Instance root overlaps with {b.node.name}",
                    "suggestion": "Move one instance to avoid overlap",
                })
    return issues


def _check_extreme_positions(
    world_nodes: list[WorldNode], center: list[float]
) -> list[dict]:
    """Detect nodes far from scene center."""
    issues: list[dict] = []
    center_tuple = tuple(center)
    for wn in world_nodes:
        dist = _compute_distance(wn.world_position, center_tuple)
        if dist > _EXTREME_DISTANCE:
            issues.append({
                "check": "extreme_positions",
                "node": wn.node.name,
                "position": list(wn.world_position),
                "severity": "info",
                "detail": f"Distance {dist:.1f} from scene center exceeds {_EXTREME_DISTANCE}",
                "suggestion": "Verify this node should be this far from center",
            })
    return issues


_COSMETIC_CHECKS = {"z_fighting", "overlapping"}

VALID_DETAIL_LEVELS = {"brief", "normal", "full"}


def _build_spatial_audit(
    session: GodotIQSession,
    scene: str,
    checks: list[str] | None = None,
    min_severity: str = "warning",
    detail: str = "normal",
) -> dict:
    """Run spatial audit checks on a scene.

    Args:
        session: Loaded GodotIQSession.
        scene: res:// path to .tscn file.
        checks: Optional list of check names to run. Runs all if None.
        min_severity: Minimum severity to include in results. Default "warning"
            filters out info-level issues. Use "info" to see everything.
        detail: Controls output verbosity. "brief" = only critical+warning,
            "normal" = excludes cosmetic info (z_fighting, overlapping),
            "full" = everything.

    Returns:
        Dictionary with audit results.
    """
    if min_severity not in _SEVERITY_ORDER:
        min_severity = "warning"
    if detail not in VALID_DETAIL_LEVELS:
        detail = "normal"
    spatial = session.resolve_scene_spatial(scene)
    if spatial is None:
        return {"error": "Scene not found", "scene": scene}

    _resolved, world_nodes = spatial

    checks_to_run = checks if checks else ALL_CHECKS
    center = _compute_center(world_nodes)

    all_issues: list[dict] = []
    by_check: dict[str, int] = {}

    check_dispatch = {
        "floating_objects": lambda: _check_floating_objects(world_nodes),
        "scale_mismatch": lambda: _check_scale_mismatch(world_nodes),
        "z_fighting": lambda: _check_z_fighting(world_nodes),
        "empty_markers": lambda: _check_empty_markers(world_nodes),
        "overlapping": lambda: _check_overlapping(world_nodes),
        "extreme_positions": lambda: _check_extreme_positions(world_nodes, center),
    }

    for check_name in checks_to_run:
        if check_name in check_dispatch:
            found = check_dispatch[check_name]()
            all_issues.extend(found)
            by_check[check_name] = len(found)

    # Compute by_severity counts before filtering
    by_severity: dict[str, int] = {}
    for issue in all_issues:
        sev = issue["severity"]
        by_severity[sev] = by_severity.get(sev, 0) + 1

    total_unfiltered = len(all_issues)

    # Sort: higher severity first
    all_issues.sort(key=lambda i: _SEVERITY_ORDER.get(i["severity"], 99))

    # Apply detail-level filtering
    if detail == "brief":
        all_issues = [i for i in all_issues if i["severity"] in ("critical", "warning")]
    elif detail == "normal":
        all_issues = [
            i for i in all_issues
            if not (i["severity"] == "info" and i.get("check") in _COSMETIC_CHECKS)
        ]
    # detail == "full" keeps everything

    # Filter by min_severity
    threshold = _SEVERITY_ORDER[min_severity]
    all_issues = [i for i in all_issues if _SEVERITY_ORDER.get(i["severity"], 99) <= threshold]

    # Cap at max
    all_issues = all_issues[:_MAX_AUDIT_ISSUES]

    result = {
        "scene": scene,
        "total_issues": sum(by_check.values()),
        "shown_issues": len(all_issues),
        "filtered_by": min_severity,
        "detail": detail,
        "by_severity": by_severity,
        "issues": all_issues,
        "by_check": by_check,
        "checks_run": list(checks_to_run),
    }

    if detail != "full":
        result["total_issues_unfiltered"] = total_unfiltered
        result["filtered_out"] = {
            check: by_check.get(check, 0) for check in _COSMETIC_CHECKS
            if by_check.get(check, 0) > 0
        }

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


@mcp.tool(
    name="godotiq_spatial_audit",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_spatial_audit(
    scene: str,
    checks: list[str] | None = None,
    min_severity: str = "warning",
    detail: str = "normal",
    ctx: Context = None,
) -> dict:
    """Automated 3D scene linter: floating objects, scale mismatches, z-fighting, overlapping instances, extreme positions. Run on any scene to catch spatial issues before they become visual bugs. Use detail="brief" for only real problems.

    Args:
        scene: Path to .tscn file (res:// or relative).
        checks: Specific checks to run (default all 6): "floating_objects", "scale_mismatch", "z_fighting", "empty_markers", "overlapping", "extreme_positions".
        min_severity: Minimum severity: "warning" (default, filters info), "info" (everything), "critical".
        detail: "brief" (critical + warning only), "normal" (excludes cosmetic z-fighting/overlapping), "full" (everything).
    """
    session = ctx.request_context.lifespan_context["session"]
    if session is None:
        return {"error": "No Godot project loaded"}
    return gate_pro("godotiq_spatial_audit", _build_spatial_audit(session, scene, checks, min_severity=min_severity, detail=detail))


# ---------------------------------------------------------------------------
# Placement
# ---------------------------------------------------------------------------

from godotiq.tools.spatial.placement import compute_placement  # noqa: E402


def _resolve_placement_nodes(
    session: GodotIQSession, scene: str | None
) -> list[dict] | None:
    """Resolve scene into flat node dicts for placement. Returns None on failure."""
    if scene:
        res_path = session.to_res_path(scene)
    else:
        from godotiq.tools.memory import _read_main_scene

        res_path = _read_main_scene(session)
        if not res_path:
            return None

    result = session.resolve_scene_spatial(res_path)
    if result is None:
        return None

    _, world_nodes = result
    return [
        {
            "name": wn.node.name,
            "type": wn.node.type,
            "position": list(wn.world_position),
            "scale": list(wn.world_scale),
            "depth": wn.depth,
            "groups": list(wn.node.groups),
            "children_count": len(wn.node.children),
        }
        for wn in world_nodes
    ]


@mcp.tool(
    name="godotiq_placement",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_placement(
    scene: str = "",
    near: str = "",
    near_position: list[float] | None = None,
    object_type: str = "",
    constraints: dict | None = None,
    max_suggestions: int = 3,
    detail: str = "normal",
    ctx: Context = None,
) -> dict:
    """Find safe placement positions for new objects. DO NOT guess positions or manually calculate coordinates. Checks Marker3D slots first (designer-intended spots, highest confidence), then grid-searches nearby space with wall/overlap validation. Returns up to 3 suggestions with confidence scores.

    Args:
        scene: Scene file path (.tscn). Uses project main scene if empty.
        near: Node name to place near (e.g. "PrinterSlot_P3").
        near_position: [x, y, z] coordinates to place near (alternative to 'near').
        object_type: Semantic type hint (e.g. "printer", "shelf") — finds similar objects for scale/rotation reference.
        constraints: {on_floor: bool, min_wall_distance: float, min_object_distance: float, face_direction: str, marker_group: str}.
        max_suggestions: Maximum number of suggestions (default 3).
        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"}

    nodes = _resolve_placement_nodes(session, scene or None)
    if nodes is None:
        return {"error": "Scene not found", "scene": scene}

    result = compute_placement(
        world_nodes=nodes,
        near=near,
        near_position=near_position,
        object_type=object_type,
        constraints=constraints,
        max_suggestions=max_suggestions,
    )

    if detail == "brief" and "suggestions" in result:
        result["suggestions"] = [
            {"position": s["position"], "confidence": s["confidence"]}
            for s in result["suggestions"][:1]
        ]
        result.pop("excluded_zones", None)
        result.pop("scale_reasoning", None)

    return gate_pro("godotiq_placement", apply_output_limit(result, detail, list_keys=["suggestions", "excluded_zones"]))
