"""
converter.py — RobloxModel → Rublox project_data.

Принимает дерево Instance'ов из parser.py и превращает в JSON-схему которую
понимает движок Rublox (студия и плеер).

Маппинг основных классов Roblox → Rublox:
   Part(Shape=Block)        → primitive: cube
   Part(Shape=Ball)         → primitive: sphere
   Part(Shape=Cylinder)     → primitive: cylinder
   WedgePart                → primitive: wedge
   CornerWedgePart          → primitive: cornerwedge
   MeshPart                 → glbModels entry (ссылка на наш сконвертированный glb)
   UnionOperation           → glbModels entry (если есть CSG)
   SpawnLocation            → scene.spawnPoint
   Script/LocalScript       → scripts entry (kind='roblox-lua', raw lua source)
   Lighting                 → scene.environment
   Sound                    → scene.sounds entry
   Folder/Model             → scene.folders entry
   Texture/Decal            → не отдельные объекты, прикрепляются к Part'у

Координаты:
   Roblox: правая Y-up, 1 stud ≈ 0.28 м.
   Rublox/Babylon: правая Y-up, 1 unit = 1 м.
   → масштабируем все координаты на 0.28 (можно настроить через scale_factor).

CFrame → position + rotationX/Y/Z (Euler XYZ в радианах).

Скрипты: сохраняются как kind='roblox-lua' с raw lua_source. Lua-runtime
(asset_proxy + RobloxLuaWorker.js в плеере) исполняет их потом.
"""
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Any, Optional, Tuple
import json
import math
import logging

from rbxl_parser import RobloxModel, Instance
from rbxl_types import (
    CFrame, Color3, Vector3, EnumValue, BrickColor,
    OptionalCFrame, PhysicalProperties,
)

logger = logging.getLogger(__name__)


# ────── константы маппинга ──────

# Roblox stud → unit Rublox-движка.
# R15-персонаж в Rublox ~5.5м, Roblox-персонаж ~5stud высотой. Чтобы карта
# была пропорциональна персонажу — scale 0.35 (платформа 4stud → 1.4 unit,
# как стандартная Rublox-платформа).
DEFAULT_SCALE = 0.35

# Маппинг Material enum → Rublox material strings.
# Roblox Enum.Material:
#   0=Plastic, 256=Plastic (default), 272=Wood, 288=Slate, 304=Concrete,
#   320=CorrodedMetal, 336=DiamondPlate, 352=Foil, 368=Grass, 384=Ice,
#   400=Marble, 416=Granite, 432=Brick, 448=Pebble, 464=Sand, 480=Fabric,
#   496=SmoothPlastic, 512=Metal, 528=WoodPlanks, 784=Neon, 1024=Glass,
#   1280=ForceField, ...
# https://create.roblox.com/docs/reference/engine/enums/Material
ROBLOX_MATERIAL_TO_RUBLOX = {
    0:    'glossy',     # Plastic
    256:  'glossy',     # Plastic (legacy)
    272:  'matte',      # Wood
    288:  'matte',      # Slate
    304:  'matte',      # Concrete
    320:  'metal',      # CorrodedMetal
    336:  'metal',      # DiamondPlate
    352:  'metal',      # Foil
    368:  'matte',      # Grass
    384:  'glass',      # Ice
    400:  'matte',      # Marble
    416:  'matte',      # Granite
    432:  'matte',      # Brick
    448:  'matte',      # Pebble
    464:  'matte',      # Sand
    480:  'matte',      # Fabric
    496:  'glossy',     # SmoothPlastic
    512:  'metal',      # Metal
    528:  'matte',      # WoodPlanks
    784:  'neon',       # Neon
    1024: 'glass',      # Glass
    1280: 'neon',       # ForceField — синий полупрозрачный
    1296: 'matte',      # Cobblestone
    1328: 'metal',      # Aluminum
    1344: 'matte',      # CrackedLava
    1360: 'metal',      # Rubber
    1376: 'matte',      # Pavement
}

# Roblox Part.Shape enum (PartType): 0=Ball, 1=Block, 2=Cylinder, ...
SHAPE_TO_PRIMITIVE = {
    0: 'sphere',     # Ball
    1: 'cube',       # Block
    2: 'cylinder',   # Cylinder
    3: 'cube',       # Wedge — но WedgePart это отдельный класс
    4: 'cornerwedge',
}


