"""B02 — Smart object placement with constraint solving.

Given a reference point (node name or coordinates) and constraints,
finds the optimal position for placing a new object. Uses scene_map
spatial data for constraint checking and scale recommendation.

Pure filesystem analysis — no addon needed.
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field


@dataclass
class PlacementSuggestion:
    position: list[float]
    rotation_deg: list[float]
    scale: list[float]
    confidence: float
    reason: str
    clearance: dict
    source: str  # "marker" | "grid_search"


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def compute_placement(
    world_nodes: list[dict],
    near: str = "",
    near_position: list[float] | None = None,
    object_type: str = "",
    constraints: dict | None = None,
    max_suggestions: int = 3,
) -> dict:
    """Find optimal placement for a new object.

    Args:
        world_nodes: Flat list of node dicts with name, type, position,
            scale, depth, groups, children_count fields.
        near: Node name to place near.
        near_position: [x, y, z] coordinates to place near.
        object_type: Semantic type hint for finding similar objects.
        constraints: Optional constraint dict (on_floor, min_wall_distance,
            min_object_distance, face_direction, marker_group).
        max_suggestions: Maximum suggestions to return.

    Returns:
        Dict with suggestions, excluded_zones, scale_reasoning, etc.
    """
    if not world_nodes:
        return {"error": "No scene data available", "suggestions": []}

    ref_pos = _resolve_reference(near, near_position, world_nodes)
    if ref_pos is None:
        target = near or str(near_position)
        return {"error": f"Reference '{target}' not found in scene", "suggestions": []}

    c = _apply_default_constraints(constraints or {})

    marker_suggestions = _find_empty_markers(ref_pos, world_nodes, c, object_type, max_radius=5.0)
    grid_suggestions = _grid_search(ref_pos, world_nodes, c, max_radius=3.0, step=0.5)

    all_suggestions = marker_suggestions + grid_suggestions
    all_suggestions.sort(key=lambda s: -s.confidence)

    scale_info = _calculate_scale(object_type, world_nodes)
    for s in all_suggestions:
        s.scale = scale_info["recommended_scale"]

    if c.get("face_direction"):
        for s in all_suggestions:
            s.rotation_deg = _direction_to_rotation(c["face_direction"])

    final = all_suggestions[:max_suggestions]

    return {
        "suggestions": [_suggestion_to_dict(s) for s in final],
        "excluded_zones": _find_excluded_zones(world_nodes),
        "scale_reasoning": scale_info.get("reasoning", ""),
        "markers_checked": len(marker_suggestions),
        "candidates_evaluated": len(marker_suggestions) + len(grid_suggestions),
        "reference_position": ref_pos,
    }


# ---------------------------------------------------------------------------
# Reference resolution
# ---------------------------------------------------------------------------


def _resolve_reference(
    near: str, near_position: list[float] | None, nodes: list[dict]
) -> list[float] | None:
    if near_position and len(near_position) >= 3:
        return list(near_position[:3])

    if near:
        # Exact match
        for node in nodes:
            if node.get("name", "") == near:
                pos = node.get("position", [0, 0, 0])
                return list(pos[:3]) if len(pos) >= 3 else None
        # Fuzzy match
        near_lower = near.lower()
        for node in nodes:
            if near_lower in node.get("name", "").lower():
                pos = node.get("position", [0, 0, 0])
                return list(pos[:3]) if len(pos) >= 3 else None

    return None


# ---------------------------------------------------------------------------
# Constraints
# ---------------------------------------------------------------------------


def _apply_default_constraints(constraints: dict) -> dict:
    c = dict(constraints)
    c.setdefault("on_floor", True)
    c.setdefault("min_wall_distance", 0.3)
    c.setdefault("min_object_distance", 0.4)
    return c


_WALL_NAME_KEYWORDS = ("wall", "floor", "ceiling", "ground", "barrier")


def _is_wall(node: dict) -> bool:
    node_type = node.get("type", "").lower()
    if "staticbody" in node_type:
        return True
    node_name = node.get("name", "").lower()
    return any(kw in node_name for kw in _WALL_NAME_KEYWORDS)


def _check_constraints(
    pos: list[float], nodes: list[dict], constraints: dict, exclude_name: str = ""
) -> dict:
    blocked = False
    px, py, pz = pos[0], pos[1], pos[2]
    min_wall = constraints.get("min_wall_distance", 0.3)
    min_obj = constraints.get("min_object_distance", 0.4)

    for node in nodes:
        name = node.get("name", "")
        if name == exclude_name:
            continue
        ntype = node.get("type", "")
        npos = node.get("position", [0, 0, 0])
        if len(npos) < 3:
            continue

        dist = math.sqrt((px - npos[0]) ** 2 + (pz - npos[2]) ** 2)

        if _is_wall(node):
            if dist < min_wall:
                blocked = True
                break

        if dist < min_obj and "Marker" not in ntype and "Light" not in ntype:
            blocked = True
            break

    return {"blocked": blocked}


# ---------------------------------------------------------------------------
# Marker search
# ---------------------------------------------------------------------------


def _infer_marker_groups(object_type: str) -> list[str]:
    type_lower = object_type.lower() if object_type else ""
    mapping = {
        "printer": ["printer_slots", "printer_slot"],
        "shelf": ["furniture_shelf_slots", "shelf_slots", "furniture_slots"],
        "furniture": ["furniture_shelf_slots", "furniture_slots"],
        "storage": ["storage_points", "storage_slots"],
        "worker": ["work_points", "worker_slots"],
    }
    for key, groups in mapping.items():
        if key in type_lower:
            return groups
    return []


def _find_empty_markers(
    ref_pos: list[float],
    nodes: list[dict],
    constraints: dict,
    object_type: str,
    max_radius: float,
) -> list[PlacementSuggestion]:
    suggestions: list[PlacementSuggestion] = []
    target_groups = _infer_marker_groups(object_type)

    for node in nodes:
        node_type = node.get("type", "")
        if "Marker" not in node_type:
            continue

        node_name = node.get("name", "")
        node_pos = node.get("position", [0, 0, 0])
        node_groups = node.get("groups", [])
        children_count = node.get("children_count", -1)

        if target_groups:
            if not any(g in node_groups for g in target_groups):
                continue

        if children_count > 0:
            continue

        if len(node_pos) < 3:
            continue

        dist = _distance_3d(ref_pos, node_pos)
        if dist > max_radius:
            continue

        validation = _check_constraints(node_pos, nodes, constraints, node_name)
        if validation["blocked"]:
            continue

        clearance = _calculate_clearance(node_pos, nodes, node_name)
        confidence = round(0.95 - (dist / max_radius * 0.1), 2)

        suggestions.append(PlacementSuggestion(
            position=list(node_pos[:3]),
            rotation_deg=[0, 0, 0],
            scale=[1, 1, 1],
            confidence=confidence,
            reason=f"Empty marker '{node_name}' at {dist:.1f}m from reference. Designer-intended placement slot.",
            clearance=clearance,
            source="marker",
        ))

    return suggestions


# ---------------------------------------------------------------------------
# Grid search
# ---------------------------------------------------------------------------


def _grid_search(
    ref_pos: list[float],
    nodes: list[dict],
    constraints: dict,
    max_radius: float,
    step: float,
) -> list[PlacementSuggestion]:
    suggestions: list[PlacementSuggestion] = []
    rx, ry, rz = ref_pos[0], ref_pos[1], ref_pos[2]

    offsets: list[tuple[float, float, float]] = []
    r = step
    while r <= max_radius:
        for angle_deg in range(0, 360, 45):
            angle_rad = math.radians(angle_deg)
            ox = r * math.cos(angle_rad)
            oz = r * math.sin(angle_rad)
            offsets.append((ox, oz, r))
        r += step

    for ox, oz, dist in offsets:
        candidate = [rx + ox, ry, rz + oz]
        if constraints.get("on_floor", True):
            candidate[1] = 0.0

        validation = _check_constraints(candidate, nodes, constraints)
        if validation["blocked"]:
            continue

        clearance = _calculate_clearance(candidate, nodes)
        avg_clearance = sum(clearance.values()) / 4
        confidence = max(0.3, 0.8 - (dist / max_radius * 0.3) + (min(avg_clearance, 2.0) / 10))
        confidence = min(0.85, confidence)

        suggestions.append(PlacementSuggestion(
            position=[round(c, 2) for c in candidate],
            rotation_deg=[0, 0, 0],
            scale=[1, 1, 1],
            confidence=round(confidence, 2),
            reason=f"Open space at {dist:.1f}m from reference. Clearance: N={clearance['N']:.1f}m S={clearance['S']:.1f}m E={clearance['E']:.1f}m W={clearance['W']:.1f}m",
            clearance=clearance,
            source="grid_search",
        ))

    return suggestions


# ---------------------------------------------------------------------------
# Clearance
# ---------------------------------------------------------------------------


def _calculate_clearance(
    pos: list[float], nodes: list[dict], exclude_name: str = ""
) -> dict[str, float]:
    clearance = {"N": 10.0, "S": 10.0, "E": 10.0, "W": 10.0}
    px, pz = pos[0], pos[2]

    for node in nodes:
        name = node.get("name", "")
        if name == exclude_name:
            continue
        ntype = node.get("type", "")
        node_name_lower = name.lower()
        if not any(k in ntype for k in ("StaticBody", "Mesh", "CSG")):
            if "wall" not in node_name_lower and "floor" not in node_name_lower:
                continue

        npos = node.get("position", [0, 0, 0])
        if len(npos) < 3:
            continue
        nx, nz = npos[0], npos[2]

        dx = nx - px
        dz = nz - pz
        dist = math.sqrt(dx * dx + dz * dz)
        if dist < 0.01:
            continue

        if abs(dx) > abs(dz):
            if dx > 0:
                clearance["E"] = min(clearance["E"], dist)
            else:
                clearance["W"] = min(clearance["W"], dist)
        else:
            if dz > 0:
                clearance["S"] = min(clearance["S"], dist)
            else:
                clearance["N"] = min(clearance["N"], dist)

    return {k: round(v, 2) for k, v in clearance.items()}


# ---------------------------------------------------------------------------
# Scale
# ---------------------------------------------------------------------------


def _calculate_scale(object_type: str, nodes: list[dict]) -> dict:
    type_lower = object_type.lower() if object_type else ""
    if not type_lower:
        return {"recommended_scale": [1, 1, 1], "reasoning": "No object_type specified"}

    similar_scales: list[list[float]] = []
    for node in nodes:
        name = node.get("name", "").lower()
        if type_lower in name:
            scale = node.get("scale")
            if scale and len(scale) >= 3:
                similar_scales.append(list(scale[:3]))

    if not similar_scales:
        return {
            "recommended_scale": [1, 1, 1],
            "reasoning": f"No similar '{object_type}' objects found in scene",
        }

    avg_x = sum(s[0] for s in similar_scales) / len(similar_scales)
    avg_y = sum(s[1] for s in similar_scales) / len(similar_scales)
    avg_z = sum(s[2] for s in similar_scales) / len(similar_scales)
    scale = [round(avg_x, 3), round(avg_y, 3), round(avg_z, 3)]

    return {
        "recommended_scale": scale,
        "reasoning": f"Based on {len(similar_scales)} similar '{object_type}' objects in scene. Average scale: {scale}",
    }


# ---------------------------------------------------------------------------
# Direction / rotation
# ---------------------------------------------------------------------------


def _direction_to_rotation(direction: str) -> list[float]:
    mapping = {
        "north": [0, 0, 0],
        "south": [0, 180, 0],
        "east": [0, 90, 0],
        "west": [0, -90, 0],
        "northeast": [0, 45, 0],
        "northwest": [0, -45, 0],
        "southeast": [0, 135, 0],
        "southwest": [0, -135, 0],
    }
    return mapping.get(direction.lower(), [0, 0, 0])


# ---------------------------------------------------------------------------
# Excluded zones
# ---------------------------------------------------------------------------


def _find_excluded_zones(nodes: list[dict]) -> list[dict]:
    zones: list[dict] = []
    for node in nodes:
        name = node.get("name", "")
        if "LockedZone" in name or "locked_zone" in name.lower():
            pos = node.get("position", [0, 0, 0])
            zones.append({"reason": name, "position": pos})
        elif "Door" in name or "door" in name:
            pos = node.get("position", [0, 0, 0])
            zones.append({"reason": f"Door swing area: {name}", "position": pos, "radius": 0.8})
    return zones


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _distance_3d(pos1: list[float], pos2: list[float]) -> float:
    if len(pos1) < 3 or len(pos2) < 3:
        return float("inf")
    return math.sqrt(
        (pos1[0] - pos2[0]) ** 2
        + (pos1[1] - pos2[1]) ** 2
        + (pos1[2] - pos2[2]) ** 2
    )


def _suggestion_to_dict(s: PlacementSuggestion) -> dict:
    return {
        "position": s.position,
        "rotation_deg": s.rotation_deg,
        "scale": s.scale,
        "confidence": s.confidence,
        "reason": s.reason,
        "clearance": s.clearance,
        "source": s.source,
    }
