"""Scene base: module designer scene graph, GIT resolution, and instance rendering."""

from __future__ import annotations

from concurrent.futures import Future
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar

from loggerplus import RobustLogger
from pykotor.gl.compat import has_pyopengl

HAS_PYOPENGL = has_pyopengl()

from pykotor.common.module import Module, ModuleResource
from pykotor.common.stream import BinaryReader
from pykotor.extract.file import ResourceIdentifier, ResourceResult
from pykotor.extract.installation import SearchLocation
from pykotor.gl.models.mdl import Model, Node
from pykotor.gl.models.predefined_mdl import (
    CAMERA_MDL_DATA,
    CAMERA_MDX_DATA,
    CURSOR_MDL_DATA,
    CURSOR_MDX_DATA,
    EMPTY_MDL_DATA,
    EMPTY_MDX_DATA,
    ENCOUNTER_MDL_DATA,
    ENCOUNTER_MDX_DATA,
    ENTRY_MDL_DATA,
    ENTRY_MDX_DATA,
    SOUND_MDL_DATA,
    SOUND_MDX_DATA,
    STORE_MDL_DATA,
    STORE_MDX_DATA,
    TRIGGER_MDL_DATA,
    TRIGGER_MDX_DATA,
    UNKNOWN_MDL_DATA,
    UNKNOWN_MDX_DATA,
    WAYPOINT_MDL_DATA,
    WAYPOINT_MDX_DATA,
)
from pykotor.gl.models.read_mdl import gl_load_stitched_model
from pykotor.gl.scene import Camera, RenderObject
from pykotor.gl.scene.async_loader import (
    AsyncResourceLoader,
    IntermediateModel,
    IntermediateTexture,
    create_model_from_intermediate,
    create_texture_from_intermediate,
)
from pykotor.gl.shader import Texture
from pykotor.resource.formats.lyt.lyt_data import LYT
from pykotor.resource.formats.tpc.tpc_data import TPC
from pykotor.resource.formats.twoda.twoda_auto import read_2da
from pykotor.resource.formats.twoda.twoda_data import TwoDA
from pykotor.resource.generics.git import GIT, GITCreature, GITInstance
from pykotor.resource.generics.ifo import IFO
from pykotor.resource.type import ResourceType
from pykotor.tools import creature
from utility.common.geometry import Vector3
from utility.common.more_collections import CaseInsensitiveDict

if TYPE_CHECKING:
    from collections.abc import Callable

    from typing_extensions import Literal  # pyright: ignore[reportMissingModuleSource]

    from pykotor.common.module import Module, ModulePieceResource, ModuleResource
    from pykotor.extract.capsule import Capsule
    from pykotor.extract.file import ResourceResult
    from pykotor.extract.installation import Installation
    from pykotor.gl.models.mdl import Model, Node
    from pykotor.resource.formats.tpc import TPC
    from pykotor.resource.generics.git import GITCreature, GITInstance
    from pykotor.resource.generics.utc import UTC

T = TypeVar("T")
SEARCH_ORDER_2DA: list[SearchLocation] = [SearchLocation.OVERRIDE, SearchLocation.CHITIN]
SEARCH_ORDER: list[SearchLocation] = [SearchLocation.OVERRIDE, SearchLocation.CHITIN]


# DO NOT USE INSTALLATION IN CHILD PROCESSES
# Both IO AND parsing should be in child process(es). ONE PROCESS PER FILE!!