# ────── BrickColor таблица (упрощённая) ──────
# Roblox использует old BrickColor enum (числа 1-1032). Только распространённые:
BRICKCOLOR_TO_HEX = {
    # Базовые тона
    1: '#f2f3f3',   2: '#a1a5a2',   3: '#f9e999',   5: '#d9e4f7',
    9: '#9c9e9c',   11: '#e8eaea',  18: '#cc8e69',  21: '#c4281c',
    23: '#0d69ac',  24: '#f5cd30',  26: '#1b2a35',  28: '#293f1a',
    29: '#a3bb71',  37: '#4b9740',  38: '#ab5a32',  101: '#dab8a3',
    102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', 105: '#cf8b3e',
    106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', 111: '#a7a6a6',
    115: '#c7d23c', 116: '#56fff0', 118: '#b4d2e4', 119: '#aac84a',
    120: '#d4f0a6', 123: '#cf6b6f', 124: '#9c54a6', 125: '#e8b486',
    126: '#a6c2e3', 127: '#deb87b', 128: '#a37e5b', 131: '#9ba19d',
    133: '#cc7c39', 134: '#de8b5f', 135: '#74859c', 136: '#876a7a',
    137: '#e6a262', 138: '#8a8a76', 140: '#234770', 141: '#26462b',
    143: '#bdc3e3', 145: '#5c8aa1', 146: '#75718b', 147: '#9a8a64',
    148: '#5a605a', 149: '#1b2a47', 150: '#9ea1a3',
    # ВАЖНО: 151 — Earth green (тёмная трава Crossroads)
    151: '#7c9b53',
    153: '#9b605a', 154: '#7a2d2d', 157: '#f5e09c', 158: '#b58c9c',
    168: '#3c3a37', 176: '#a39989', 178: '#aa724c', 180: '#cc9555',
    190: '#f7b830', 191: '#e69138',
    192: '#5a3019', 193: '#f59d24', 194: '#9c9b91', 195: '#447ba6',
    196: '#283970', 198: '#7b4b85', 199: '#3c3e3f', 200: '#7a854b',
    208: '#dbdcdc', 209: '#a4733f', 210: '#7d8a8e', 211: '#9da3b3',
    212: '#a5cce0', 213: '#6584b5', 215: '#7c8aa4', 216: '#8a5040',
    217: '#7a5443', 218: '#94748a', 219: '#5c5a8a', 220: '#a3a8c4',
    221: '#cc4488', 222: '#e8a8e0', 223: '#dd7790', 224: '#f3e3a5',
    225: '#e8b685', 226: '#fff8a8', 232: '#bce0f0', 268: '#3c2e74',
    301: '#73584b',
    # Бипалитра 1001-1032 — стандартные яркие цвета
    1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000',
    1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00',
    1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff',
    1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0',
    1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80',
    1021: '#80c0ff', 1022: '#80ffff', 1023: '#80ff00', 1024: '#00ff80',
    1025: '#ff4040', 1026: '#8a0028', 1027: '#001f80', 1028: '#4d4d4d',
    1029: '#9d9d9d', 1030: '#5e3923', 1031: '#7a4f30', 1032: '#cca5a5',
}


# ──── вспомогательные функции ────

def cframe_to_pos_rot(cf: Optional[CFrame], scale: float) -> Tuple[Dict, Dict]:
    """Конвертит CFrame в позицию и Euler ротацию.

    Roblox координаты: правая Y-up. Babylon: правая Y-up. То есть совпадают.
    Возвращает ({x,y,z}, {rx,ry,rz}).
    """
    if cf is None:
        return ({'x': 0.0, 'y': 0.0, 'z': 0.0},
                {'rx': 0.0, 'ry': 0.0, 'rz': 0.0})
    pos = {'x': cf.position.x * scale, 'y': cf.position.y * scale, 'z': cf.position.z * scale}
    rx, ry, rz = cf.to_euler_xyz()
    rot = {'rx': rx, 'ry': ry, 'rz': rz}
    return pos, rot


def color3_to_hex(c: Optional[Color3]) -> str:
    if c is None:
        return '#cccccc'
    return c.to_hex()


def brickcolor_to_hex(b: Optional[BrickColor]) -> str:
    if b is None:
        return '#cccccc'
    return BRICKCOLOR_TO_HEX.get(b.code, '#cccccc')


def material_to_string(m: Optional[EnumValue]) -> str:
    if m is None:
        return 'glossy'
    return ROBLOX_MATERIAL_TO_RUBLOX.get(m.value, 'glossy')


def get_part_color(props: Dict[str, Any]) -> str:
    """Достаёт цвет Part: сначала Color3uint8, потом BrickColor, потом дефолт."""
    if 'Color3uint8' in props:
        return color3_to_hex(props['Color3uint8'])
    if 'Color' in props:
        return color3_to_hex(props['Color'])
    if 'BrickColor' in props:
        return brickcolor_to_hex(props['BrickColor'])
    return '#cccccc'


def name_or_default(props: Dict[str, Any], default: str) -> str:
    return str(props.get('Name', default))


# ────── Конвертеры классов ──────


@dataclass
class ConversionStats:
    primitives_created: int = 0
    glb_models_created: int = 0
    scripts_collected: int = 0
    scripts_skipped: int = 0
    parts_dropped: int = 0
    skipped_classes: Dict[str, int] = field(default_factory=dict)
    warnings: List[str] = field(default_factory=list)
    asset_ids_needed: List[int] = field(default_factory=list)  # rbx_asset_id'ы для скачки


