# Top-level orchestration. Walks transforms, builds hierarchy in glTF nodes,
# extracts meshes/materials, copies/embeds textures, writes .gltf or .glb,
# returns a structured report.

import base64
import json
import math
import os
import re
import struct
import traceback
from urllib.parse import unquote

from maya import cmds
from maya.api import OpenMaya as om
import maya.utils

from .. import settings
from . import scene_scan
from . import mesh_extract
from . import material_utils
from . import texture_utils
from . import light_extract
from . import validators
from .gltf_writer import GltfWriter


_COMPONENT_FORMATS = {
    settings.COMPONENT_TYPE_BYTE: ("b", 1, True),
    settings.COMPONENT_TYPE_UNSIGNED_BYTE: ("B", 1, False),
    settings.COMPONENT_TYPE_SHORT: ("h", 2, True),
    settings.COMPONENT_TYPE_UNSIGNED_SHORT: ("H", 2, False),
    settings.COMPONENT_TYPE_UNSIGNED_INT: ("I", 4, False),
    settings.COMPONENT_TYPE_FLOAT: ("f", 4, True),
}

_TYPE_WIDTHS = {
    settings.TYPE_SCALAR: 1,
    settings.TYPE_VEC2: 2,
    settings.TYPE_VEC3: 3,
    settings.TYPE_VEC4: 4,
    "MAT2": 4,
    "MAT3": 9,
    settings.TYPE_MAT4: 16,
}


def _read_matrix(transform, world=False):
    name = transform.split("|")[-1].split(":")[-1]
    attr = "worldMatrix[0]" if world else "matrix"
    try:
        value = cmds.getAttr("{0}.{1}".format(transform, attr))
        matrix = list(value)
    except Exception:
        matrix = [
            1.0, 0.0, 0.0, 0.0,
            0.0, 1.0, 0.0, 0.0,
            0.0, 0.0, 1.0, 0.0,
            0.0, 0.0, 0.0, 1.0,
        ]
    return {"name": name, "matrix": matrix}


