"""Asset Intelligence tools — inventory, scaling, validation."""

from __future__ import annotations

import os
import statistics
from pathlib import PurePosixPath

from mcp.server.fastmcp import Context

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

# Extension -> category mapping
_CATEGORY_MAP: dict[str, str] = {
    ".glb": "models",
    ".gltf": "models",
    ".obj": "models",
    ".fbx": "models",
    ".png": "textures",
    ".jpg": "textures",
    ".jpeg": "textures",
    ".svg": "textures",
    ".webp": "textures",
    ".wav": "audio",
    ".ogg": "audio",
    ".mp3": "audio",
    ".ttf": "fonts",
    ".otf": "fonts",
    ".woff": "fonts",
    ".tscn": "scenes",
    ".scn": "scenes",
    ".tres": "resources",
    ".res": "resources",
    ".gdshader": "shaders",
    ".shader": "shaders",
}

_MAX_UNUSED_LIST = 30


def _categorize(path: str) -> str:
    """Determine asset category from file extension."""
    ext = PurePosixPath(path).suffix.lower()
    return _CATEGORY_MAP.get(ext, "other")


def _build_reachable_resources(session: GodotIQSession) -> set[str]:
    """Build set of asset res:// paths reachable from top-level scenes.

    Uses session._index.scene_instances to walk the instance chain:
    1. Find top-level scenes (scenes not instanced by any other scene).
    2. Recursively collect all instanced sub-scenes (BFS).
    3. Collect all resources referenced by any scene in the chain.
    """
    index = session._index
    # Find which scenes are instanced by others
    instanced_scenes: set[str] = set()
    for subs in index.scene_instances.values():
        instanced_scenes.update(subs)

    # Top-level scenes: those not instanced by any other scene
    all_scenes = set(index.scenes.keys())
    top_level = all_scenes - instanced_scenes

    # BFS from top-level to collect all reachable scenes
    reachable: set[str] = set()
    queue = list(top_level)
    while queue:
        scene = queue.pop(0)
        if scene in reachable:
            continue
        reachable.add(scene)
        for sub in index.scene_instances.get(scene, []):
            if sub not in reachable:
                queue.append(sub)

    # Collect all resources used by reachable scenes
    used_resources: set[str] = set()
    for resource_path, using_scenes in index.resource_usage.items():
        for scene in using_scenes:
            if scene in reachable:
                used_resources.add(resource_path)
                break

    return used_resources