class Converter:
    """Главный конвертер. Работает на одной RobloxModel за раз."""

    def __init__(
        self,
        model: RobloxModel,
        scale: float = DEFAULT_SCALE,
        asset_url_resolver=None,    # callable(rbx_asset_id) -> str|None (public URL после скачки)
    ):
        self.model = model
        self.scale = scale
        self.stats = ConversionStats()
        self.asset_url_resolver = asset_url_resolver
        self._next_primitive_id = 1
        self._next_glb_id = 1
        self._next_script_id = 1
        self._instance_to_primitive_id: Dict[int, int] = {}

    # ──── главный entry-point ────

    def convert(self) -> Dict[str, Any]:
        """Возвращает project_data dict готовый к сохранению как JSON."""
        scene = {
            'blocks': [],
            'models': [],
            'primitives': [],
            'userModels': [],
            'terrain': [],
            'robloxTerrain': self._default_roblox_terrain(),
            'decorations': [],
            'folders': [],
            'gui': [],
            'inventory': [],
            'spawnPoint': {'x': 0, 'y': 2, 'z': 0},
            # Стандартный R15-скин bacon-hair как во всех новых проектах студии.
            # 'default' — невалидный typeId, PlayerController на нём падает.
            'playerModelType': 'skin_bacon-hair',
            'worldSize': 100,
            'floorEnabled': True,
            'jumpPowerMul': 1.0,
            'cameraMode': 'thirdPerson',
            'crosshair': 'default',
            'shadowQuality': 'medium',
            'environment': {
                'preset': 'day',
                'timeOfDay': 14,
                'dayDurationMin': 5,
                'nightDurationMin': 3,
                'fogEnabled': False,
                'fogColor': [0.7, 0.8, 0.9],
                'fogDensity': 0.01,
            },
            'audio': {},
            'assets': [],
            'sounds': [],
            'glbModels': [],
            'scripts': [],
            # Команды PvP (Roblox Battle): {id, name, color_hex, auto_assignable}
            'teams': [],
            # Spawn-точки команд (для SpawnLocation.TeamColor)
            'team_spawns': [],  # {team_color_hex, x, y, z}
        }

        # Эвристика для Roblox Battle: Model с именем "TeamBeacon X" →
        # команда X. PvP-карты часто используют этот паттерн вместо Team-инстансов.
        TEAM_BEACON_COLORS = {
            'Black': '#1f1f1f', 'Blue': '#0d69ac', 'Red': '#c4281c',
            'Green': '#4b9740', 'White': '#f2f3f3', 'Yellow': '#f5cd30',
            'Orange': '#d97e29', 'Purple': '#6b327a',
        }
        for inst in self.model.instances:
            name = inst.properties.get('Name', '')
            if (inst.class_name == 'Model' and isinstance(name, str)
                    and name.startswith('TeamBeacon ')):
                team_name = name.replace('TeamBeacon ', '').strip()
                color = TEAM_BEACON_COLORS.get(team_name, '#cccccc')
                scene['teams'].append({
                    'id': f'team_{len(scene["teams"]) + 1}',
                    'name': team_name,
                    'color_hex': color,
                    'auto_assignable': True,
                })

        # Обходим все instances и конвертим
        for inst in self.model.instances:
            self._convert_one(inst, scene)

        # Spawn fallback: если SpawnLocation в карте НЕ был (или дефолт 0,2,0
        # остался) — поднимаем выше самой высокой Part'ы. Иначе игрок
        # появляется внутри Anchored=True геометрии и не может двигаться.
        sp = scene.get('spawnPoint', {'x': 0, 'y': 2, 'z': 0})
        if sp.get('x') == 0 and sp.get('y') == 2 and sp.get('z') == 0:
            prims = scene.get('primitives', [])
            if prims:
                max_top = max(
                    (p['y'] + p.get('sy', 1) / 2) for p in prims
                    if isinstance(p.get('y'), (int, float))
                )
                scene['spawnPoint'] = {'x': 0, 'y': max_top + 5, 'z': 0}

        # Финальный отчёт о скипнутых классах
        for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]:
            self.stats.warnings.append(f"skipped {n}× {cls}")

        return {
            'version': 1,
            'scene': scene,
            'editorCamera': {'x': 20, 'y': 15, 'z': 20, 'targetX': 0, 'targetY': 0, 'targetZ': 0},
            'settings': {
                'isGd': False,
                'importedFrom': 'roblox',
                'importStats': asdict(self.stats),
            },
        }

    # ──── per-class конвертеры ────

    def _convert_one(self, inst: Instance, scene: Dict) -> None:
        cls = inst.class_name
        try:
            if cls == 'Part':
                self._convert_part(inst, scene)
            elif cls == 'WedgePart':
                self._convert_wedge(inst, scene)
            elif cls == 'CornerWedgePart':
                self._convert_cornerwedge(inst, scene)
            elif cls == 'TrussPart':
                self._convert_truss(inst, scene)
            elif cls == 'MeshPart':
                self._convert_meshpart(inst, scene)
            elif cls == 'UnionOperation':
                self._convert_union(inst, scene)
            elif cls == 'SpecialMesh':
                # SpecialMesh — child объект Part'а, меняет визуал родителя
                # Обработка делается в _convert_part через children, тут не нужно
                pass
            elif cls == 'SpawnLocation':
                self._convert_spawn(inst, scene)
            elif cls == 'Script' or cls == 'LocalScript' or cls == 'ModuleScript':
                self._convert_script(inst, scene)
            elif cls == 'Sound':
                self._convert_sound(inst, scene)
            elif cls == 'PointLight' or cls == 'SpotLight' or cls == 'SurfaceLight':
                self._convert_light(inst, scene)
            elif cls == 'Folder' or cls == 'Model':
                self._convert_folder(inst, scene)
            elif cls in ('ScreenGui', 'BillboardGui', 'SurfaceGui'):
                # Контейнер — сам по себе ничего не рендерит, дети идут в scene.gui
                # как top-level элементы с parentId=None.
                self._convert_screen_gui(inst, scene)
            elif cls in ('Frame', 'ScrollingFrame', 'TextLabel', 'TextButton',
                         'ImageLabel', 'ImageButton', 'TextBox'):
                self._convert_gui_element(inst, scene)
            elif cls in ('Decal', 'Texture'):
                # Прикрепляются к Part'у — обрабатываются при конверте родителя
                pass
            elif cls == 'Lighting':
                self._convert_lighting(inst, scene)
            elif cls == 'Workspace':
                # Workspace = root, его свойства мапим на scene.worldSize и т.п.
                pass
            elif cls == 'Team':
                # PvP-команда: имя + цвет в scene.teams[].
                self._convert_team(inst, scene)
            elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
                         'StarterPack', 'StarterCharacterScripts', 'Players',
                         'Teams',
                         'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
                         'SoundService', 'TweenService', 'RunService',
                         'UserInputService', 'HttpService', 'DataStoreService',
                         'TeleportService', 'BadgeService', 'MarketplaceService',
                         'ContentProvider', 'NetworkClient', 'NetworkServer',
                         'Chat', 'Stats', 'Debris', 'AnalyticsService',
                         'CSGDictionaryService', 'NonReplicatedCSGDictionaryService'):
                # Системные сервисы — игнорируем (Lua-runtime создаст mock'и)
                pass
            else:
                self.stats.skipped_classes[cls] = self.stats.skipped_classes.get(cls, 0) + 1
        except Exception as e:
            self.stats.warnings.append(f"convert {cls} (referent={inst.referent}) failed: {e!r}")
            self.stats.parts_dropped += 1

    # ─── Part / WedgePart / etc ───

    def _convert_part(self, inst: Instance, scene: Dict) -> None:
        props = inst.properties
        cf = props.get('CFrame')
        size = props.get('size') or props.get('Size')
        pos, rot = cframe_to_pos_rot(cf, self.scale)

        # Roblox Part.Shape: 0=Ball, 1=Block, 2=Cylinder
        # PartType enum value — берётся из props.get('shape') или props.get('Shape')
        shape = props.get('Shape')
        if isinstance(shape, EnumValue):
            ptype = SHAPE_TO_PRIMITIVE.get(shape.value, 'cube')
        else:
            ptype = 'cube'

        # Размер
        if size:
            sx = abs(size.x) * self.scale
            sy = abs(size.y) * self.scale
            sz = abs(size.z) * self.scale
        else:
            sx = sy = sz = 1.0 * self.scale

        # Проверяем есть ли child SpecialMesh — он переопределяет визуал
        for child in inst.children:
            if child.class_name == 'SpecialMesh':
                mesh_type = child.properties.get('MeshType')
                if isinstance(mesh_type, EnumValue):
                    # MeshType: 0=Head, 1=Torso, 2=Wedge, 3=Sphere, 4=Cylinder, 5=FileMesh, 6=Brick
                    mt = mesh_type.value
                    if mt == 3: ptype = 'sphere'
                    elif mt == 4: ptype = 'cylinder'

        prim_id = self._next_primitive_id
        self._next_primitive_id += 1
        self._instance_to_primitive_id[inst.referent] = prim_id

        primitive = {
            'id': prim_id,
            'type': ptype,
            'name': name_or_default(props, 'Part'),
            'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
            'sx': sx, 'sy': sy, 'sz': sz,
            'color': get_part_color(props),
            'material': material_to_string(props.get('Material')),
            'canCollide': bool(props.get('CanCollide', True)),
            'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else True,
            'opacity': max(0.0, 1.0 - (props.get('Transparency', 0) or 0)),
            # FORCE-ANCHORED — Welds импортируем как заглушки, без них
            # физика 700+ unanchored Part'ов = карта рассыпается.
            'anchored': True,
            'mass': 1.0,
            'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
        }
        scene['primitives'].append(primitive)
        self.stats.primitives_created += 1

    def _convert_wedge(self, inst: Instance, scene: Dict) -> None:
        props = inst.properties
        cf = props.get('CFrame')
        size = props.get('size') or props.get('Size')
        pos, rot = cframe_to_pos_rot(cf, self.scale)

        if size:
            sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
        else:
            sx = sy = sz = 1.0

        prim_id = self._next_primitive_id
        self._next_primitive_id += 1
        self._instance_to_primitive_id[inst.referent] = prim_id

        scene['primitives'].append({
            'id': prim_id, 'type': 'wedge',
            'name': name_or_default(props, 'Wedge'),
            'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
            'sx': sx, 'sy': sy, 'sz': sz,
            'color': get_part_color(props),
            'material': material_to_string(props.get('Material')),
            'canCollide': bool(props.get('CanCollide', True)),
            'visible': True,
            # FORCE-ANCHORED — Welds импортируем как заглушки, без них
            # физика 700+ unanchored Part'ов = карта рассыпается.
            'anchored': True,
            'mass': 1.0,
            'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
        })
        self.stats.primitives_created += 1

    def _convert_cornerwedge(self, inst: Instance, scene: Dict) -> None:
        props = inst.properties
        cf = props.get('CFrame')
        size = props.get('size') or props.get('Size')
        pos, rot = cframe_to_pos_rot(cf, self.scale)
        if size:
            sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
        else:
            sx = sy = sz = 1.0

        prim_id = self._next_primitive_id
        self._next_primitive_id += 1
        self._instance_to_primitive_id[inst.referent] = prim_id

        scene['primitives'].append({
            'id': prim_id, 'type': 'cornerwedge',
            'name': name_or_default(props, 'CornerWedge'),
            'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
            'sx': sx, 'sy': sy, 'sz': sz,
            'color': get_part_color(props),
            'material': material_to_string(props.get('Material')),
            'canCollide': bool(props.get('CanCollide', True)),
            'visible': True,
            # FORCE-ANCHORED — Welds импортируем как заглушки, без них
            # физика 700+ unanchored Part'ов = карта рассыпается.
            'anchored': True,
            'mass': 1.0,
            'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
        })
        self.stats.primitives_created += 1

    def _convert_truss(self, inst: Instance, scene: Dict) -> None:
        """TrussPart — Roblox-специфичный леса. Конвертим в cube с пометкой."""
        props = inst.properties
        cf = props.get('CFrame')
        size = props.get('size') or props.get('Size')
        pos, rot = cframe_to_pos_rot(cf, self.scale)
        if size:
            sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
        else:
            sx, sy, sz = 0.5, 5.0, 0.5

        prim_id = self._next_primitive_id
        self._next_primitive_id += 1
        self._instance_to_primitive_id[inst.referent] = prim_id

        scene['primitives'].append({
            'id': prim_id, 'type': 'cube',
            'name': name_or_default(props, 'Truss'),
            'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
            'sx': sx, 'sy': sy, 'sz': sz,
            'color': get_part_color(props),
            'material': 'metal',
            'canCollide': True,
            'visible': True,
            'anchored': bool(props.get('Anchored', True)),
            'mass': 1.0,
            'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
        })
        self.stats.primitives_created += 1

    # ─── MeshPart / UnionOperation ───

    def _convert_meshpart(self, inst: Instance, scene: Dict) -> None:
        """MeshPart → glbModels entry с ссылкой на сконвертированный GLB."""
        props = inst.properties
        cf = props.get('CFrame')
        size = props.get('size') or props.get('Size')
        pos, rot = cframe_to_pos_rot(cf, self.scale)

        # MeshId — это rbxassetid:// строка
        mesh_id_str = props.get('MeshID') or props.get('MeshId') or ''
        rbx_id = self._parse_asset_id(mesh_id_str)
        if rbx_id:
            self.stats.asset_ids_needed.append(rbx_id)

        glb_url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None

        if size:
            sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
        else:
            sx = sy = sz = 1.0

        # Если GLB не доступен — fallback на bbox cube
        if not glb_url:
            prim_id = self._next_primitive_id
            self._next_primitive_id += 1
            self._instance_to_primitive_id[inst.referent] = prim_id
            scene['primitives'].append({
                'id': prim_id, 'type': 'cube',
                'name': name_or_default(props, 'MeshPart'),
                'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
                'sx': sx, 'sy': sy, 'sz': sz,
                'color': get_part_color(props),
                'material': material_to_string(props.get('Material')),
                'canCollide': bool(props.get('CanCollide', True)),
                'visible': True,
                # FORCE-ANCHORED — Welds импортируем как заглушки, без них
            # физика 700+ unanchored Part'ов = карта рассыпается.
            'anchored': True,
                'mass': 1.0,
                'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
                'note': f'MeshPart (no GLB) rbxid={rbx_id}',
            })
            self.stats.primitives_created += 1
            self.stats.parts_dropped += 1
            return

        glb_id = self._next_glb_id
        self._next_glb_id += 1

        scene['glbModels'].append({
            'id': glb_id,
            'name': name_or_default(props, 'MeshPart'),
            'url': glb_url,
            'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
            'sx': sx, 'sy': sy, 'sz': sz,
            'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
            'color': get_part_color(props),
            'canCollide': bool(props.get('CanCollide', True)),
            # FORCE-ANCHORED — Welds импортируем как заглушки, без них
            # физика 700+ unanchored Part'ов = карта рассыпается.
            'anchored': True,
            'origin': 'roblox-meshpart',
            'rbxAssetId': rbx_id,
        })
        self.stats.glb_models_created += 1

    def _convert_union(self, inst: Instance, scene: Dict) -> None:
        """UnionOperation — CSG объединение. Берём AssetId если есть, иначе bbox."""
        props = inst.properties
        cf = props.get('CFrame')
        size = props.get('size') or props.get('Size')
        pos, rot = cframe_to_pos_rot(cf, self.scale)

        if size:
            sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
        else:
            sx = sy = sz = 1.0

        # AssetId Union'а — это его CSG-mesh
        asset_id_str = props.get('AssetId') or ''
        rbx_id = self._parse_asset_id(asset_id_str)
        if rbx_id:
            self.stats.asset_ids_needed.append(rbx_id)

        glb_url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None

        if not glb_url:
            # Fallback: cube
            prim_id = self._next_primitive_id
            self._next_primitive_id += 1
            self._instance_to_primitive_id[inst.referent] = prim_id
            scene['primitives'].append({
                'id': prim_id, 'type': 'cube',
                'name': name_or_default(props, 'Union'),
                'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
                'sx': sx, 'sy': sy, 'sz': sz,
                'color': get_part_color(props),
                'material': material_to_string(props.get('Material')),
                'canCollide': bool(props.get('CanCollide', True)),
                'visible': True,
                # FORCE-ANCHORED — Welds импортируем как заглушки, без них
            # физика 700+ unanchored Part'ов = карта рассыпается.
            'anchored': True,
                'mass': 1.0,
                'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
                'note': f'Union (no CSG GLB) rbxid={rbx_id}',
            })
            self.stats.primitives_created += 1
            return

        glb_id = self._next_glb_id
        self._next_glb_id += 1
        scene['glbModels'].append({
            'id': glb_id,
            'name': name_or_default(props, 'Union'),
            'url': glb_url,
            'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
            'sx': sx, 'sy': sy, 'sz': sz,
            'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
            'color': get_part_color(props),
            'canCollide': bool(props.get('CanCollide', True)),
            # FORCE-ANCHORED — Welds импортируем как заглушки, без них
            # физика 700+ unanchored Part'ов = карта рассыпается.
            'anchored': True,
            'origin': 'roblox-union',
            'rbxAssetId': rbx_id,
        })
        self.stats.glb_models_created += 1

    # ─── Spawn ───

    def _convert_team(self, inst: Instance, scene: Dict) -> None:
        """Roblox Team → scene.teams[]."""
        props = inst.properties
        name = str(props.get('Name', 'Team'))
        # TeamColor — BrickColor код, мапим в hex через существующую таблицу
        team_color = props.get('TeamColor')
        color_hex = '#ffffff'
        if isinstance(team_color, BrickColor):
            color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc')
        scene['teams'].append({
            'id': f'team_{len(scene["teams"]) + 1}',
            'name': name,
            'color_hex': color_hex,
            'auto_assignable': bool(props.get('AutoAssignable', True)),
        })

    def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
        props = inst.properties
        cf = props.get('CFrame')
        pos, _ = cframe_to_pos_rot(cf, self.scale)

        # TeamColor (если есть) → spawn для команды.
        team_color = props.get('TeamColor')
        if isinstance(team_color, BrickColor):
            color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc')
            scene['team_spawns'].append({
                'team_color_hex': color_hex,
                'x': pos['x'], 'y': pos['y'] + 1.5, 'z': pos['z'],
                'neutral': not bool(props.get('Neutral', True)) and team_color.code != 0,
            })

        # Spawn должен быть значительно выше — старые Roblox-карты часто имеют
        # толстый Floor выше плиты, юзер появляется внутри стены/пола если
        # не дать запас. +5 единиц достаточно — гравитация уронит на пол.
        scene['spawnPoint'] = {
            'x': pos['x'],
            'y': pos['y'] + 5,
            'z': pos['z'],
        }

    # ─── Scripts ───

    def _convert_script(self, inst: Instance, scene: Dict) -> None:
        """Lua-script сохраняем raw с пометкой kind='roblox-lua'.

        Plus, attached to конкретному примитиву если у скрипта есть Parent
        с известным referent.
        """
        props = inst.properties
        source = props.get('Source', '')
        if not source or not isinstance(source, str):
            self.stats.scripts_skipped += 1
            return

        # Цель — primitive id предка если есть
        target = None
        if inst.parent_referent and inst.parent_referent in self._instance_to_primitive_id:
            target = self._instance_to_primitive_id[inst.parent_referent]

        script_id = f'rbx_{self._next_script_id}'
        self._next_script_id += 1

        # storys-нормализатор сохраняет только {id,code,target,name} —
        # поэтому упаковываем kind+lua_source ВНУТРЬ поля code как комментарий-маркер,
        # а GameRuntime/RobloxLuaSandbox распакуют обратно.
        # Маркер: первая строка ровно "// @roblox-lua" + JSON-метадата на 2-й строке.
        marker_header = '// @roblox-lua\n// ' + json.dumps({
            'roblox_class': inst.class_name,
            'enabled': bool(props.get('Disabled', False) is False),
        }, ensure_ascii=False) + '\n/* lua_source:\n'
        marker_footer = '\n*/\n'
        packed_code = marker_header + source + marker_footer
        scene['scripts'].append({
            'id': script_id,
            'name': name_or_default(props, inst.class_name),
            'target': target,
            'code': packed_code,
        })
        self.stats.scripts_collected += 1

    # ─── Sound ───

    def _convert_sound(self, inst: Instance, scene: Dict) -> None:
        props = inst.properties
        sound_id_str = props.get('SoundId') or ''
        rbx_id = self._parse_asset_id(sound_id_str)
        if rbx_id:
            self.stats.asset_ids_needed.append(rbx_id)

        url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None
        scene['sounds'].append({
            'id': f'sound_{rbx_id or inst.referent}',
            'name': name_or_default(props, 'Sound'),
            'url': url or '',
            'volume': float(props.get('Volume', 1.0) or 1.0),
            'loop': bool(props.get('Looped', False)),
            'autoplay': bool(props.get('Playing', False)),
            'rbxAssetId': rbx_id,
        })

    # ─── Light ───

    def _convert_light(self, inst: Instance, scene: Dict) -> None:
        """Roblox PointLight / SpotLight / SurfaceLight → primitive type='light'."""
        props = inst.properties
        # Position берём от parent Part (если есть)
        x = y = z = 0.0
        parent = self.model.by_referent.get(inst.parent_referent) if inst.parent_referent else None
        if parent and 'CFrame' in parent.properties:
            pcf = parent.properties['CFrame']
            x, y, z = pcf.position.x * self.scale, pcf.position.y * self.scale, pcf.position.z * self.scale

        prim_id = self._next_primitive_id
        self._next_primitive_id += 1

        scene['primitives'].append({
            'id': prim_id, 'type': 'light',
            'name': name_or_default(props, inst.class_name),
            'x': x, 'y': y, 'z': z,
            'sx': 1, 'sy': 1, 'sz': 1,
            'color': color3_to_hex(props.get('Color')),
            'material': 'glossy',
            'canCollide': False,
            'visible': True,
            'anchored': True,
            'mass': 0,
            'rotationX': 0, 'rotationY': 0, 'rotationZ': 0,
            'lightRange': float(props.get('Range', 8.0) or 8.0) * self.scale,
            'lightBrightness': float(props.get('Brightness', 1.0) or 1.0),
        })
        self.stats.primitives_created += 1

    # ─── Folder / Model ───

    def _convert_folder(self, inst: Instance, scene: Dict) -> None:
        props = inst.properties
        scene['folders'].append({
            'id': f'rbx_folder_{inst.referent}',
            'name': name_or_default(props, inst.class_name),
            'parent': inst.parent_referent,
            'origin': 'roblox-' + inst.class_name.lower(),
        })

    # ─── GUI ───

    def _convert_screen_gui(self, inst: Instance, scene: Dict) -> None:
        # ScreenGui сам не рендерится — он контейнер. Дети получают parentId=None
        # при конверте. Сохраняем referent чтобы _gui_parent_id() видел.
        # Также сохраняем Enabled-свойство: если ScreenGui.Enabled=false →
        # все дети должны быть скрыты (Roblox прячет всю иерархию).
        if not hasattr(self, '_screen_gui_refs'):
            self._screen_gui_refs = set()
            self._screen_gui_enabled = {}
            self._screen_gui_kind = {}  # ref → 'screen' | 'billboard' | 'surface'
        self._screen_gui_refs.add(inst.referent)
        enabled = inst.properties.get('Enabled', True)
        self._screen_gui_enabled[inst.referent] = bool(enabled) if enabled is not None else True
        # Сохраняем тип контейнера — потом отфильтруем 3D-GUI если выбрано screen-only
        kind = {'ScreenGui': 'screen', 'BillboardGui': 'billboard', 'SurfaceGui': 'surface'}.get(inst.class_name, 'screen')
        self._screen_gui_kind[inst.referent] = kind

    def _gui_parent_id(self, parent_ref) -> Optional[str]:
        if parent_ref is None:
            return None
        if hasattr(self, '_screen_gui_refs') and parent_ref in self._screen_gui_refs:
            return None  # top-level в ScreenGui = parentId=None в Rublox
        return f'rbx_gui_{parent_ref}'

    def _udim_to_percent(self, udim, axis: str = 'x') -> float:
        """Roblox UDim(scale, offset) → процент (0..100) для Rublox GUI.
        Rublox использует проценты от viewport. Конвертация:
          - scale (0..1) → scale * 100
          - offset (px) → offset / viewport_size * 100 (1280×720 reference)
        """
        if udim is None:
            return 0.0
        ref = 1280.0 if axis == 'x' else 720.0
        if isinstance(udim, dict):
            scale = udim.get('scale', 0) or 0
            offset = udim.get('offset', 0) or 0
        else:
            scale = getattr(udim, 'scale', 0) or 0
            offset = getattr(udim, 'offset', 0) or 0
        return scale * 100.0 + (offset / ref) * 100.0

    def _udim2_pair(self, udim2) -> Tuple[float, float]:
        """UDim2 → (x%, y%). Поддерживает dataclass UDim2 и dict."""
        if udim2 is None:
            return (0.0, 0.0)
        if isinstance(udim2, dict):
            x_obj = udim2.get('x')
            y_obj = udim2.get('y')
        else:
            x_obj = getattr(udim2, 'x', None)
            y_obj = getattr(udim2, 'y', None)
        return (self._udim_to_percent(x_obj, 'x'), self._udim_to_percent(y_obj, 'y'))

    def _color3_to_hex(self, c3) -> str:
        if c3 is None:
            return '#ffffff'
        try:
            if hasattr(c3, 'to_hex'):
                return c3.to_hex()
            r = int(round(getattr(c3, 'r', 1) * 255))
            g = int(round(getattr(c3, 'g', 1) * 255))
            b = int(round(getattr(c3, 'b', 1) * 255))
            return f'#{r:02x}{g:02x}{b:02x}'
        except Exception:
            return '#ffffff'

    def _convert_gui_element(self, inst: Instance, scene: Dict) -> None:
        props = inst.properties
        cls = inst.class_name
        # type-маппинг Roblox → Rublox GUI
        if cls in ('Frame', 'ScrollingFrame'):
            r_type = 'frame'
        elif cls == 'TextLabel':
            r_type = 'text'
        elif cls in ('TextButton', 'ImageButton'):
            r_type = 'button'
        elif cls == 'ImageLabel':
            r_type = 'image'
        elif cls == 'TextBox':
            r_type = 'textbox'
        else:
            r_type = 'frame'

        pos_x, pos_y = self._udim2_pair(props.get('Position'))
        size_x, size_y = self._udim2_pair(props.get('Size'))
        # В процентах. Если размер не указан — дефолт 20%×10%.
        if size_x <= 0: size_x = 20.0
        if size_y <= 0: size_y = 10.0
        # Округляем до 0.1% для читаемости JSON
        pos_x = round(pos_x, 2)
        pos_y = round(pos_y, 2)
        size_x = round(size_x, 2)
        size_y = round(size_y, 2)

        # Фильтр Roblox CDN URL'ов: rbxasset://, rbxassetid://, rbxhttp:// —
        # браузер их не поймёт, даём пустую строку. В будущем asset_downloader
        # может подменить на cached_url.
        raw_image = str(props.get('Image', '') or '')
        if raw_image.startswith(('rbxasset://', 'rbxassetid://', 'rbxhttp://', 'rbxthumb://')):
            raw_image = ''

        # Видимость: если родитель — ScreenGui.Enabled=false, скрываем весь элемент.
        own_visible = props.get('Visible', True)
        if own_visible is None:
            own_visible = True
        # Поднимаемся по родителям пока не найдём ScreenGui — если он Disabled,
        # элемент тоже невидим.
        parent_ref = inst.parent_referent
        screen_enabled = True
        container_kind = 'screen'  # default
        if hasattr(self, '_screen_gui_refs'):
            cur = parent_ref
            depth = 0
            while cur is not None and depth < 50:
                if cur in self._screen_gui_refs:
                    screen_enabled = self._screen_gui_enabled.get(cur, True)
                    container_kind = self._screen_gui_kind.get(cur, 'screen')
                    break
                # Поиск родителя cur в instances (если есть)
                cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None
                cur = cur_inst.parent_referent if cur_inst else None
                depth += 1
        effective_visible = bool(own_visible) and screen_enabled

        # Эвристика: HDAdmin/Chat/Leaderboard модалки в Roblox показываются
        # отдельными Lua-скриптами по триггеру. Без работающих скриптов они
        # показываются ВСЕ сразу и наслаиваются. Скрываем их по имени.
        gui_name = name_or_default(props, cls)
        ADMIN_HIDDEN = ('HDAdmin', 'Cmdbar', 'Console', 'TeleportTo',
                        'Notifications', 'Settings', 'Promo', 'PlayerList',
                        'BanList', 'Admin', 'CommandBar')
        # Поднимаемся по родителям проверяя их имена
        cur = inst.parent_referent
        depth = 0
        while cur is not None and depth < 10:
            cur_inst = self.model.by_referent.get(cur)
            if not cur_inst: break
            pn = cur_inst.properties.get('Name') or cur_inst.class_name
            if any(h.lower() in str(pn).lower() for h in ADMIN_HIDDEN):
                effective_visible = False
                break
            cur = cur_inst.parent_referent
            depth += 1

        element = {
            'id': f'rbx_gui_{inst.referent}',
            'type': r_type,
            'name': name_or_default(props, cls),
            'parentId': self._gui_parent_id(inst.parent_referent),
            'x': pos_x,
            'y': pos_y,
            'w': size_x,
            'h': size_y,
            'anchor': 'top-left',  # Roblox по умолчанию top-left
            'visible': effective_visible,
            'bgColor': self._color3_to_hex(props.get('BackgroundColor3')),
            'bgOpacity': max(0.0, min(1.0, 1.0 - float(props.get('BackgroundTransparency', 0) or 0))),
            'borderColor': self._color3_to_hex(props.get('BorderColor3')),
            'borderWidth': int(props.get('BorderSizePixel', 0) or 0),
            'borderRadius': 0,
            'text': str(props.get('Text', '') or ''),
            'textColor': self._color3_to_hex(props.get('TextColor3')),
            'textSize': int(props.get('TextSize', 14) or 14),
            'textAlign': 'center',
            'fontWeight': 700 if cls in ('TextButton',) else 500,
            'imageUrl': raw_image,
            'imageAsset': None,
            'zIndex': int(props.get('ZIndex', 1) or 1),
            'origin': 'roblox-' + cls.lower(),
            # 'screen' — обычный HUD; 'billboard' — 3D-табличка над частью;
            # 'surface' — на грани Part. Last 2 рендерятся в 3D-сцене и
            # сильно тормозят если их сотни.
            'gui_container_kind': container_kind,
        }
        scene['gui'].append(element)

    # ─── Lighting ───

    def _convert_lighting(self, inst: Instance, scene: Dict) -> None:
        """Roblox Lighting service — мапим на scene.environment."""
        props = inst.properties
        env = scene['environment']

        ambient = props.get('Ambient')
        if isinstance(ambient, Color3):
            env['ambientColor'] = ambient.to_hex()
        brightness = props.get('Brightness')
        if isinstance(brightness, (int, float)):
            env['brightness'] = float(brightness)
        clock = props.get('ClockTime')
        if isinstance(clock, (int, float)):
            env['timeOfDay'] = float(clock)
        fog_color = props.get('FogColor')
        if isinstance(fog_color, Color3):
            env['fogColor'] = [fog_color.r, fog_color.g, fog_color.b]
        fog_end = props.get('FogEnd')
        if isinstance(fog_end, (int, float)) and fog_end < 999999:
            env['fogEnabled'] = True
            env['fogDensity'] = 1.0 / max(fog_end, 1.0)

    # ─── utility ───

    def _default_roblox_terrain(self) -> Dict:
        """Минимальный плоский ландшафт чтобы импорт не падал.

        Real Roblox terrain потребует отдельной конвертации (voxel grid)
        — оставлю на потом.
        """
        return {
            'format': 'robloxterrain-v1',
            'origin': {'x': -22, 'y': 0, 'z': -22},
            'size': {'x': 44, 'y': 24, 'z': 44},
            'palette': ['', 'grass', 'rock', 'sand'],
            'mat': '',
            'density': '',
        }

    def _parse_asset_id(self, s: Any) -> Optional[int]:
        """rbxassetid://12345 или rbxasset://12345 или просто 12345 → int."""
        if not s:
            return None
        s = str(s).strip()
        if not s:
            return None
        if s.startswith('rbxassetid://'):
            s = s[len('rbxassetid://'):]
        elif s.startswith('rbxasset://'):
            s = s[len('rbxasset://'):]
        elif s.startswith('http://www.roblox.com/asset/?id='):
            s = s.split('=')[-1]
        try:
            return int(s)
        except ValueError:
            return None