class SceneBase:
    SPECIAL_MODELS: ClassVar[list[str]] = ["waypoint", "store", "sound", "camera", "trigger", "encounter", "unknown"]
    _TWODA_TABLES: ClassVar[dict[str, str]] = {
        "table_doors": "genericdoors",
        "table_placeables": "placeables",
        "table_creatures": "appearance",
        "table_heads": "heads",
        "table_baseitems": "baseitems",
    }
    table_doors: TwoDA
    table_placeables: TwoDA
    table_creatures: TwoDA
    table_heads: TwoDA
    table_baseitems: TwoDA

    def __init__(
        self,
        *,
        installation: Installation | None = None,
        module: Module | None = None,
    ):
        # Initialize async_loader to None early to handle partial initialization
        # If __init__ raises an exception before line 195, __del__ can safely check is not None
        self.async_loader: AsyncResourceLoader | None = None
        self._shutdown: bool = False

        self._missing_texture: Texture = Texture.from_color(255, 0, 255)
        self._missing_lightmap: Texture = Texture.from_color(0, 0, 0)
        self._loading_texture: Texture = Texture.from_color(128, 128, 128)

        self.textures: CaseInsensitiveDict[Texture] = CaseInsensitiveDict()
        self.textures["NULL"] = Texture.from_color()
        self.models: CaseInsensitiveDict[Model] = CaseInsensitiveDict()
        self.table_doors = TwoDA()
        self.table_placeables = TwoDA()
        self.table_creatures = TwoDA()
        self.table_heads = TwoDA()
        self.table_baseitems = TwoDA()

        self.installation: Installation | None = installation
        if installation is not None:
            self.set_installation(installation)

        # World-space anchor under the mouse ray (legacy name: "cursor").
        # IMPORTANT: This is *not* the camera/view focus point.
        self.mouse_world_anchor: RenderObject = RenderObject("cursor")
        # Backwards-compatible alias used by the toolset code.
        self.cursor: RenderObject = self.mouse_world_anchor
        self.objects: dict[Any, RenderObject] = {}

        self.selection: list[RenderObject] = []
        self._module: Module | None = module
        self.camera: Camera = Camera()

        self.git: GIT | None = None
        self.layout: LYT | None = None
        self.clear_cache_buffer: list[ResourceIdentifier] = []

        # Texture lookup tracking (for UI/debugging)
        # Populated by the existing async resolver (main process) + completion polling.
        # NOTE: Do NOT add additional installation.location(...) calls just for UI.
        self.texture_lookup_info: dict[str, dict[str, Any]] = {}
        self.requested_texture_names: set[str] = set()

        # Async resource loading
        # Main thread: Use Installation to RESOLVE file locations ONLY (no Installation in child!)
        # Child process: Do raw file IO + parsing (one process per file)

        # Pre-compute search location lists to avoid creating new lists on every call
        # Include all texture packs so creature/world textures resolve correctly.
        texture_search_locs: list[SearchLocation] = [
            SearchLocation.OVERRIDE,
            SearchLocation.TEXTURES_TPA,
            SearchLocation.TEXTURES_TPB,
            SearchLocation.TEXTURES_TPC,
            SearchLocation.TEXTURES_GUI,
            SearchLocation.CHITIN,
        ]
        model_search_locs = [SearchLocation.OVERRIDE, SearchLocation.CHITIN]
        # Cache capsules list for model loading (computed once per scene, not per model)
        _cached_capsules: list[Capsule] | None = None

        def _infer_search_location(filepath_str: str) -> SearchLocation | None:
            """Infer SearchLocation from the resolved filepath (no additional lookups).

            This is used purely for reporting/UI: we already have the resolved filepath
            from the existing Installation.location() call.
            """
            path_lower = filepath_str.lower().replace("\\", "/")
            if "/override/" in path_lower:
                return SearchLocation.OVERRIDE
            if "/modules/" in path_lower:
                return SearchLocation.MODULES
            if "/texturepacks/" in path_lower:
                # Typical filenames: swpc_tex_tpa.erf, swpc_tex_tpb.erf, swpc_tex_tpc.erf, swpc_tex_gui.erf
                if "tex_gui" in path_lower or "_gui" in path_lower:
                    return SearchLocation.TEXTURES_GUI
                if "tex_tpa" in path_lower or "_tpa" in path_lower:
                    return SearchLocation.TEXTURES_TPA
                if "tex_tpb" in path_lower or "_tpb" in path_lower:
                    return SearchLocation.TEXTURES_TPB
                if "tex_tpc" in path_lower or "_tpc" in path_lower:
                    return SearchLocation.TEXTURES_TPC
            if "/data/" in path_lower or "chitin.key" in path_lower:
                return SearchLocation.CHITIN
            return None

        def _resolve_texture_location(name: str) -> tuple[str, int, int, int] | None:
            """Resolve texture file location in main thread using Installation ONLY.

            Returns (filepath, offset, size) or None.
            """
            if self.installation is None:
                self.texture_lookup_info[name] = {
                    "found": False,
                    "filepath": None,
                    "restype": None,
                    "source_location": None,
                    "search_order": list(texture_search_locs),
                    "error": "No installation set on Scene",
                }
                return None

            # Single call: find texture by trying all supported texture restypes.
            # This avoids extra location() calls while still reporting the real restype (TPC/TGA/DDS).
            restypes: list[ResourceType] = list(getattr(self.installation, "TEXTURES_TYPES", (ResourceType.TPC, ResourceType.TGA, ResourceType.DDS)))
            found_loc = None
            found_restype = None
            locs_by_query = self.installation.locations(([name], restypes), list(texture_search_locs))
            for rt in restypes:
                query = ResourceIdentifier(name, rt)
                locs = locs_by_query.get(query, [])
                if locs:
                    found_loc = locs[0]
                    found_restype = rt
                    break

            if found_loc is not None and found_restype is not None:
                loc_path_str = str(found_loc.filepath)
                resolved_source = _infer_search_location(loc_path_str)
                self.texture_lookup_info[name] = {
                    "found": True,
                    "filepath": loc_path_str,
                    "offset": found_loc.offset,
                    "size": found_loc.size,
                    "restype": found_restype.extension,
                    "source_location": resolved_source,
                    "search_order": list(texture_search_locs),
                    "error": None,
                }
                RobustLogger().debug(
                    "SceneBase: resolved texture '%s.%s' -> %s (offset=%s size=%s, source=%s, search_order=%s)",
                    name,
                    found_restype.extension,
                    loc_path_str,
                    found_loc.offset,
                    found_loc.size,
                    getattr(resolved_source, "name", resolved_source),
                    [getattr(x, "name", str(x)) for x in texture_search_locs],
                )
                return (str(found_loc.filepath), found_loc.offset, found_loc.size, found_restype.type_id)
            self.texture_lookup_info[name] = {
                "found": False,
                "filepath": None,
                "restype": None,
                "source_location": None,
                "search_order": list(texture_search_locs),
                "error": f"Texture '{name}' not found",
            }
            RobustLogger().debug(
                "SceneBase: failed to resolve texture '%s' (search_order=%s)",
                name,
                [getattr(x, "name", str(x)) for x in texture_search_locs],
            )
            return None

        def _resolve_model_location(name: str) -> tuple[tuple[str, int, int] | None, tuple[str, int, int] | None]:
            """Resolve model file locations in main thread using Installation ONLY.

            Returns ((mdl_path, offset, size), (mdx_path, offset, size)) or (None, None).
            """
            nonlocal _cached_capsules
            if self.installation is None:
                return (None, None)

            # Cache capsules list for this scene (avoids repeated calls to capsules())
            if _cached_capsules is None and self._module is not None:
                module_capsules = self._module.capsules()
                _cached_capsules = list(module_capsules) if module_capsules else []

            capsules = _cached_capsules or []

            # Installation.location() returns list[LocationResult] with offset/size fields
            mdl_locs = self.installation.location(name, ResourceType.MDL, model_search_locs, capsules=capsules)
            mdx_locs = self.installation.location(name, ResourceType.MDX, model_search_locs, capsules=capsules)

            mdl_loc = None
            mdx_loc = None

            if mdl_locs:
                loc = mdl_locs[0]
                mdl_loc = (str(loc.filepath), loc.offset, loc.size)

            if mdx_locs:
                loc = mdx_locs[0]
                mdx_loc = (str(loc.filepath), loc.offset, loc.size)

            return (mdl_loc, mdx_loc)

        # Now assign the actual AsyncResourceLoader instance
        self.async_loader = AsyncResourceLoader(
            texture_location_resolver=_resolve_texture_location,
            model_location_resolver=_resolve_model_location,
        )
        # Type narrowing: async_loader is guaranteed to be non-None after assignment
        assert self.async_loader is not None, "AsyncResourceLoader is not initialized" # Type narrowing for type checker
        self.async_loader.start()
        self._pending_texture_futures: dict[str, Future[tuple[str, IntermediateTexture | None, str | None]]] = {}  # name -> Future
        self._pending_model_futures: dict[str, Future[tuple[str, IntermediateModel | None, str | None]]] = {}  # name -> Future

        self.hide_creatures: bool = False
        self.hide_placeables: bool = False
        self.hide_doors: bool = False
        self.hide_triggers: bool = False
        self.hide_encounters: bool = False
        self.hide_waypoints: bool = False
        self.hide_sounds: bool = False
        self.hide_stores: bool = False
        self.hide_cameras: bool = False
        self.hide_sound_boundaries: bool = True
        self.hide_trigger_boundaries: bool = True
        self.hide_encounter_boundaries: bool = True
        self.backface_culling: bool = True
        self.use_lightmap: bool = True
        self.use_wireframe: bool = False
        # When True, picker includes objects hidden by type (e.g. hide_creatures) so they can be selected.
        self.pick_include_hidden: bool = False
        # Visibility of the 3D axis gizmo that marks the camera focal point.
        # Historical API name is `show_cursor`; keep that alias for compatibility.
        self._show_focus_point_gizmo: bool = True

    @property
    def is_shutdown(self) -> bool:
        return self._shutdown

    @property
    def show_focus_point_gizmo(self) -> bool:
        """Show/hide the camera focal-point gizmo."""
        return self._show_focus_point_gizmo

    @show_focus_point_gizmo.setter
    def show_focus_point_gizmo(self, value: bool) -> None:
        self._show_focus_point_gizmo = bool(value)

    @property
    def show_cursor(self) -> bool:
        """Legacy alias for `show_focus_point_gizmo`.

        The name predates current behavior and is kept for API compatibility.
        It now controls the *camera focal-point gizmo* visibility, not a mouse cursor.
        """
        return self._show_focus_point_gizmo

    @show_cursor.setter
    def show_cursor(self, value: bool) -> None:
        self._show_focus_point_gizmo = bool(value)

    def camera_focal_point(self) -> Vector3:
        """Return the world-space camera focal/orbit point.

        This is the current view focus used by camera orbit logic.
        It is intentionally separate from `self.cursor`/`self.mouse_world_anchor`,
        which track mouse-projected world coordinates for placement operations.
        """
        return Vector3(self.camera.x, self.camera.y, self.camera.z)

    def shutdown(self, *, wait: bool = False):
        """Release non-GL resources and stop background workers.

        This is safe to call multiple times.
        """
        if self._shutdown:
            return
        self._shutdown = True

        # Cancel pending futures early (these can outlive the widget otherwise).
        try:
            self.invalidate_cache()
        except Exception:  # noqa: BLE001
            RobustLogger().debug("SceneBase.shutdown: invalidate_cache failed", exc_info=True)

        # Stop worker processes. Do not block by default; Qt teardown may be time-sensitive.
        try:
            if self.async_loader is not None:
                self.async_loader.shutdown(wait=wait)
        except Exception:  # noqa: BLE001
            RobustLogger().debug("SceneBase.shutdown: async_loader.shutdown failed", exc_info=True)
        finally:
            self.async_loader = None

    def __del__(self):
        """Cleanup async resources when scene is destroyed."""
        try:
            # __del__ can run during Qt teardown; keep this fast and exception-safe.
            self.shutdown(wait=False)
            RobustLogger().debug("Scene cleanup: shutdown async loader")
        except Exception as e:  # noqa: BLE001, S110
            # Log but don't raise exceptions in __del__
            # NOTE: With strict type checking, async_loader should always exist after __init__
            RobustLogger().warning(f"Exception during scene cleanup: {e}", exc_info=True)

    def set_lyt(self, lyt: LYT):
        self.layout = lyt

    def set_installation(
        self,
        installation: Installation,
    ):
        def load_2da(name: str) -> TwoDA:
            resource: ResourceResult | None = installation.resource(name, ResourceType.TwoDA, SEARCH_ORDER_2DA)
            if resource is None:
                RobustLogger().warning(f"Could not load {name}.2da, this means its models will not be rendered")
                return TwoDA()
            return read_2da(resource.data)

        for attr_name, filename in self._TWODA_TABLES.items():
            setattr(self, attr_name, load_2da(filename))

    def get_creature_render_object(  # noqa: C901
        self,
        instance: GITCreature | None = None,
        utc: UTC | None = None,
        *,
        sync: bool = False,
    ) -> RenderObject:
        """Get a RenderObject for a creature.

        Args:
            instance: Optional GITCreature instance to get the UTC from.
            utc: Optional UTC object to use directly.
            sync: If True, force synchronous model loading (required for hook finding).
        """
        assert self.installation is not None
        try:
            if instance is not None and utc is None:
                utc = self._resource_from_gitinstance(instance, self.module.creature)
            if utc is None:
                if instance is not None:
                    RobustLogger().warning(f"Could not get UTC for GITCreature instance '{instance.identifier()}', not found in mod/override.")
                else:
                    RobustLogger().warning("Could not get UTC for GITCreature, no instance provided.")
                return RenderObject("unknown", data=instance)

            head_obj: RenderObject | None = None
            mask_hook = None

            body_model, body_texture = creature.get_body_model(
                utc,
                self.installation,
                appearance=self.table_creatures,
                baseitems=self.table_baseitems,
            )
            if not body_model or not body_model.strip():
                raise ValueError("creature.get_body_model failed to return a valid body_model resref str.")  # noqa: TRY301
            head_model, head_texture = creature.get_head_model(
                utc,
                self.installation,
                appearance=self.table_creatures,
                heads=self.table_heads,
            )
            rhand_model, lhand_model = creature.get_weapon_models(
                utc,
                self.installation,
                appearance=self.table_creatures,
                baseitems=self.table_baseitems,
            )
            mask_model = creature.get_mask_model(
                utc,
                self.installation,
            )

            obj = RenderObject(body_model, data=instance, override_texture=body_texture)

            # Use synchronous model loading for hook finding to ensure model is loaded
            body_model_obj: Model = self.model_sync(body_model) if sync else self.model(body_model)
            head_hook: Node | None = body_model_obj.find("headhook")
            if head_model and head_hook:
                head_obj = RenderObject(head_model, override_texture=head_texture)
                head_obj.set_transform(head_hook.global_transform())
                obj.children.append(head_obj)

            rhand_hook: Node | None = body_model_obj.find("rhand")
            if rhand_model and rhand_hook:
                # Pre-load weapon model synchronously when sync=True to ensure it's renderable immediately
                if sync:
                    self.model_sync(rhand_model)
                rhand_obj = RenderObject(rhand_model)
                rhand_obj.set_transform(rhand_hook.global_transform())
                obj.children.append(rhand_obj)
            lhand_hook: Node | None = body_model_obj.find("lhand")
            if lhand_model and lhand_hook:
                # Pre-load weapon model synchronously when sync=True to ensure it's renderable immediately
                if sync:
                    self.model_sync(lhand_model)
                lhand_obj = RenderObject(lhand_model)
                lhand_obj.set_transform(lhand_hook.global_transform())
                obj.children.append(lhand_obj)
            if head_hook is None:
                mask_hook = body_model_obj.find("gogglehook")
            elif head_model:
                head_model_obj: Model = self.model_sync(head_model) if sync else self.model(head_model)
                mask_hook = head_model_obj.find("gogglehook")
            if mask_model and mask_hook:
                # Pre-load mask model synchronously when sync=True to ensure it's renderable immediately
                if sync:
                    self.model_sync(mask_model)
                mask_obj = RenderObject(mask_model)
                mask_obj.set_transform(mask_hook.global_transform())
                if head_hook is None:
                    obj.children.append(mask_obj)
                elif head_obj is not None:
                    head_obj.children.append(mask_obj)

        except Exception:  # noqa: BLE001
            RobustLogger().exception("Exception occurred getting the creature render object.")
            # If failed to load creature models, use the unknown model instead
            obj = RenderObject("unknown", data=instance)

        return obj

    @property
    def module(self) -> Module:
        if not self._module:
            raise RuntimeError("Module must be defined before a Scene can be rendered.")
        return self._module

    @module.setter
    def module(self, value: Module):
        self._module = value

    def _get_git(self) -> GIT:
        module_resource_git: ModuleResource[GIT] | None = self.module.git()
        result: GIT | None = self._resource_from_module(module_resource_git, "' is missing a GIT.")
        if result is None:
            RobustLogger().warning(f"Module '{self.module.root()}' is missing a GIT.")
            return GIT()
        return result

    def _get_lyt(self) -> LYT:
        layout_module_resource: ModuleResource[LYT] | None = self.module.layout()
        result: LYT | None = self._resource_from_module(layout_module_resource, "' is missing a LYT.")
        if result is None:
            RobustLogger().warning(f"Module '{self.module.root()}' is missing a LYT.")
            return LYT()
        return result

    def _get_ifo(self) -> IFO:
        info_module_resource: ModuleResource[IFO] | None = self.module.info()
        result: IFO | None = self._resource_from_module(info_module_resource, "' is missing an IFO.")
        if result is None:
            RobustLogger().warning(f"Module '{self.module.root()}' is missing an IFO.")
            return IFO()
        return result

    def _resource_from_module(
        self,
        module_res: ModuleResource[T] | None,
        errpart: str,
    ) -> T | None:
        if module_res is None:
            RobustLogger().error(f"Cannot render a frame in Scene when this module '{self.module.root()}{errpart}")
            return None
        resource: T | None = module_res.resource()
        if resource is None:
            RobustLogger().error(f"No locations found for '{module_res.identifier()}', needed to render a Scene for module '{self.module.root()}'")
            return None
        return resource

    def _resource_from_gitinstance(
        self,
        instance: GITInstance,
        lookup_func: Callable[..., ModuleResource[T] | None],
    ) -> T | None:
        resource: ModuleResource[T] | None = lookup_func(str(instance.resref))
        if resource is None:
            RobustLogger().error(f"The module '{self.module.root()}' does not store '{instance.identifier()}' needed to render a Scene.")
            return None
        resource_data: T | None = resource.resource()
        if resource_data is None:
            RobustLogger().error(f"No locations found for '{resource.identifier()}' needed by module '{self.module.root()}'")
            return None
        return resource_data

    def jump_to_entry_location(self):
        if self._module is None:
            self.camera.x = 0
            self.camera.y = 0
            self.camera.z = 0
        else:
            point: Vector3 = self._get_ifo().entry_position
            self.camera.x = point.x
            self.camera.y = point.y
            self.camera.z = point.z + 1.8

    def invalidate_cache(self):
        """Invalidate resource caches and cancel pending async operations."""
        # Cancel pending operations
        for future in self._pending_texture_futures.values():
            future.cancel()
        for future in self._pending_model_futures.values():
            future.cancel()

        self._pending_texture_futures.clear()
        self._pending_model_futures.clear()

        # Clear caches (but keep predefined models/textures)
        predefined_models = {"waypoint", "sound", "store", "entry", "encounter", "trigger", "camera", "empty", "cursor", "unknown"}
        self.models = CaseInsensitiveDict({k: v for k, v in self.models.items() if k in predefined_models})
        # Preserve the existing NULL texture; do NOT call Texture.from_color() here.
        # This method can run from __del__ via GC at unpredictable times (e.g. during
        # another scene's render loop) when the GL context may be invalid or belong to
        # a different widget.  Any GL call (glGenTextures, glBindTexture, glGetError …)
        # in that situation corrupts state and eventually crashes the driver.
        null_tex = self.textures.get("NULL")
        self.textures = CaseInsensitiveDict()
        if null_tex is not None:
            self.textures["NULL"] = null_tex

        RobustLogger().debug("Invalidated resource cache")

    def poll_async_resources(self, *, max_textures_per_frame: int = 8, max_models_per_frame: int = 4):
        """Poll for completed async resource loading and create OpenGL objects.

        MUST be called from main thread with active OpenGL context.
        This is non-blocking and processes only completed futures.

        Args:
            max_textures_per_frame: Max textures to upload to GPU per frame (prevents frame spikes)
            max_models_per_frame: Max models to upload to GPU per frame (prevents frame spikes)
        """
        # Fast path: nothing to poll
        if not self._pending_texture_futures and not self._pending_model_futures:
            return

        # Check completed texture futures (limited per frame to prevent stuttering)
        completed_textures: list[str] = []
        textures_processed = 0
        for name, future in self._pending_texture_futures.items():
            if textures_processed >= max_textures_per_frame:
                break  # Process more next frame
            if future.done():
                try:
                    resource_name, intermediate, error = future.result()
                    if error:
                        # Only log at debug level for missing textures (very common)
                        if "not found" in error:
                            RobustLogger().debug(f"Texture not found: '{resource_name}'")
                        else:
                            RobustLogger().warning(f"Async texture load failed for '{resource_name}': {error}")
                        self.textures[resource_name] = self._missing_texture  # Magenta placeholder
                        # Persist status for UI/debugging (no additional lookups)
                        info = self.texture_lookup_info.setdefault(resource_name, {})
                        info.update({"loaded": False, "load_error": error})
                        RobustLogger().debug(
                            "SceneBase: texture async complete '%s' (loaded=False, error=%s)",
                            resource_name,
                            error,
                        )
                    elif intermediate:
                        self.textures[resource_name] = create_texture_from_intermediate(intermediate)
                        info = self.texture_lookup_info.setdefault(resource_name, {})
                        info.update({"loaded": True, "load_error": None})
                        RobustLogger().debug("SceneBase: texture async complete '%s' (loaded=True)", resource_name)
                    completed_textures.append(name)
                    textures_processed += 1
                except Exception:  # noqa: BLE001
                    RobustLogger().exception(f"Error processing completed texture future for '{name}'")
                    self.textures[name] = self._missing_texture
                    info = self.texture_lookup_info.setdefault(name, {})
                    info.update({"loaded": False, "load_error": "Exception while processing completed texture future"})
                    completed_textures.append(name)
                    textures_processed += 1

        for name in completed_textures:
            del self._pending_texture_futures[name]

        # Check completed model futures (limited per frame)
        completed_models: list[str] = []
        models_processed = 0
        for name, future in self._pending_model_futures.items():
            if models_processed >= max_models_per_frame:
                break  # Process more next frame
            if future.done():
                try:
                    resource_name, intermediate, error = future.result()
                    if error:
                        # Only log at debug level for missing models
                        if "not found" in error:
                            RobustLogger().debug(f"Model not found: '{resource_name}'")
                        else:
                            RobustLogger().warning(f"Async model load failed for '{resource_name}': {error}")
                        # Load empty model as fallback
                        self.models[resource_name] = gl_load_stitched_model(
                            self,  # pyright: ignore[reportArgumentType]
                            BinaryReader.from_bytes(EMPTY_MDL_DATA, 12),
                            BinaryReader.from_bytes(EMPTY_MDX_DATA),
                        )
                    elif intermediate:
                        self.models[resource_name] = create_model_from_intermediate(self, intermediate)
                    completed_models.append(name)
                    models_processed += 1
                except Exception:  # noqa: BLE001
                    RobustLogger().exception(f"Error processing completed model future for '{name}'")
                    self.models[name] = gl_load_stitched_model(
                        self,  # pyright: ignore[reportArgumentType]
                        BinaryReader.from_bytes(EMPTY_MDL_DATA, 12),
                        BinaryReader.from_bytes(EMPTY_MDX_DATA),
                    )
                    completed_models.append(name)
                    models_processed += 1

        for name in completed_models:
            del self._pending_model_futures[name]

    def texture(
        self,
        name: str,
        *,
        lightmap: bool = False,
    ) -> Texture:
        # Already cached?
        if name in self.textures:
            tex = self.textures[name]
            if tex is self._missing_texture and lightmap:
                return self._missing_lightmap
            return tex

        # Already loading?
        if name in self._pending_texture_futures:
            # Return placeholder while loading
            return self._loading_texture

        # Start async loading if location resolver available
        if self.async_loader is not None and self.async_loader.texture_location_resolver is not None:
            # Track requests for UI/debugging (names only). Resolution details are stored by the resolver.
            self.requested_texture_names.add(name)
            RobustLogger().debug("SceneBase: requesting texture '%s' (lightmap=%s)", name, lightmap)
            future = self.async_loader.load_texture_async(name)
            self._pending_texture_futures[name] = future
            # Return gray placeholder immediately
            return self._loading_texture

        # Fallback to synchronous loading (e.g., if process pools unavailable)
        type_name: Literal["lightmap", "texture"] = "lightmap" if lightmap else "texture"
        tpc: TPC | None = None
        try:
            # Check the textures linked to the module first
            if self._module is not None:
                RobustLogger().debug(f"Locating {type_name} '{name}' in module '{self.module.root()}'")
                module_tex: ModuleResource[TPC] | None = self.module.texture(name)
                if module_tex is not None:
                    RobustLogger().debug(f"Loading {type_name} '{name}' from module '{self.module.root()}'")
                    tpc = module_tex.resource()

            # Otherwise just search through all relevant game files
            if tpc is None and self.installation is not None:
                RobustLogger().debug(f"Locating and loading {type_name} '{name}' from override/bifs/texturepacks...")
                tpc = self.installation.texture(
                    name,
                    [
                        SearchLocation.OVERRIDE,
                        SearchLocation.TEXTURES_TPA,
                        SearchLocation.TEXTURES_TPB,
                        SearchLocation.TEXTURES_TPC,
                        SearchLocation.TEXTURES_GUI,
                        SearchLocation.CHITIN,
                    ],
                )
            if tpc is None:
                RobustLogger().warning(f"MISSING {type_name.upper()}: '{name}'")
        except Exception:  # noqa: BLE001
            RobustLogger().warning(f"Could not load {type_name} '{name}'.")

        if tpc is None:
            self.textures[name] = self._missing_texture
            return self._missing_lightmap if lightmap else self._missing_texture

        self.textures[name] = Texture.from_tpc(tpc)
        return self.textures[name]

    def model_sync(
        self,
        name: str,
    ) -> Model:
        """Load a model synchronously, bypassing async loading.

        This is useful when you need the model immediately (e.g., for finding hooks).
        The model will be cached for future use.

        Args:
            name: The model name to load.

        Returns:
            The loaded Model object.
        """
        # Already cached?
        if name in self.models:
            return self.models[name]

        # Cancel any pending async load for this model
        if name in self._pending_model_futures:
            self._pending_model_futures[name].cancel()
            del self._pending_model_futures[name]

        # Handle predefined models
        predefined_models: dict[str, tuple[bytes, bytes]] = {
            "waypoint": (WAYPOINT_MDL_DATA, WAYPOINT_MDX_DATA),
            "sound": (SOUND_MDL_DATA, SOUND_MDX_DATA),
            "store": (STORE_MDL_DATA, STORE_MDX_DATA),
            "entry": (ENTRY_MDL_DATA, ENTRY_MDX_DATA),
            "encounter": (ENCOUNTER_MDL_DATA, ENCOUNTER_MDX_DATA),
            "trigger": (TRIGGER_MDL_DATA, TRIGGER_MDX_DATA),
            "camera": (CAMERA_MDL_DATA, CAMERA_MDX_DATA),
            "empty": (EMPTY_MDL_DATA, EMPTY_MDX_DATA),
            "cursor": (CURSOR_MDL_DATA, CURSOR_MDX_DATA),
            "unknown": (UNKNOWN_MDL_DATA, UNKNOWN_MDX_DATA),
        }

        if name in predefined_models:
            mdl_data, mdx_data = predefined_models[name]
            try:
                mdl_reader: BinaryReader = BinaryReader.from_bytes(mdl_data, 12)
                mdx_reader: BinaryReader = BinaryReader.from_bytes(mdx_data)
                model: Model = gl_load_stitched_model(self, mdl_reader, mdx_reader)  # pyright: ignore[reportArgumentType]
                self.models[name] = model
                return model
            except Exception:  # noqa: BLE001
                RobustLogger().exception(f"Could not load predefined model '{name}'.")

        # Synchronous loading from installation
        fallback_mdl_data: bytes = EMPTY_MDL_DATA
        fallback_mdx_data: bytes = EMPTY_MDX_DATA

        if self.installation is not None:
            capsules: list[ModulePieceResource] = [] if self._module is None else self.module.capsules()
            mdl_search: ResourceResult | None = self.installation.resource(name, ResourceType.MDL, SEARCH_ORDER, capsules=capsules)
            mdx_search: ResourceResult | None = self.installation.resource(name, ResourceType.MDX, SEARCH_ORDER, capsules=capsules)
            if mdl_search is not None and mdx_search is not None:
                fallback_mdl_data = mdl_search.data
                fallback_mdx_data = mdx_search.data
            else:
                RobustLogger().warning(f"Model '{name}' not found in installation (MDL: {mdl_search is not None}, MDX: {mdx_search is not None})")

        try:
            mdl_reader = BinaryReader.from_bytes(fallback_mdl_data, 12)
            mdx_reader = BinaryReader.from_bytes(fallback_mdx_data)
            model = gl_load_stitched_model(self, mdl_reader, mdx_reader)  # pyright: ignore[reportArgumentType]
        except Exception:  # noqa: BLE001
            RobustLogger().warning(f"Could not load model '{name}'.")
            model = gl_load_stitched_model(
                self,  # pyright: ignore[reportArgumentType]
                BinaryReader.from_bytes(EMPTY_MDL_DATA, 12),
                BinaryReader.from_bytes(EMPTY_MDX_DATA),
            )

        self.models[name] = model
        return model

    def model(  # noqa: C901, PLR0912
        self,
        name: str,
    ) -> Model:
        # Already cached?
        if name in self.models:
            return self.models[name]

        # Special/predefined models - load synchronously
        predefined_models: dict[str, tuple[bytes, bytes]] = {
            "waypoint": (WAYPOINT_MDL_DATA, WAYPOINT_MDX_DATA),
            "sound": (SOUND_MDL_DATA, SOUND_MDX_DATA),
            "store": (STORE_MDL_DATA, STORE_MDX_DATA),
            "entry": (ENTRY_MDL_DATA, ENTRY_MDX_DATA),
            "encounter": (ENCOUNTER_MDL_DATA, ENCOUNTER_MDX_DATA),
            "trigger": (TRIGGER_MDL_DATA, TRIGGER_MDX_DATA),
            "camera": (CAMERA_MDL_DATA, CAMERA_MDX_DATA),
            "empty": (EMPTY_MDL_DATA, EMPTY_MDX_DATA),
            "cursor": (CURSOR_MDL_DATA, CURSOR_MDX_DATA),
            "unknown": (UNKNOWN_MDL_DATA, UNKNOWN_MDX_DATA),
        }

        if name in predefined_models:
            mdl_data, mdx_data = predefined_models[name]
            try:
                mdl_reader: BinaryReader = BinaryReader.from_bytes(mdl_data, 12)
                mdx_reader: BinaryReader = BinaryReader.from_bytes(mdx_data)
                model: Model = gl_load_stitched_model(self, mdl_reader, mdx_reader)  # pyright: ignore[reportArgumentType]
                self.models[name] = model
                return model
            except Exception:  # noqa: BLE001
                RobustLogger().exception(f"Could not load predefined model '{name}'.")
                # Fall through to empty model

        # Already loading?
        if name in self._pending_model_futures:
            # Return empty model placeholder while loading
            if "empty" not in self.models:
                empty_model = gl_load_stitched_model(
                    self,  # pyright: ignore[reportArgumentType]
                    BinaryReader.from_bytes(EMPTY_MDL_DATA, 12),
                    BinaryReader.from_bytes(EMPTY_MDX_DATA),
                )
                self.models["empty"] = empty_model
            return self.models["empty"]

        # Start async loading if location resolver available
        if self.async_loader is not None and self.async_loader.model_location_resolver is not None:
            future = self.async_loader.load_model_async(name)
            self._pending_model_futures[name] = future
            # Return empty model immediately as placeholder
            if "empty" not in self.models:
                empty_model = gl_load_stitched_model(
                    self,  # pyright: ignore[reportArgumentType]
                    BinaryReader.from_bytes(EMPTY_MDL_DATA, 12),
                    BinaryReader.from_bytes(EMPTY_MDX_DATA),
                )
                self.models["empty"] = empty_model
            return self.models["empty"]

        # Fallback to synchronous loading
        fallback_mdl_data: bytes = EMPTY_MDL_DATA
        fallback_mdx_data: bytes = EMPTY_MDX_DATA

        if self.installation is not None:
            capsules: list[ModulePieceResource] = [] if self._module is None else self.module.capsules()
            mdl_search: ResourceResult | None = self.installation.resource(name, ResourceType.MDL, SEARCH_ORDER, capsules=capsules)
            mdx_search: ResourceResult | None = self.installation.resource(name, ResourceType.MDX, SEARCH_ORDER, capsules=capsules)
            if mdl_search is not None and mdx_search is not None:
                fallback_mdl_data = mdl_search.data
                fallback_mdx_data = mdx_search.data

        try:
            mdl_reader = BinaryReader.from_bytes(fallback_mdl_data, 12)
            mdx_reader = BinaryReader.from_bytes(fallback_mdx_data)
            model = gl_load_stitched_model(self, mdl_reader, mdx_reader)  # pyright: ignore[reportArgumentType]
        except Exception:  # noqa: BLE001
            RobustLogger().warning(f"Could not load model '{name}'.")
            model = gl_load_stitched_model(
                self,  # pyright: ignore[reportArgumentType]
                BinaryReader.from_bytes(EMPTY_MDL_DATA, 12),
                BinaryReader.from_bytes(EMPTY_MDX_DATA),
            )

        self.models[name] = model
        return model