class GLTFExporter(object):

    def __init__(self, options, progress_cb=None, log_cb=None):
        merged = dict(settings.DEFAULT_OPTIONS)
        if options:
            merged.update(options)
        self.options = merged
        self.warnings = []
        self.progress_cb = progress_cb
        self.log_cb = log_cb
        self._cancelled = False
        self._tex_index = {}
        self._tex_index_built = False
        self._tex_roots = []
        self._texture_cache = {}

    def cancel(self):
        self._cancelled = True

    def _log(self, msg):
        try:
            if self.log_cb:
                self.log_cb(msg)
        except Exception:
            pass

    def _progress(self, pct, msg=""):
        try:
            if self.progress_cb:
                self.progress_cb(int(pct), msg)
        except Exception:
            pass

    def _maya_call(self, func, *args, **kwargs):
        try:
            if maya.utils.isRunningInMainThread():
                return func(*args, **kwargs)
        except Exception:
            pass

        def _invoke():
            return func(*args, **kwargs)

        return maya.utils.executeInMainThreadWithResult(_invoke)

    # ---------- texture intake ----------

    def _texture_search_roots(self, out_dir):
        # Conservative folders to scan when a texture's stored path is stale.
        # Whole projects and parent folders can contain millions of files.
        roots = []
        try:
            scene = cmds.file(query=True, sceneName=True)
            if scene:
                sdir = os.path.dirname(scene)
                roots.append(sdir)
        except Exception:
            pass
        try:
            ws = cmds.workspace(query=True, rootDirectory=True)
            if ws:
                roots.append(os.path.join(ws, "sourceimages"))
        except Exception:
            pass
        if out_dir:
            roots.append(out_dir)
        roots.extend(self.options.get("texture_search_dirs", []) or [])
        seen = set()
        uniq = []
        for r in roots:
            try:
                if r and os.path.isdir(r):
                    key = os.path.normcase(os.path.abspath(r))
                    if key not in seen:
                        seen.add(key)
                        uniq.append(r)
            except Exception:
                pass
        return uniq

    def _resolve_texture_path(self, source_path):
        # Most authored paths are valid. Only pay for a recovery scan when a
        # literal path is missing, and build that index at most once per export.
        resolved, relocated = texture_utils.resolve_texture_path(
            source_path, self._tex_index)
        if source_path and not relocated and not os.path.isfile(
                os.path.normpath(source_path)) and not self._tex_index_built:
            self._tex_index = texture_utils.build_texture_index(self._tex_roots)
            self._tex_index_built = True
            resolved, relocated = texture_utils.resolve_texture_path(
                source_path, self._tex_index)
        return resolved, relocated

    def _prepare_texture(self, source_path, out_dir, embed):
        # Returns (texture_info_dict, warnings) or (None, warnings).
        if not source_path:
            return None, []
        warns = []
        # Recover textures whose stored path is stale (moved/renamed folders).
        resolved, relocated = self._resolve_texture_path(source_path)
        if relocated:
            warns.append("Recovered moved texture '{0}' -> '{1}'".format(
                source_path, resolved))
            source_path = resolved
        max_size = int(self.options.get("max_texture_size", 0) or 0)
        try:
            normalized_source = os.path.normcase(os.path.abspath(source_path))
        except Exception:
            normalized_source = source_path
        cache_key = (
            normalized_source,
            os.path.normcase(os.path.abspath(out_dir)),
            bool(embed),
            bool(self.options.get("copy_textures", True)),
            max_size,
        )
        cached = self._texture_cache.get(cache_key)
        if cached is not None:
            return dict(cached), warns
        native_path, native_warn = texture_utils.ensure_native_texture(
            source_path, out_dir, max_size=max_size
        )
        native_warn = warns + native_warn
        if native_path is None:
            return None, native_warn
        source_path = native_path
        if embed:
            data = texture_utils.read_texture_bytes(source_path)
            if data is None:
                return None, native_warn + [
                    "Missing or unreadable texture: '{0}'".format(source_path)
                ]
            mime_type = texture_utils.mime_for_path(source_path)
            if not mime_type:
                return None, native_warn + [
                    "Unsupported glTF image format: '{0}'".format(source_path)
                ]
            info = {
                "embed_bytes": data,
                "mime_type": mime_type,
                "source_path": source_path,
            }
            self._texture_cache[cache_key] = dict(info)
            return info, native_warn
        if self.options.get("copy_textures", True):
            dest, warn = texture_utils.copy_texture(source_path, out_dir)
            if dest is None:
                return None, native_warn + warn
            info = {
                "uri": texture_utils.normalize_uri(dest, out_dir)
            }
            self._texture_cache[cache_key] = dict(info)
            return info, native_warn + warn
        info = {
            "uri": texture_utils.normalize_uri(source_path, out_dir)
        }
        self._texture_cache[cache_key] = dict(info)
        return info, native_warn

    def _prepare_material(self, material_node, out_dir, embed):
        mat = material_utils.extract_material(material_node)
        # Resolve every texture path up front so the packers (which read files
        # directly) also benefit from stale-path recovery.
        source_textures = {}
        for slot, pth in (mat.get("textures") or {}).items():
            if pth:
                rp, relocated = self._resolve_texture_path(pth)
                if relocated:
                    self.warnings.append(
                        "Recovered moved texture '{0}' -> '{1}'".format(pth, rp))
                source_textures[slot] = rp
            else:
                source_textures[slot] = pth
        tex_section = {}
        max_size = int(self.options.get("max_texture_size", 0) or 0)
        opacity_path = source_textures.pop("opacity", None)
        base_color_path = source_textures.get("base_color")
        if opacity_path:
            packed_color, alpha_warn = texture_utils.pack_base_color_alpha(
                base_color_path, opacity_path, out_dir, max_size=max_size
            )
            if alpha_warn:
                self.warnings.extend(alpha_warn)
            if packed_color:
                source_textures["base_color"] = packed_color

        metallic_path = source_textures.pop("metallic", None)
        roughness_path = source_textures.pop("roughness", None)
        packed_path, packed_warn = texture_utils.pack_metallic_roughness(
            metallic_path, roughness_path, out_dir,
            invert_roughness=bool(mat.get("roughness_invert")),
            max_size=max_size,
        )
        if packed_warn:
            self.warnings.extend(packed_warn)
        if packed_path:
            source_textures["metallic_roughness"] = packed_path

        for slot, src_path in source_textures.items():
            if not src_path:
                continue
            info, warns = self._prepare_texture(src_path, out_dir, embed)
            if warns:
                self.warnings.extend(warns)
            if info:
                tex_section[slot] = info
        mat["textures"] = tex_section
        return mat

    # ---------- hierarchy ----------

    def _build_hierarchy(self, writer, transforms):
        # Maps Maya DAG path -> glTF node index.
        # Creates intermediate parent transform nodes as needed when
        # include_hierarchy is True; otherwise meshes are flat roots.
        if not self.options.get("include_hierarchy", True):
            mesh_holders = {}
            for tpath in transforms:
                transform_data = _read_matrix(tpath, world=True)
                idx = writer.add_node(
                    transform_data["name"],
                    matrix=transform_data["matrix"],
                )
                mesh_holders[tpath] = idx
            return mesh_holders

        path_to_node = {}
        mesh_holders = {}
        # Build parents first via ancestry walks.
        for tpath in transforms:
            ancestors = scene_scan.get_all_ancestors(tpath) + [tpath]
            parent_node_idx = None
            for anc in ancestors:
                if anc in path_to_node:
                    parent_node_idx = path_to_node[anc]
                    continue
                transform_data = _read_matrix(anc, world=False)
                idx = writer.add_node(
                    transform_data["name"],
                    parent_index=parent_node_idx,
                    matrix=transform_data["matrix"],
                )
                path_to_node[anc] = idx
                parent_node_idx = idx
            mesh_holders[tpath] = path_to_node[tpath]
        return mesh_holders

    # ---------- main ----------

    def export(self):
        report = {
            "success": False,
            "output_path": self.options.get("output_path"),
            "format": self.options.get("format"),
            "mesh_count": 0,
            "primitive_count": 0,
            "source_face_count": 0,
            "source_triangle_face_count": 0,
            "source_quad_count": 0,
            "source_ngon_count": 0,
            "triangle_count": 0,
            "material_count": 0,
            "texture_count": 0,
            "camera_count": 0,
            "light_count": 0,
            "unsupported_light_count": 0,
            "warnings": self.warnings,
            "error": None,
        }

        try:
            ok, msg = validators.validate_options(self.options)
            if not ok:
                report["error"] = msg
                return report

            mode = self.options["export_mode"]
            if mode == "selected":
                transforms = self._maya_call(
                    scene_scan.get_selected_mesh_transforms
                )
            else:
                transforms = self._maya_call(
                    scene_scan.get_visible_mesh_transforms
                )
            if not transforms:
                report["error"] = ("No polygon meshes selected." if mode == "selected"
                                   else "No visible polygon meshes found.")
                return report

            out_path = self.options["output_path"]
            out_dir = os.path.dirname(out_path) or "."
            fmt = self.options.get("format", "gltf")
            embed = bool(self.options.get("embed_textures", False)) and fmt == "glb"

            writer = GltfWriter()
            writer.set_embed_textures(embed)

            # Recovery roots are indexed lazily only if a stored path is stale.
            self._progress(1, "Preparing textures")
            try:
                self._tex_roots = self._maya_call(
                    self._texture_search_roots, out_dir)
                self._tex_index = {}
                self._tex_index_built = False
            except Exception:
                self._tex_roots = []
                self._tex_index = {}
                self._tex_index_built = True

            self._progress(2, "Building hierarchy")
            mesh_holders = self._maya_call(
                self._build_hierarchy, writer, transforms
            )

            unique_materials = set()
            unique_textures = set()
            total_triangles = 0
            total_primitives = 0
            total_source_faces = 0
            total_source_triangle_faces = 0
            total_source_quads = 0
            total_source_ngons = 0

            for i, tpath in enumerate(transforms):
                if self._cancelled:
                    report["error"] = "Cancelled by user."
                    return report
                self._progress(5 + int(85 * i / max(len(transforms), 1)),
                               "Extracting {0}".format(tpath.split('|')[-1]))
                try:
                    mesh_data, mwarn = self._maya_call(
                        mesh_extract.extract_mesh, tpath, self.options
                    )
                    self.warnings.extend(mwarn)
                    if not mesh_data or not mesh_data.get("primitives"):
                        self.warnings.append("Skipped '{0}': no primitives.".format(tpath))
                        continue

                    total_source_faces += mesh_data.get("source_face_count", 0)
                    total_source_triangle_faces += mesh_data.get(
                        "source_triangle_face_count", 0
                    )
                    total_source_quads += mesh_data.get("source_quad_count", 0)
                    total_source_ngons += mesh_data.get("source_ngon_count", 0)

                    primitives_payload = []
                    for prim in mesh_data["primitives"]:
                        mat = self._maya_call(
                            self._prepare_material,
                            prim.get("material_node"),
                            out_dir,
                            embed,
                        )
                        unique_materials.add(mat.get("name"))
                        for slot, info in (mat.get("textures") or {}).items():
                            key = info.get("uri") or info.get("source_path")
                            if key:
                                unique_textures.add(key)
                        prim_payload = dict(prim)
                        prim_payload["material"] = mat
                        primitives_payload.append(prim_payload)
                        total_primitives += 1
                        total_triangles += prim.get("triangle_count", 0)

                    mesh_index = writer.add_mesh(mesh_data["name"], primitives_payload)
                    holder_idx = mesh_holders.get(tpath)
                    if holder_idx is not None:
                        writer._nodes[holder_idx]["mesh"] = mesh_index
                    report["mesh_count"] += 1
                except Exception as exc:
                    self.warnings.append(
                        "Failed to export '{0}': {1}".format(tpath, exc)
                    )
                    self.warnings.append(traceback.format_exc())

            if report["mesh_count"] == 0:
                report["error"] = "Nothing was exported."
                return report

            # Cameras are core glTF objects. Maya startup cameras are excluded;
            # all authored perspective and orthographic cameras are included.
            if self.options.get("include_cameras", True):
                self._progress(89, "Collecting cameras")
                try:
                    cameras = self._maya_call(
                        scene_scan.extract_cameras, self.options
                    )
                    for entry in cameras:
                        ci = writer.add_camera(entry["camera"])
                        writer.add_node(
                            entry["name"], matrix=entry["matrix"],
                            camera_index=ci
                        )
                    report["camera_count"] = len(cameras)
                except Exception as exc:
                    self.warnings.append(
                        "Camera export failed: {0}".format(exc)
                    )

            # KHR_lights_punctual supports point, spot, and directional only.
            if self.options.get("include_lights", True):
                self._progress(90, "Collecting lights")
                try:
                    lights = self._maya_call(light_extract.extract_lights, self.options)
                    for entry in lights:
                        li = writer.add_light(entry["light"])
                        writer.add_node(entry["name"], matrix=entry["matrix"], light_index=li)
                    report["light_count"] = len(lights)
                    unsupported = self._maya_call(
                        light_extract.find_unsupported_lights, self.options
                    )
                    area_lights = unsupported.get("area_lights", [])
                    skydomes = unsupported.get("skydomes", [])
                    report["unsupported_light_count"] = (
                        len(area_lights) + len(skydomes)
                    )
                    if area_lights:
                        self.warnings.append(
                            "Skipped {0} area light(s). glTF KHR_lights_punctual "
                            "supports only point, spot, and directional lights; "
                            "exporting an area light as point would change its "
                            "type and lighting.".format(len(area_lights))
                        )
                    if skydomes:
                        hdri_names = []
                        for sky in skydomes:
                            hdri_names.append(
                                sky.get("hdri") or sky.get("shape") or "skydome"
                            )
                        self.warnings.append(
                            "Skipped {0} skydome environment(s): {1}. Blender 5.0 "
                            "does not import glTF image-based lights; load the HDRI "
                            "in Blender's World shader.".format(
                                len(skydomes), ", ".join(hdri_names)
                            )
                        )
                except Exception as exc:
                    self.warnings.append("Light export failed: {0}".format(exc))

            self._progress(92, "Writing glTF buffers")
            if fmt == "glb":
                info = writer.write_glb(out_path)
            else:
                info = writer.write_gltf(out_path)

            report["triangle_count"] = total_triangles
            report["primitive_count"] = total_primitives
            report["source_face_count"] = total_source_faces
            report["source_triangle_face_count"] = total_source_triangle_faces
            report["source_quad_count"] = total_source_quads
            report["source_ngon_count"] = total_source_ngons
            report["material_count"] = len(unique_materials)
            report["texture_count"] = len(unique_textures)
            report["bin_path"] = info.get("bin_path")
            report["success"] = True

            if self.options.get("open_folder"):
                try:
                    import webbrowser
                    webbrowser.open(out_dir)
                except Exception:
                    pass

            self._progress(100, "Done")
            return report

        except Exception as exc:
            report["error"] = "Unhandled exporter error: {0}".format(exc)
            self.warnings.append(traceback.format_exc())
            return report