# ────── CLI ──────

if __name__ == '__main__':
    import sys
    import json
    sys.path.insert(0, '/opt/rbxl-importer/src')
    from rbxl_parser import parse

    path = sys.argv[1] if len(sys.argv) > 1 else '/opt/rbxl-importer/reference_files/easy_obby.rbxl'
    out = sys.argv[2] if len(sys.argv) > 2 else '/tmp/converted.json'

    with open(path, 'rb') as f:
        blob = f.read()
    logging.basicConfig(level=logging.INFO)
    print(f'parsing {path} ({len(blob)} bytes)...')
    model = parse(blob)
    print(f'  → {model.instance_count} instances')

    print(f'\nconverting...')
    conv = Converter(model)
    project_data = conv.convert()

    with open(out, 'w', encoding='utf-8') as f:
        json.dump(project_data, f, ensure_ascii=False, indent=2)
    print(f'\nwritten to {out} ({sum(1 for _ in open(out))} lines)')

    print('\n=== stats ===')
    for k, v in asdict(conv.stats).items():
        if k == 'warnings' and isinstance(v, list):
            print(f'  warnings: {len(v)} total')
            for w in v[:5]:
                print(f'    - {w}')
        elif k == 'skipped_classes':
            top = sorted(v.items(), key=lambda x: -x[1])[:10]
            print(f'  skipped_classes (top 10):')
            for c, n in top:
                print(f'    {n:>4d}  {c}')
        elif k == 'asset_ids_needed':
            print(f'  asset_ids_needed: {len(v)} (deduped: {len(set(v))})')
        else:
            print(f'  {k}: {v}')