def _build_asset_registry(
    session: GodotIQSession,
    category: str = "all",
    check_usage: bool = True,
    detail: str = "normal",
    path_filter: str = "",
    max_results: int = 0,
) -> dict:
    """Build asset inventory with usage tracking and categorization.

    Args:
        session: Loaded GodotIQSession.
        category: Filter to a specific category, or "all".
        check_usage: Whether to check usage in scenes.

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

    # Build recursive usage set
    reachable_resources = _build_reachable_resources(session) if check_usage else set()

    assets_list: list[dict] = []
    by_category: dict[str, int] = {}
    by_directory: dict[str, int] = {}
    used_count = 0
    unused_count = 0
    unused_list: list[dict] = []

    for asset_path, asset_info in index.assets.items():
        cat = _categorize(asset_path)

        # Apply category filter
        if category != "all" and cat != category:
            continue

        # Apply path filter
        if path_filter and path_filter not in asset_path:
            continue

        # Get file size
        abs_path = session._resolve_res_path(asset_path)
        try:
            size = os.path.getsize(abs_path)
        except OSError:
            size = 0

        # Check usage via recursive reachability
        is_used = (asset_path in reachable_resources) if check_usage else True

        entry = {
            "path": asset_path,
            "type": cat,
            "size": size,
            "size_bytes": size,
            "used": is_used,
        }
        assets_list.append(entry)

        # Aggregate by category
        by_category[cat] = by_category.get(cat, 0) + 1

        # Aggregate by directory
        directory = str(PurePosixPath(asset_path).parent)
        by_directory[directory] = by_directory.get(directory, 0) + 1

        # Usage tracking
        if is_used:
            used_count += 1
        else:
            unused_count += 1
            unused_list.append(entry)

    # Sort unused by size descending, cap at max
    unused_list.sort(key=lambda e: e["size"], reverse=True)
    unused_list = unused_list[:_MAX_UNUSED_LIST]

    # Apply max_results cap
    if max_results > 0:
        assets_list = assets_list[:max_results]

    # Build result based on detail level
    detail = detail.lower() if detail else "normal"

    if detail == "brief":
        # Brief: only counts + issues summary
        result = {
            "total_assets": len(assets_list) if max_results <= 0 else min(max_results, len(assets_list)),
            "by_category": by_category,
            "used_count": used_count,
            "unused_count": unused_count,
        }
        if path_filter:
            result["path_filter"] = path_filter
        return apply_output_limit(result, detail)

    if detail == "normal":
        # Normal: counts + assets with issues + top 20 unused
        top_unused = unused_list[:20]
        result = {
            "total_assets": len(assets_list) if max_results <= 0 else min(max_results, len(assets_list)),
            "by_category": by_category,
            "used_count": used_count,
            "unused_count": unused_count,
            "unused_list": top_unused,
            "usage_summary": {
                "used": used_count,
                "unused": unused_count,
            },
        }
        if path_filter:
            result["path_filter"] = path_filter
        return apply_output_limit(result, detail, list_keys=["unused_list"])

    # Full: everything (current behavior)
    result = {
        "total_assets": len(assets_list),
        "by_category": by_category,
        "by_directory": by_directory,
        "assets": assets_list,
        "used_count": used_count,
        "unused_count": unused_count,
        "unused_list": unused_list,
        "usage_summary": {
            "used": used_count,
            "unused": unused_count,
        },
    }
    if path_filter:
        result["path_filter"] = path_filter
    return apply_output_limit(result, detail, list_keys=["assets", "unused_list"])


@mcp.tool(
    name="godotiq_asset_registry",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_asset_registry(
    category: str = "all",
    check_usage: bool = True,
    detail: str = "normal",
    path_filter: str = "",
    max_results: int = 0,
    ctx: Context = None,
) -> dict:
    """Complete asset inventory: find unused assets, missing references, assets by type. Use to audit project assets and clean up bloat.

    Args:
        category: "all", "models", "textures", "audio", "scenes", "scripts", etc.
        check_usage: Check which assets are actually used in scenes (default true).
        detail: "brief" (counts only), "normal" (counts + top 20 unused), "full" (everything).
        path_filter: Filter assets by path substring (e.g. "kenney_furniturekit").
        max_results: Max number of assets to return (0 = no limit).
    """
    session = ctx.request_context.lifespan_context["session"]
    if session is None:
        return {"error": "No Godot project loaded"}
    return gate_pro("godotiq_asset_registry", _build_asset_registry(session, category, check_usage, detail=detail, path_filter=path_filter, max_results=max_results))


# ---------------------------------------------------------------------------
# Suggest Scale
# ---------------------------------------------------------------------------


def _collect_world_nodes_recursive(
    session: GodotIQSession, scene: str
) -> tuple[list, bool]:
    """Collect world nodes from a scene and all recursively instanced sub-scenes.

    Uses session._index.scene_instances to walk the instance chain and resolve
    each sub-scene's spatial data independently.  This ensures models inside
    instanced sub-scenes are always found even if the top-level scene resolver
    cannot fully expand them.

    Returns:
        Tuple of (merged world_nodes list, success flag).
    """
    from godotiq.parsers.scene_resolver import WorldNode
    from godotiq.parsers.project_index import get_scene_dependency_chain

    spatial = session.resolve_scene_spatial(scene)
    if spatial is None:
        return [], False

    _resolved, world_nodes = spatial

    # Also resolve instanced sub-scenes explicitly via scene_instances
    index = session._index
    if index is not None:
        sub_scenes = get_scene_dependency_chain(index, scene)
        seen_sources: set[str | None] = {
            wn.instance_source for wn in world_nodes if wn.instance_source
        }
        for sub_scene in sub_scenes:
            sub_spatial = session.resolve_scene_spatial(sub_scene)
            if sub_spatial is None:
                continue
            _sub_resolved, sub_nodes = sub_spatial
            for wn in sub_nodes:
                if wn.instance_source and wn.instance_source not in seen_sources:
                    world_nodes.append(wn)
                    seen_sources.add(wn.instance_source)

    return world_nodes, True


def _find_ext_resource_refs(
    session: GodotIQSession,
    scene: str,
    model: str,
    world_nodes: list,
) -> list:
    """Find WorldNodes that reference a model via ext_resource properties.

    When a model (.glb) is used as a mesh/property (not as instance=ExtResource),
    the WorldNode won't have instance_source set. This function searches the
    parsed TscnScene ext_resources in the scene and its sub-scenes to find
    nodes referencing the model, then maps them to WorldNodes.

    Args:
        session: Loaded GodotIQSession.
        scene: res:// path to the top-level scene.
        model: res:// path to the model asset.
        world_nodes: Already-collected WorldNodes from the scene.

    Returns:
        List of WorldNodes that reference the model via ext_resource properties.
    """
    from godotiq.parsers.project_index import get_scene_dependency_chain

    index = session._index
    if index is None:
        return []

    model_filename = PurePosixPath(model).name

    # Collect scenes to check: the scene itself + its instance chain
    scenes_to_check = [scene] + get_scene_dependency_chain(index, scene)

    # For each scene, find ext_resources matching the model and the nodes using them
    matching_node_names: set[str] = set()
    for scene_path in scenes_to_check:
        tscn = session.get_scene(scene_path)
        if tscn is None:
            continue

        # Find ext_resource IDs whose path matches the model
        matching_ext_ids: set[str] = set()
        for ext_id, ext_res in tscn.ext_resources.items():
            ext_filename = PurePosixPath(ext_res.path).name
            if ext_res.path == model or ext_filename == model_filename:
                matching_ext_ids.add(ext_id)

        if not matching_ext_ids:
            continue

        # Find nodes that reference these ext_resources via properties
        for node in tscn.nodes:
            # Skip nodes that already use it as instance (handled by instance_source)
            if node.instance in matching_ext_ids:
                continue
            for _key, value in node.properties.items():
                if (
                    isinstance(value, dict)
                    and value.get("type") == "ext_resource"
                    and value.get("id") in matching_ext_ids
                ):
                    matching_node_names.add(node.name)
                    break

    if not matching_node_names:
        return []

    # Map matching node names to WorldNodes
    return [
        wn for wn in world_nodes
        if wn.node.name in matching_node_names and wn.instance_source is None
    ]


_NON_VISUAL_TYPES = {
    "Marker3D", "Node3D", "Position3D", "RemoteTransform3D",
    "BoneAttachment3D", "SpringArm3D", "NavigationAgent3D",
}

_UI_SUBSTRINGS = {"Container", "Panel", "Button", "Label"}


def _is_visual_node(wn) -> bool:
    """Check if a WorldNode represents visible geometry, not just a marker/placeholder."""
    node_type = wn.node.type
    # Exclude Control-based UI nodes
    if node_type == "Control" or any(s in node_type for s in _UI_SUBSTRINGS):
        return False
    if node_type in _NON_VISUAL_TYPES and not wn.instance_source:
        return False
    return True


def _reference_mode(
    scene: str,
    intended_use: str,
    world_nodes: list,
) -> dict:
    """Search scene for nodes matching intended_use query, return scale statistics."""
    query = intended_use.lower()
    matched = []
    for wn in world_nodes:
        name_match = query in wn.node.name.lower()
        source_match = (
            wn.instance_source is not None
            and query in PurePosixPath(wn.instance_source).name.lower()
        )
        if name_match or source_match:
            matched.append(wn)
    # Filter to visible geometry only — excludes Marker3D/bare Node3D placeholders
    matched = [wn for wn in matched if _is_visual_node(wn)]

    if not matched:
        return {
            "scene": scene,
            "mode": "reference",
            "intended_use": intended_use,
            "matches_found": 0,
            "references": [],
            "recommended_scale": None,
            "note": f"No nodes matching '{intended_use}' found. Try a broader query.",
        }

    references = [
        {
            "name": wn.node.name,
            "scale": list(wn.world_scale),
            "position": list(wn.world_position),
            "instance_source": wn.instance_source,
        }
        for wn in matched
    ]

    # Prioritize GLB/GLTF models for recommended_scale
    glb_matches = [
        wn for wn in matched
        if (wn.instance_source and wn.instance_source.lower().endswith((".glb", ".gltf")))
        or "Model" in wn.node.name or "Mesh" in wn.node.name
    ]
    scale_source = glb_matches if glb_matches else matched
    recommendation_source = "glb_models" if glb_matches else "all_matches"

    scales_x = [wn.world_scale[0] for wn in scale_source]
    scales_y = [wn.world_scale[1] for wn in scale_source]
    scales_z = [wn.world_scale[2] for wn in scale_source]
    recommended_scale = [
        statistics.median(scales_x),
        statistics.median(scales_y),
        statistics.median(scales_z),
    ]

    return {
        "scene": scene,
        "mode": "reference",
        "intended_use": intended_use,
        "matches_found": len(matched),
        "references": references,
        "recommendation_source": recommendation_source,
        "scale_range": {
            "min": [min(scales_x), min(scales_y), min(scales_z)],
            "max": [max(scales_x), max(scales_y), max(scales_z)],
        },
        "recommended_scale": recommended_scale,
        "note": f"Found {len(matched)} node(s) matching '{intended_use}' in scene.",
    }


def _build_suggest_scale(
    session: GodotIQSession,
    scene: str,
    model: str | None = None,
    near_node: str | None = None,
    intended_use: str = "",
    detail: str = "normal",
) -> dict:
    """Suggest scale for a model based on similar models in a scene.

    When model is provided: finds similar models and suggests scale based on them.
    When model is omitted: searches for nodes matching intended_use and returns
    their scale statistics (reference mode).

    Args:
        session: Loaded GodotIQSession.
        scene: res:// path to .tscn file.
        model: res:// path to the model asset. Optional — omit for reference mode.
        near_node: Optional node name to suggest position near.
        intended_use: Hint about intended use. Required when model is omitted.
        detail: Output verbosity — "brief" (summary), "normal" (default), or "full" (everything).

    Returns:
        Dictionary with scale suggestion data.
    """
    if intended_use is None:
        intended_use = ""

    if model is not None and not model.strip():
        model = None

    if model is None and not intended_use:
        return {"error": "Either 'model' or 'intended_use' must be provided"}

    from godotiq.tools.spatial import _find_focus_node

    world_nodes, ok = _collect_world_nodes_recursive(session, scene)
    if not ok:
        return {"error": "Scene not found", "scene": scene}

    if model is None:
        result = _reference_mode(scene, intended_use, world_nodes)
        if detail == "brief":
            brief_keys = {"scene", "mode", "recommended_scale", "match_tier", "matches_found", "error"}
            result = {k: v for k, v in result.items() if k in brief_keys}
        return apply_output_limit(result, detail, list_keys=["reference_models", "references"])

    # Find similar models in the scene (including recursively instanced sub-scenes)
    # Priority order: exact filename → same directory → group.
    # Use ONLY the highest-priority tier that has matches.

    # Tier 1 — exact filename: instance_source matches OR ext_resource property matches
    exact_matches = [
        wn for wn in world_nodes
        if wn.instance_source == model
    ]
    exact_ids = set(id(wn) for wn in exact_matches)
    ext_ref_matches = [
        wn for wn in _find_ext_resource_refs(session, scene, model, world_nodes)
        if id(wn) not in exact_ids
    ]
    exact_all = exact_matches + ext_ref_matches

    if exact_all:
        references = exact_all
        match_tier = "exact_filename"
    else:
        # Tier 2 — same directory (different file)
        model_dir = str(PurePosixPath(model).parent)
        dir_matches = [
            wn for wn in world_nodes
            if wn.instance_source
            and str(PurePosixPath(wn.instance_source).parent) == model_dir
        ]
        if dir_matches:
            references = dir_matches
            match_tier = "same_directory"
        else:
            # Tier 3 — group match (nodes sharing groups with any model node)
            all_model_nodes = [wn for wn in world_nodes if wn.instance_source]
            group_set: set[str] = set()
            for wn in all_model_nodes:
                group_set.update(wn.node.groups)
            group_matches = [
                wn for wn in world_nodes
                if wn.instance_source and bool(group_set & set(wn.node.groups))
            ]
            if group_matches:
                references = group_matches
                match_tier = "group"
            else:
                references = []
                match_tier = "none"

    # Build reference model list
    reference_models = []
    for wn in references:
        reference_models.append({
            "name": wn.node.name,
            "source": wn.instance_source or model,
            "scale": list(wn.world_scale),
        })

    # Compute recommended scale
    recommended_scale: list[float] | None = None
    reasoning: str
    suggestion: str | None = None
    note: str | None = None

    if references:
        # Compute median scale per axis
        scales_x = [wn.world_scale[0] for wn in references]
        scales_y = [wn.world_scale[1] for wn in references]
        scales_z = [wn.world_scale[2] for wn in references]
        median_x = statistics.median(scales_x)
        median_y = statistics.median(scales_y)
        median_z = statistics.median(scales_z)
        recommended_scale = [median_x, median_y, median_z]
        reasoning = f"Median of {len(references)} reference model(s) in the scene"
    else:
        reasoning = "No similar reference models found in scene"
        suggestion = "Place the model at scale (1, 1, 1) and adjust manually"
        note = "No models with matching path, directory, or groups found"

    # Handle near_node
    position_suggestion: list[float] | None = None
    if near_node and world_nodes:
        focus_wn, _alts = _find_focus_node(world_nodes, near_node)
        if focus_wn is not None:
            # Suggest position slightly offset from the reference node
            pos = focus_wn.world_position
            position_suggestion = [pos[0] + 1.0, pos[1], pos[2]]

    result: dict = {
        "scene": scene,
        "model": model,
        "mode": "model",
        "recommended_scale": recommended_scale,
        "match_tier": match_tier,
        "reference_models": reference_models,
        "reasoning": reasoning,
    }

    if position_suggestion is not None:
        result["position_suggestion"] = position_suggestion
    elif near_node:
        result["position_suggestion"] = None

    if suggestion is not None:
        result["suggestion"] = suggestion
    if note is not None:
        result["note"] = note

    if detail == "brief":
        brief_keys = {"scene", "mode", "recommended_scale", "match_tier", "matches_found", "error"}
        result = {k: v for k, v in result.items() if k in brief_keys}

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


@mcp.tool(
    name="godotiq_suggest_scale",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_suggest_scale(
    scene: str,
    model: str | None = None,
    near_node: str | None = None,
    intended_use: str = "",
    detail: str = "normal",
    ctx: Context = None,
) -> dict:
    """Recommend scale + position for a model based on similar assets in the scene. Never guess scales — this analyzes existing objects and suggests matching values. Respects asset_origins from .godotiq.json.

    Args:
        scene: Path to .tscn file (res:// or relative).
        model: Model asset path (res://). Omit for reference mode (returns scale stats for matching nodes).
        near_node: Node name to suggest position near.
        intended_use: Semantic hint (e.g. "printer", "shelf"). Required when model is omitted.
        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_suggest_scale", _build_suggest_scale(session, scene, model, near_node, intended_use, detail=detail))