class GLTFImporter(object):

    def __init__(self, options, progress_cb=None, log_cb=None):
        self.options = dict(options or {})
        self.progress_cb = progress_cb
        self.log_cb = log_cb
        self.warnings = []
        self.document = {}
        self.buffers = []
        self.source_path = ""
        self.source_dir = ""
        self.texture_dir = ""
        self._material_cache = {}
        self._image_cache = {}
        self._node_cache = {}
        self._created_root = None
        self._report = None

    def _progress(self, pct, message):
        if self.progress_cb:
            try:
                self.progress_cb(int(pct), message)
            except Exception:
                pass

    def _log(self, message):
        if self.log_cb:
            try:
                self.log_cb(message)
            except Exception:
                pass

    def _safe_name(self, value, fallback):
        name = str(value or fallback)
        name = name.split("|")[-1].split(":")[-1]
        name = re.sub(r"[^A-Za-z0-9_]", "_", name)
        if not name:
            name = fallback
        if name[0].isdigit():
            name = "_" + name
        return name

    def _decode_data_uri(self, uri):
        if not uri.startswith("data:") or "," not in uri:
            raise ValueError("Invalid data URI.")
        header, payload = uri.split(",", 1)
        if ";base64" in header:
            return base64.b64decode(payload), header[5:].split(";")[0]
        return unquote(payload).encode("utf-8"), header[5:].split(";")[0]

    def _external_path(self, uri):
        decoded = unquote(uri or "")
        if decoded.lower().startswith(("http://", "https://")):
            raise ValueError("Remote URI is not supported: {0}".format(uri))
        if decoded.lower().startswith("file://"):
            decoded = decoded[7:]
        if os.path.isabs(decoded):
            return os.path.normpath(decoded)
        return os.path.normpath(os.path.join(self.source_dir, decoded))

    def _load_document(self, path):
        self.source_path = os.path.abspath(path)
        self.source_dir = os.path.dirname(self.source_path)
        extension = os.path.splitext(path)[1].lower()
        glb_bin = None
        if extension == ".glb":
            with open(path, "rb") as stream:
                raw = stream.read()
            if len(raw) < 20:
                raise ValueError("GLB file is too small.")
            magic, version, total_length = struct.unpack_from("<III", raw, 0)
            if magic != settings.GLB_MAGIC or version != settings.GLB_VERSION:
                raise ValueError("Not a valid GLB 2.0 file.")
            if total_length != len(raw):
                raise ValueError("GLB header length does not match the file size.")
            offset = 12
            json_bytes = None
            while offset + 8 <= len(raw):
                chunk_length, chunk_type = struct.unpack_from("<II", raw, offset)
                offset += 8
                end = offset + chunk_length
                if end > len(raw):
                    raise ValueError("GLB chunk exceeds the file size.")
                chunk = raw[offset:end]
                offset = end
                if chunk_type == settings.GLB_CHUNK_JSON:
                    json_bytes = chunk
                elif chunk_type == settings.GLB_CHUNK_BIN and glb_bin is None:
                    glb_bin = chunk
            if json_bytes is None:
                raise ValueError("GLB has no JSON chunk.")
            self.document = json.loads(json_bytes.rstrip(b" \x00").decode("utf-8"))
        elif extension == ".gltf":
            with open(path, "r", encoding="utf-8-sig") as stream:
                self.document = json.load(stream)
        else:
            raise ValueError("Choose a .gltf or .glb file.")

        asset = self.document.get("asset") or {}
        if str(asset.get("version", "")).split(".")[0] != "2":
            raise ValueError("Only glTF 2.0 files are supported.")
        supported_required = {
            "KHR_lights_punctual",
            "KHR_materials_ior",
            "KHR_materials_transmission",
            "KHR_mesh_quantization",
            "KHR_texture_transform",
        }
        unknown_required = sorted(
            set(self.document.get("extensionsRequired") or []) - supported_required
        )
        if unknown_required:
            raise ValueError(
                "Required glTF extension is not supported: {0}".format(
                    ", ".join(unknown_required))
            )

        self.buffers = []
        for index, info in enumerate(self.document.get("buffers") or []):
            uri = info.get("uri")
            if uri:
                if uri.startswith("data:"):
                    data, _mime = self._decode_data_uri(uri)
                else:
                    buffer_path = self._external_path(uri)
                    if not os.path.isfile(buffer_path):
                        raise ValueError("Missing buffer: {0}".format(buffer_path))
                    with open(buffer_path, "rb") as stream:
                        data = stream.read()
            elif index == 0 and glb_bin is not None:
                data = glb_bin
            else:
                raise ValueError("Buffer {0} has no readable data.".format(index))
            expected = int(info.get("byteLength", 0))
            if expected and len(data) < expected:
                raise ValueError("Buffer {0} is shorter than declared.".format(index))
            self.buffers.append(data)

    def _normalized_value(self, value, component_type):
        if component_type == settings.COMPONENT_TYPE_BYTE:
            return max(float(value) / 127.0, -1.0)
        if component_type == settings.COMPONENT_TYPE_UNSIGNED_BYTE:
            return float(value) / 255.0
        if component_type == settings.COMPONENT_TYPE_SHORT:
            return max(float(value) / 32767.0, -1.0)
        if component_type == settings.COMPONENT_TYPE_UNSIGNED_SHORT:
            return float(value) / 65535.0
        if component_type == settings.COMPONENT_TYPE_UNSIGNED_INT:
            return float(value) / 4294967295.0
        return float(value)

    def _decode_elements(self, buffer_data, start, count, component_type,
                         width, stride, normalized=False):
        component = _COMPONENT_FORMATS.get(component_type)
        if component is None:
            raise ValueError("Unsupported accessor component type: {0}".format(
                component_type))
        fmt, component_size, _signed = component
        packed_size = component_size * width
        if stride < packed_size:
            raise ValueError("Accessor byteStride is smaller than its element size.")
        out = []
        unpack_fmt = "<" + (fmt * width)
        for item_index in range(count):
            offset = start + (item_index * stride)
            end = offset + packed_size
            if offset < 0 or end > len(buffer_data):
                raise ValueError("Accessor data exceeds its buffer bounds.")
            values = list(struct.unpack_from(unpack_fmt, buffer_data, offset))
            if normalized and component_type != settings.COMPONENT_TYPE_FLOAT:
                values = [self._normalized_value(v, component_type) for v in values]
            if width == 1:
                out.append(values[0])
            else:
                out.append(values)
        return out

    def _read_accessor(self, accessor_index):
        accessors = self.document.get("accessors") or []
        if accessor_index < 0 or accessor_index >= len(accessors):
            raise ValueError("Accessor index is out of range: {0}".format(accessor_index))
        accessor = accessors[accessor_index]
        component_type = int(accessor.get("componentType"))
        width = _TYPE_WIDTHS.get(accessor.get("type"))
        count = int(accessor.get("count", 0))
        if width is None or count < 0:
            raise ValueError("Accessor {0} has an invalid type or count.".format(
                accessor_index))
        component = _COMPONENT_FORMATS.get(component_type)
        if component is None:
            raise ValueError("Accessor {0} has an unsupported component type.".format(
                accessor_index))
        component_size = component[1]
        if "bufferView" not in accessor:
            zero = 0.0 if component_type == settings.COMPONENT_TYPE_FLOAT else 0
            values = [zero if width == 1 else [zero] * width for _ in range(count)]
        else:
            views = self.document.get("bufferViews") or []
            view_index = int(accessor["bufferView"])
            if view_index < 0 or view_index >= len(views):
                raise ValueError("Accessor bufferView is out of range.")
            view = views[view_index]
            buffer_index = int(view.get("buffer", 0))
            if buffer_index < 0 or buffer_index >= len(self.buffers):
                raise ValueError("Accessor buffer index is out of range.")
            start = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0))
            stride = int(view.get("byteStride", component_size * width))
            values = self._decode_elements(
                self.buffers[buffer_index], start, count, component_type,
                width, stride, bool(accessor.get("normalized", False)))

        sparse = accessor.get("sparse")
        if sparse:
            sparse_count = int(sparse.get("count", 0))
            index_info = sparse.get("indices") or {}
            value_info = sparse.get("values") or {}
            views = self.document.get("bufferViews") or []
            index_view = views[int(index_info["bufferView"])]
            value_view = views[int(value_info["bufferView"])]
            index_component = int(index_info.get("componentType"))
            index_size = _COMPONENT_FORMATS[index_component][1]
            sparse_indices = self._decode_elements(
                self.buffers[int(index_view.get("buffer", 0))],
                int(index_view.get("byteOffset", 0)) + int(index_info.get("byteOffset", 0)),
                sparse_count, index_component, 1, index_size, False)
            sparse_values = self._decode_elements(
                self.buffers[int(value_view.get("buffer", 0))],
                int(value_view.get("byteOffset", 0)) + int(value_info.get("byteOffset", 0)),
                sparse_count, component_type, width, component_size * width,
                bool(accessor.get("normalized", False)))
            for sparse_index, sparse_value in zip(sparse_indices, sparse_values):
                if sparse_index < 0 or sparse_index >= len(values):
                    raise ValueError("Sparse accessor index is out of range.")
                values[int(sparse_index)] = sparse_value
        return values

    def _buffer_view_bytes(self, view_index):
        views = self.document.get("bufferViews") or []
        if view_index < 0 or view_index >= len(views):
            raise ValueError("Image bufferView is out of range.")
        view = views[view_index]
        buffer_index = int(view.get("buffer", 0))
        start = int(view.get("byteOffset", 0))
        end = start + int(view.get("byteLength", 0))
        if buffer_index < 0 or buffer_index >= len(self.buffers):
            raise ValueError("Image buffer index is out of range.")
        if start < 0 or end > len(self.buffers[buffer_index]):
            raise ValueError("Image bufferView exceeds its buffer bounds.")
        return self.buffers[buffer_index][start:end]

    def _image_path(self, texture_index):
        if texture_index in self._image_cache:
            return self._image_cache[texture_index]
        textures = self.document.get("textures") or []
        images = self.document.get("images") or []
        if texture_index < 0 or texture_index >= len(textures):
            self.warnings.append("Texture index {0} is out of range.".format(texture_index))
            return None
        source = int(textures[texture_index].get("source", -1))
        if source < 0 or source >= len(images):
            self.warnings.append("Texture {0} has no valid image source.".format(texture_index))
            return None
        image = images[source]
        uri = image.get("uri")
        if uri and not uri.startswith("data:"):
            path = self._external_path(uri)
            if not os.path.isfile(path):
                self.warnings.append("Missing image: {0}".format(path))
                return None
            self._image_cache[texture_index] = path
            self._report["texture_count"] += 1
            return path

        if uri:
            data, mime = self._decode_data_uri(uri)
        elif "bufferView" in image:
            data = self._buffer_view_bytes(int(image["bufferView"]))
            mime = image.get("mimeType", "image/png")
        else:
            self.warnings.append("Image {0} has no URI or bufferView.".format(source))
            return None

        ext_by_mime = {
            "image/png": ".png",
            "image/jpeg": ".jpg",
            "image/webp": ".webp",
            "image/ktx2": ".ktx2",
        }
        extension = ext_by_mime.get(str(mime).lower(), ".bin")
        if extension in (".webp", ".ktx2", ".bin"):
            self.warnings.append(
                "Maya may not read embedded {0} texture {1}.".format(mime, source))
        if not os.path.isdir(self.texture_dir):
            os.makedirs(self.texture_dir)
        image_name = self._safe_name(image.get("name"), "image_{0}".format(source))
        path = os.path.join(self.texture_dir, image_name + extension)
        with open(path, "wb") as stream:
            stream.write(data)
        self._image_cache[texture_index] = path
        self._report["texture_count"] += 1
        return path

    def _set_attr(self, node, attr, value):
        plug = node + "." + attr
        if not cmds.objExists(plug):
            return False
        try:
            if isinstance(value, (list, tuple)):
                cmds.setAttr(plug, *value)
            else:
                cmds.setAttr(plug, value)
            return True
        except Exception:
            return False

    def _create_file_node(self, name, path, color_space, texture_info=None):
        file_node = cmds.shadingNode("file", asTexture=True,
                                     name=self._safe_name(name, "texture") + "_file")
        place = cmds.shadingNode("place2dTexture", asUtility=True,
                                 name=self._safe_name(name, "texture") + "_place2d")
        pairs = (
            ("coverage", "coverage"), ("translateFrame", "translateFrame"),
            ("rotateFrame", "rotateFrame"), ("mirrorU", "mirrorU"),
            ("mirrorV", "mirrorV"), ("stagger", "stagger"),
            ("wrapU", "wrapU"), ("wrapV", "wrapV"),
            ("repeatUV", "repeatUV"), ("offset", "offset"),
            ("rotateUV", "rotateUV"), ("noiseUV", "noiseUV"),
            ("vertexUvOne", "vertexUvOne"), ("vertexUvTwo", "vertexUvTwo"),
            ("vertexUvThree", "vertexUvThree"),
            ("vertexCameraOne", "vertexCameraOne"),
        )
        for source_attr, target_attr in pairs:
            try:
                cmds.connectAttr(place + "." + source_attr,
                                 file_node + "." + target_attr, force=True)
            except Exception:
                pass
        cmds.connectAttr(place + ".outUV", file_node + ".uvCoord", force=True)
        cmds.connectAttr(place + ".outUvFilterSize", file_node + ".uvFilterSize", force=True)
        cmds.setAttr(file_node + ".fileTextureName", path, type="string")
        try:
            cmds.setAttr(file_node + ".colorSpace", color_space, type="string")
        except Exception:
            pass

        texture_info = texture_info or {}
        transform = (texture_info.get("extensions") or {}).get("KHR_texture_transform") or {}
        scale = transform.get("scale")
        offset = transform.get("offset")
        if scale and len(scale) >= 2:
            self._set_attr(place, "repeatUV", [float(scale[0]), float(scale[1])])
        if offset and len(offset) >= 2:
            self._set_attr(place, "offset", [float(offset[0]), float(offset[1])])
        return file_node

    def _connect_texture(self, shader, shader_attr, texture_info,
                         color_space="Raw", output="outColor", factor=None):
        if not self.options.get("import_textures", True) or not texture_info:
            return None
        texture_index = int(texture_info.get("index", -1))
        path = self._image_path(texture_index)
        if not path:
            return None
        file_node = self._create_file_node(
            self._safe_name(shader_attr, "texture"), path, color_space, texture_info)

        source_plug = file_node + "." + output
        target_plug = shader + "." + shader_attr
        if factor is not None:
            if isinstance(factor, (list, tuple)):
                mult = cmds.shadingNode("multiplyDivide", asUtility=True,
                                        name=self._safe_name(shader_attr, "factor") + "_factor")
                self._set_attr(mult, "input2", list(factor[:3]))
                cmds.connectAttr(source_plug, mult + ".input1", force=True)
                source_plug = mult + ".output"
            else:
                mult = cmds.shadingNode("multDoubleLinear", asUtility=True,
                                        name=self._safe_name(shader_attr, "factor") + "_factor")
                self._set_attr(mult, "input2", float(factor))
                cmds.connectAttr(source_plug, mult + ".input1", force=True)
                source_plug = mult + ".output"
        try:
            cmds.connectAttr(source_plug, target_plug, force=True)
        except Exception as exc:
            self.warnings.append("Could not connect texture to {0}: {1}".format(
                shader_attr, exc))
        return file_node

    def _material(self, material_index):
        if material_index in self._material_cache:
            return self._material_cache[material_index]
        materials = self.document.get("materials") or []
        if material_index < 0 or material_index >= len(materials):
            material = {}
            material_name = "RFX_Default_Material"
        else:
            material = materials[material_index]
            material_name = self._safe_name(
                material.get("name"), "material_{0}".format(material_index))
        if not self.options.get("import_materials", True):
            self._material_cache[material_index] = None
            return None

        shader_type = "standardSurface" if "standardSurface" in (
            cmds.listNodeTypes("shader") or []) else "lambert"
        shader = cmds.shadingNode(shader_type, asShader=True, name=material_name)
        shading_group = cmds.sets(renderable=True, noSurfaceShader=True, empty=True,
                                  name=material_name + "SG")
        cmds.connectAttr(shader + ".outColor", shading_group + ".surfaceShader", force=True)
        pbr = material.get("pbrMetallicRoughness") or {}
        base = list(pbr.get("baseColorFactor", [1.0, 1.0, 1.0, 1.0]))
        while len(base) < 4:
            base.append(1.0)
        self._set_attr(shader, "baseColor" if shader_type == "standardSurface" else "color",
                       base[:3])
        if shader_type == "standardSurface":
            self._set_attr(shader, "metalness", float(pbr.get("metallicFactor", 1.0)))
            self._set_attr(shader, "specularRoughness", float(pbr.get("roughnessFactor", 1.0)))
            self._set_attr(shader, "opacity", [base[3], base[3], base[3]])
        else:
            transparency = 1.0 - base[3]
            self._set_attr(shader, "transparency", [transparency] * 3)

        base_texture = pbr.get("baseColorTexture")
        base_attr = "baseColor" if shader_type == "standardSurface" else "color"
        base_file = self._connect_texture(shader, base_attr, base_texture,
                                          color_space="sRGB", factor=base[:3])
        if base_file and shader_type == "standardSurface" and material.get("alphaMode", "OPAQUE") != "OPAQUE":
            alpha_mult = cmds.shadingNode("multDoubleLinear", asUtility=True,
                                         name=material_name + "_alpha_factor")
            self._set_attr(alpha_mult, "input2", base[3])
            cmds.connectAttr(base_file + ".outAlpha", alpha_mult + ".input1", force=True)
            for channel in ("R", "G", "B"):
                cmds.connectAttr(alpha_mult + ".output", shader + ".opacity" + channel, force=True)

        if shader_type == "standardSurface":
            mr = pbr.get("metallicRoughnessTexture")
            if mr and self.options.get("import_textures", True):
                texture_index = int(mr.get("index", -1))
                path = self._image_path(texture_index)
                if path:
                    file_node = self._create_file_node(
                        material_name + "_metalrough", path, "Raw", mr)
                    self._connect_scalar_factor(file_node + ".outColorB", shader + ".metalness",
                                                float(pbr.get("metallicFactor", 1.0)),
                                                material_name + "_metal_factor")
                    self._connect_scalar_factor(file_node + ".outColorG",
                                                shader + ".specularRoughness",
                                                float(pbr.get("roughnessFactor", 1.0)),
                                                material_name + "_rough_factor")

            normal = material.get("normalTexture")
            if normal and self.options.get("import_textures", True):
                path = self._image_path(int(normal.get("index", -1)))
                if path:
                    file_node = self._create_file_node(
                        material_name + "_normal", path, "Raw", normal)
                    self._set_attr(file_node, "alphaIsLuminance", True)
                    bump = cmds.shadingNode("bump2d", asUtility=True,
                                            name=material_name + "_normal_bump")
                    self._set_attr(bump, "bumpInterp", 1)
                    self._set_attr(bump, "bumpDepth", float(normal.get("scale", 1.0)))
                    cmds.connectAttr(file_node + ".outAlpha", bump + ".bumpValue", force=True)
                    cmds.connectAttr(bump + ".outNormal", shader + ".normalCamera", force=True)

            emissive = list(material.get("emissiveFactor", [0.0, 0.0, 0.0]))
            while len(emissive) < 3:
                emissive.append(0.0)
            if any(abs(v) > 1.0e-8 for v in emissive) or material.get("emissiveTexture"):
                self._set_attr(shader, "emission", 1.0)
                self._set_attr(shader, "emissionColor", emissive[:3])
                self._connect_texture(shader, "emissionColor", material.get("emissiveTexture"),
                                      color_space="sRGB", factor=emissive[:3])

            extensions = material.get("extensions") or {}
            transmission = (extensions.get("KHR_materials_transmission") or {}).get(
                "transmissionFactor")
            if transmission is not None:
                self._set_attr(shader, "transmission", float(transmission))
            ior = (extensions.get("KHR_materials_ior") or {}).get("ior")
            if ior is not None:
                self._set_attr(shader, "specularIOR", float(ior))
            unsupported_extensions = sorted(
                set(extensions.keys()) - {
                    "KHR_materials_ior", "KHR_materials_transmission"
                }
            )
            if unsupported_extensions:
                self.warnings.append(
                    "Material '{0}' uses extensions not reconstructed in Maya: {1}.".format(
                        material_name, ", ".join(unsupported_extensions))
                )
            if material.get("occlusionTexture"):
                self.warnings.append(
                    "Material '{0}' has an occlusion texture. standardSurface has no "
                    "dedicated ambient-occlusion input, so it was not connected.".format(
                        material_name)
                )

        self._material_cache[material_index] = {
            "shader": shader,
            "shading_group": shading_group,
            "double_sided": bool(material.get("doubleSided", False)),
        }
        self._report["material_count"] += 1
        return self._material_cache[material_index]

    def _connect_scalar_factor(self, source, target, factor, name):
        mult = cmds.shadingNode("multDoubleLinear", asUtility=True, name=name)
        self._set_attr(mult, "input2", factor)
        cmds.connectAttr(source, mult + ".input1", force=True)
        cmds.connectAttr(mult + ".output", target, force=True)

    def _triangle_indices(self, primitive, vertex_count):
        if "indices" in primitive:
            source = [int(v) for v in self._read_accessor(int(primitive["indices"]))]
        else:
            source = list(range(vertex_count))
        mode = int(primitive.get("mode", 4))
        if mode == 4:
            return source[:len(source) - (len(source) % 3)]
        triangles = []
        if mode == 5:
            for i in range(len(source) - 2):
                if i % 2:
                    triangles.extend([source[i + 1], source[i], source[i + 2]])
                else:
                    triangles.extend([source[i], source[i + 1], source[i + 2]])
            return triangles
        if mode == 6:
            for i in range(1, len(source) - 1):
                triangles.extend([source[0], source[i], source[i + 1]])
            return triangles
        self.warnings.append("Skipped unsupported primitive mode {0}.".format(mode))
        return []

    def _create_mesh(self, mesh_index, parent_transform):
        meshes = self.document.get("meshes") or []
        if mesh_index < 0 or mesh_index >= len(meshes):
            self.warnings.append("Mesh index {0} is out of range.".format(mesh_index))
            return None
        mesh = meshes[mesh_index]
        points = []
        connects = []
        normals_by_vertex = {}
        uvs_by_vertex = {}
        colors_by_vertex = {}
        face_materials = []
        double_sided = False

        for primitive in mesh.get("primitives") or []:
            attrs = primitive.get("attributes") or {}
            if "POSITION" not in attrs:
                self.warnings.append("Skipped a primitive without POSITION data.")
                continue
            positions = self._read_accessor(int(attrs["POSITION"]))
            base_vertex = len(points)
            for position in positions:
                points.append(om.MPoint(float(position[0]), float(position[1]),
                                        float(position[2])))
            indices = self._triangle_indices(primitive, len(positions))
            valid_indices = []
            for value in indices:
                if value < 0 or value >= len(positions):
                    raise ValueError("Mesh primitive index exceeds POSITION count.")
                valid_indices.append(base_vertex + value)
            face_start = len(connects) // 3
            connects.extend(valid_indices)
            face_count = len(valid_indices) // 3

            if "NORMAL" in attrs:
                values = self._read_accessor(int(attrs["NORMAL"]))
                for index, value in enumerate(values[:len(positions)]):
                    normals_by_vertex[base_vertex + index] = om.MVector(
                        float(value[0]), float(value[1]), float(value[2]))
            if "TEXCOORD_0" in attrs:
                values = self._read_accessor(int(attrs["TEXCOORD_0"]))
                for index, value in enumerate(values[:len(positions)]):
                    v_value = float(value[1])
                    if self.options.get("import_flip_uv_v", True):
                        v_value = 1.0 - v_value
                    uvs_by_vertex[base_vertex + index] = (float(value[0]), v_value)
            if "COLOR_0" in attrs:
                values = self._read_accessor(int(attrs["COLOR_0"]))
                for index, value in enumerate(values[:len(positions)]):
                    color = list(value)
                    while len(color) < 4:
                        color.append(1.0)
                    colors_by_vertex[base_vertex + index] = om.MColor(color[:4])

            material_index = int(primitive.get("material", -1))
            material = self._material(material_index)
            if material and material.get("double_sided"):
                double_sided = True
            face_materials.append((face_start, face_count, material))
            self._report["primitive_count"] += 1

        if not points or not connects:
            return None
        selection = om.MSelectionList()
        selection.add(parent_transform)
        parent_object = selection.getDependNode(0)
        counts = om.MIntArray([3] * (len(connects) // 3))
        mesh_fn = om.MFnMesh()
        mesh_object = mesh_fn.create(om.MPointArray(points), counts,
                                     om.MIntArray(connects), parent=parent_object)
        shape_name = self._safe_name(mesh.get("name"), "mesh_{0}".format(mesh_index)) + "Shape"
        shape_name = om.MFnDagNode(mesh_object).setName(shape_name)

        if normals_by_vertex:
            normal_values = om.MVectorArray()
            face_ids = om.MIntArray()
            vertex_ids = om.MIntArray()
            for face_id in range(len(connects) // 3):
                for corner in range(3):
                    vertex_id = connects[(face_id * 3) + corner]
                    normal = normals_by_vertex.get(vertex_id)
                    if normal is not None:
                        normal_values.append(normal)
                        face_ids.append(face_id)
                        vertex_ids.append(vertex_id)
            if len(normal_values):
                mesh_fn.setFaceVertexNormals(normal_values, face_ids, vertex_ids,
                                             om.MSpace.kObject)

        if uvs_by_vertex:
            u_values = om.MFloatArray([0.0] * len(points))
            v_values = om.MFloatArray([0.0] * len(points))
            for vertex_id, uv in uvs_by_vertex.items():
                u_values[vertex_id] = uv[0]
                v_values[vertex_id] = uv[1]
            mesh_fn.setUVs(u_values, v_values, "map1")
            mesh_fn.assignUVs(counts, om.MIntArray(connects), "map1")

        if colors_by_vertex:
            try:
                cmds.polyColorSet(shape_name, create=True, colorSet="colorSet1",
                                  representation="RGBA")
                mesh_fn.setCurrentColorSetName("colorSet1")
                color_values = om.MColorArray()
                face_ids = om.MIntArray()
                vertex_ids = om.MIntArray()
                for face_id in range(len(connects) // 3):
                    for corner in range(3):
                        vertex_id = connects[(face_id * 3) + corner]
                        color_values.append(colors_by_vertex.get(
                            vertex_id, om.MColor((1.0, 1.0, 1.0, 1.0))))
                        face_ids.append(face_id)
                        vertex_ids.append(vertex_id)
                mesh_fn.setFaceVertexColors(color_values, face_ids, vertex_ids)
            except Exception as exc:
                self.warnings.append("Vertex color import failed: {0}".format(exc))

        if double_sided:
            self._set_attr(shape_name, "doubleSided", True)
        for face_start, face_count, material in face_materials:
            if not material or face_count <= 0:
                continue
            component = "{0}.f[{1}:{2}]".format(
                shape_name, face_start, face_start + face_count - 1)
            try:
                cmds.sets(component, edit=True, forceElement=material["shading_group"])
            except Exception as exc:
                self.warnings.append("Material assignment failed: {0}".format(exc))

        self._report["mesh_count"] += 1
        self._report["triangle_count"] += len(connects) // 3
        return shape_name

    def _node_matrix(self, node):
        matrix = node.get("matrix")
        if matrix and len(matrix) == 16:
            return [float(value) for value in matrix]
        translation = node.get("translation", [0.0, 0.0, 0.0])
        rotation = node.get("rotation", [0.0, 0.0, 0.0, 1.0])
        scale = node.get("scale", [1.0, 1.0, 1.0])
        transform = om.MTransformationMatrix()
        transform.setTranslation(om.MVector(*[float(v) for v in translation[:3]]),
                                 om.MSpace.kTransform)
        transform.setRotation(om.MQuaternion(*[float(v) for v in rotation[:4]]))
        transform.setScale([float(v) for v in scale[:3]], om.MSpace.kTransform)
        return list(transform.asMatrix())

    def _create_camera(self, camera_index, transform):
        cameras = self.document.get("cameras") or []
        if camera_index < 0 or camera_index >= len(cameras):
            self.warnings.append("Camera index {0} is out of range.".format(camera_index))
            return
        camera = cameras[camera_index]
        shape = cmds.createNode("camera", name=self._safe_name(
            camera.get("name"), "camera_{0}".format(camera_index)) + "Shape",
            parent=transform)
        camera_type = camera.get("type")
        data = camera.get(camera_type) or {}
        self._set_attr(shape, "nearClipPlane", max(1.0e-6, float(data.get("znear", 0.1))))
        if data.get("zfar") is not None:
            self._set_attr(shape, "farClipPlane", float(data["zfar"]))
        if camera_type == "orthographic":
            self._set_attr(shape, "orthographic", True)
            self._set_attr(shape, "orthographicWidth", float(data.get("xmag", 1.0)) * 2.0)
        else:
            yfov = max(1.0e-6, float(data.get("yfov", math.radians(45.0))))
            aperture_inches = float(cmds.getAttr(shape + ".verticalFilmAperture"))
            focal = (aperture_inches * 25.4) / (2.0 * math.tan(yfov * 0.5))
            self._set_attr(shape, "focalLength", focal)
        self._report["camera_count"] += 1

    def _create_light(self, light_index, transform):
        extension = (self.document.get("extensions") or {}).get(
            "KHR_lights_punctual") or {}
        lights = extension.get("lights") or []
        if light_index < 0 or light_index >= len(lights):
            self.warnings.append("Light index {0} is out of range.".format(light_index))
            return
        light = lights[light_index]
        gltf_type = light.get("type", "point")
        maya_type = {
            "point": "pointLight",
            "spot": "spotLight",
            "directional": "directionalLight",
        }.get(gltf_type)
        if not maya_type:
            self.warnings.append("Unsupported glTF light type: {0}".format(gltf_type))
            return
        shape = cmds.createNode(maya_type, name=self._safe_name(
            light.get("name"), "light_{0}".format(light_index)) + "Shape",
            parent=transform)
        self._set_attr(shape, "color", list(light.get("color", [1.0, 1.0, 1.0]))[:3])
        intensity = float(light.get("intensity", 1.0))
        if gltf_type == "directional":
            intensity = intensity / light_extract._DSCALE
        else:
            intensity = intensity / light_extract._PSCALE
        self._set_attr(shape, "intensity", intensity)
        if gltf_type == "spot":
            spot = light.get("spot") or {}
            outer = float(spot.get("outerConeAngle", math.pi / 4.0))
            inner = float(spot.get("innerConeAngle", 0.0))
            self._set_attr(shape, "coneAngle", math.degrees(outer * 2.0))
            self._set_attr(shape, "penumbraAngle", max(0.0, math.degrees(outer - inner)))
        self._report["light_count"] += 1

    def _create_node(self, node_index, parent_transform, active_stack=None):
        nodes = self.document.get("nodes") or []
        if node_index < 0 or node_index >= len(nodes):
            self.warnings.append("Scene node index {0} is out of range.".format(node_index))
            return None
        if active_stack is None:
            active_stack = set()
        if node_index in active_stack:
            raise ValueError("glTF node hierarchy contains a cycle.")
        active_stack.add(node_index)
        node = nodes[node_index]
        transform = cmds.createNode(
            "transform",
            name=self._safe_name(node.get("name"), "node_{0}".format(node_index)),
            parent=parent_transform)
        cmds.xform(transform, objectSpace=True, matrix=self._node_matrix(node))
        self._node_cache[node_index] = transform

        if node.get("mesh") is not None:
            self._create_mesh(int(node["mesh"]), transform)
        if self.options.get("import_cameras", True) and node.get("camera") is not None:
            self._create_camera(int(node["camera"]), transform)
        if self.options.get("import_lights", True):
            light_info = (node.get("extensions") or {}).get("KHR_lights_punctual")
            if light_info and light_info.get("light") is not None:
                self._create_light(int(light_info["light"]), transform)
        for child in node.get("children") or []:
            self._create_node(int(child), transform, active_stack)
        active_stack.remove(node_index)
        return transform

    def import_file(self):
        path = (self.options.get("input_path") or "").strip()
        report = {
            "success": False,
            "input_path": path,
            "format": os.path.splitext(path)[1].lower().lstrip("."),
            "root": None,
            "mesh_count": 0,
            "primitive_count": 0,
            "triangle_count": 0,
            "material_count": 0,
            "texture_count": 0,
            "camera_count": 0,
            "light_count": 0,
            "warnings": self.warnings,
            "error": None,
        }
        self._report = report
        undo_open = False
        try:
            if not path or not os.path.isfile(path):
                report["error"] = "Choose an existing .gltf or .glb file."
                return report
            self._progress(3, "Reading glTF")
            self._load_document(path)
            if self.document.get("animations"):
                self.warnings.append(
                    "Animation data is present but is not imported in version 1.3.0."
                )
            if self.document.get("skins"):
                self.warnings.append(
                    "Skin and joint data is present but is not imported in version 1.3.0."
                )
            if any(primitive.get("targets") for mesh in self.document.get("meshes") or []
                   for primitive in mesh.get("primitives") or []):
                self.warnings.append(
                    "Morph targets are present but are not imported in version 1.3.0."
                )
            stem = self._safe_name(os.path.splitext(os.path.basename(path))[0], "gltf_asset")
            self.texture_dir = os.path.join(self.source_dir, stem + "_textures")
            cmds.undoInfo(openChunk=True, chunkName="RFX glTF Import")
            undo_open = True
            self._created_root = cmds.createNode("transform", name=stem + "_GLTF")
            report["root"] = self._created_root

            scenes = self.document.get("scenes") or []
            scene_index = int(self.document.get("scene", 0))
            if scenes and 0 <= scene_index < len(scenes):
                roots = scenes[scene_index].get("nodes") or []
            else:
                child_indices = set()
                for node in self.document.get("nodes") or []:
                    child_indices.update(node.get("children") or [])
                roots = [index for index in range(len(self.document.get("nodes") or []))
                         if index not in child_indices]
            if not roots:
                raise ValueError("The glTF scene has no root nodes.")

            for position, node_index in enumerate(roots):
                pct = 10 + int(82 * position / max(len(roots), 1))
                self._progress(pct, "Importing scene node {0}".format(node_index))
                self._create_node(int(node_index), self._created_root)
            if report["mesh_count"] == 0 and report["camera_count"] == 0 and report["light_count"] == 0:
                raise ValueError("The glTF scene contains no supported objects.")
            cmds.select(self._created_root, replace=True)
            report["success"] = True
            self._progress(100, "Done")
            return report
        except Exception as exc:
            report["error"] = "Import failed: {0}".format(exc)
            self.warnings.append(traceback.format_exc())
            if undo_open:
                try:
                    cmds.undoInfo(closeChunk=True)
                    undo_open = False
                    cmds.undo()
                except Exception:
                    pass
            elif self._created_root and cmds.objExists(self._created_root):
                try:
                    cmds.delete(self._created_root)
                except Exception:
                    pass
            return report
        finally:
            if undo_open:
                try:
                    cmds.undoInfo(closeChunk=True)
                except Exception:
                    pass
