"""
pygine3d.py — A single-file importable 3D game engine for Python
================================================================
Dependencies:  pip install pygame PyOpenGL PyOpenGL_accelerate numpy pillow

Quick start:
    import pygine3d

    engine = pygine3d.Engine(800, 600, "My Game")
    scene  = engine.scene

    # Spawn a cube
    cube = scene.spawn("Cube")
    cube.add_component(pygine3d.MeshRenderer(pygine3d.Mesh.cube()))
    cube.transform.position = pygine3d.Vec3(0, 0, -5)

    # Lighting
    scene.ambient_light  = pygine3d.Color(0.2, 0.2, 0.2)
    scene.directional_light = pygine3d.DirectionalLight(
        direction=pygine3d.Vec3(-1, -1, -1),
        color=pygine3d.Color(1.0, 0.95, 0.9)
    )

    # Fly-cam
    engine.camera = pygine3d.FlyCamera(speed=5.0)

    engine.run()
"""

import sys, os, math, struct, time, ctypes
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Callable, Tuple, Any

# Suppress pygame's "Hello from the pygame community" message
os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1")

__version__ = "1.1.2"

def _print_banner():
    lines = [
        f"[PyGine3D] Loaded Library! v{__version__}",
        "[PyGine3D] Backend: Pygame + OpenGL 3.3 Core",
        "[PyGine3D] Dependencies: numpy, pillow, PyOpenGL",
    ]
    width = max(len(l) for l in lines) + 4
    print("┌" + "─" * width + "┐")
    for l in lines:
        print("│  " + l + " " * (width - len(l) - 2) + "│")
    print("└" + "─" * width + "┘")

_print_banner()

# ── optional deps – fail with a helpful message ────────────────────────────
def _require(pkg, install_name=None):
    try:
        return __import__(pkg)
    except ImportError:
        name = install_name or pkg
        sys.exit(f"[pygine3d] Missing dependency: {name}\n  → pip install {name}")

pygame   = _require("pygame")
np       = _require("numpy")
PIL      = _require("PIL", "pillow")

import pygame
import numpy as np
from PIL import Image

from OpenGL.GL import (
    GL_ARRAY_BUFFER,
    GL_BACK,
    GL_BLEND,
    GL_COLOR_BUFFER_BIT,
    GL_CULL_FACE,
    GL_DEPTH_BUFFER_BIT,
    GL_DEPTH_TEST,
    GL_ELEMENT_ARRAY_BUFFER,
    GL_FALSE,
    GL_FLOAT,
    GL_FRAGMENT_SHADER,
    GL_LINEAR,
    GL_LINEAR_MIPMAP_LINEAR,
    GL_MULTISAMPLE,
    GL_NEAREST,
    GL_ONE_MINUS_SRC_ALPHA,
    GL_REPEAT,
    GL_RGBA,
    GL_SRC_ALPHA,
    GL_STATIC_DRAW,
    GL_TEXTURE0,
    GL_TEXTURE_2D,
    GL_TEXTURE_MAG_FILTER,
    GL_TEXTURE_MIN_FILTER,
    GL_TEXTURE_WRAP_S,
    GL_TEXTURE_WRAP_T,
    GL_TRIANGLES,
    GL_TRUE,
    GL_UNSIGNED_BYTE,
    GL_UNSIGNED_INT,
    GL_VERTEX_SHADER,
    glActiveTexture,
    glBindBuffer,
    glBindTexture,
    glBindVertexArray,
    glBlendFunc,
    glBufferData,
    glClear,
    glClearColor,
    glCullFace,
    glDeleteBuffers,
    glDeleteTextures,
    glDeleteVertexArrays,
    glDisable,
    glDrawArrays,
    glDrawElements,
    glEnable,
    glEnableVertexAttribArray,
    glGenBuffers,
    glGenTextures,
    glGenVertexArrays,
    glGenerateMipmap,
    glGetUniformLocation,
    glTexImage2D,
    glTexParameteri,
    glUniform1f,
    glUniform1i,
    glUniform3f,
    glUniform4f,
    glUniformMatrix3fv,
    glUniformMatrix4fv,
    glUseProgram,
    glVertexAttribPointer,
    glViewport
)
from OpenGL.GL.shaders import compileShader, compileProgram

# ══════════════════════════════════════════════════════════════════════════════
#  Math primitives
# ══════════════════════════════════════════════════════════════════════════════

class Vec3:
    """Simple 3-component vector."""
    __slots__ = ("x", "y", "z")

    def __init__(self, x=0.0, y=0.0, z=0.0):
        self.x = float(x)
        self.y = float(y)
        self.z = float(z)

    # ── arithmetic ─────────────────────────────────────────────────────────
    def __add__(self, o):  return Vec3(self.x+o.x, self.y+o.y, self.z+o.z)
    def __sub__(self, o):  return Vec3(self.x-o.x, self.y-o.y, self.z-o.z)
    def __mul__(self, s):  return Vec3(self.x*s,   self.y*s,   self.z*s)
    def __rmul__(self, s): return self.__mul__(s)
    def __neg__(self):     return Vec3(-self.x, -self.y, -self.z)
    def __repr__(self):    return f"Vec3({self.x:.3f}, {self.y:.3f}, {self.z:.3f})"

    def dot(self, o):   return self.x*o.x + self.y*o.y + self.z*o.z
    def length(self):   return math.sqrt(self.dot(self))
    def normalized(self):
        l = self.length()
        return Vec3(self.x/l, self.y/l, self.z/l) if l > 1e-9 else Vec3(0,0,0)

    def cross(self, o):
        return Vec3(
            self.y*o.z - self.z*o.y,
            self.z*o.x - self.x*o.z,
            self.x*o.y - self.y*o.x
        )

    def to_list(self): return [self.x, self.y, self.z]
    def to_np(self):   return np.array([self.x, self.y, self.z], dtype=np.float32)

    @staticmethod
    def zero():    return Vec3(0,0,0)
    @staticmethod
    def one():     return Vec3(1,1,1)
    @staticmethod
    def up():      return Vec3(0,1,0)
    @staticmethod
    def forward(): return Vec3(0,0,-1)
    @staticmethod
    def right():   return Vec3(1,0,0)


@dataclass
class Color:
    r: float = 1.0
    g: float = 1.0
    b: float = 1.0
    a: float = 1.0
    def to_list(self): return [self.r, self.g, self.b, self.a]
    def to_rgb(self):  return [self.r, self.g, self.b]

    @staticmethod
    def white():  return Color(1,1,1,1)
    @staticmethod
    def black():  return Color(0,0,0,1)
    @staticmethod
    def red():    return Color(1,0,0,1)
    @staticmethod
    def green():  return Color(0,1,0,1)
    @staticmethod
    def blue():   return Color(0,0,1,1)


def _mat4_identity():
    return np.eye(4, dtype=np.float32)

def _mat4_translate(v: Vec3):
    m = _mat4_identity()
    m[0,3] = v.x; m[1,3] = v.y; m[2,3] = v.z
    return m

def _mat4_scale(v: Vec3):
    m = _mat4_identity()
    m[0,0] = v.x; m[1,1] = v.y; m[2,2] = v.z
    return m

def _mat4_rotate_x(a):
    c, s = math.cos(a), math.sin(a)
    m = _mat4_identity()
    m[1,1]=c; m[1,2]=-s; m[2,1]=s; m[2,2]=c
    return m

def _mat4_rotate_y(a):
    c, s = math.cos(a), math.sin(a)
    m = _mat4_identity()
    m[0,0]=c; m[0,2]=s; m[2,0]=-s; m[2,2]=c
    return m

def _mat4_rotate_z(a):
    c, s = math.cos(a), math.sin(a)
    m = _mat4_identity()
    m[0,0]=c; m[0,1]=-s; m[1,0]=s; m[1,1]=c
    return m

def _mat4_perspective(fov_deg, aspect, near, far):
    f = 1.0 / math.tan(math.radians(fov_deg) / 2.0)
    m = np.zeros((4,4), dtype=np.float32)
    m[0,0] = f / aspect
    m[1,1] = f
    m[2,2] = (far + near) / (near - far)
    m[2,3] = (2*far*near) / (near - far)
    m[3,2] = -1.0
    return m

def _mat4_look_at(eye: Vec3, center: Vec3, up: Vec3):
    f = (center - eye).normalized()
    r = f.cross(up).normalized()
    u = r.cross(f)
    m = np.array([
        [ r.x,  r.y,  r.z, -r.dot(eye)],
        [ u.x,  u.y,  u.z, -u.dot(eye)],
        [-f.x, -f.y, -f.z,  f.dot(eye)],
        [ 0,    0,    0,    1          ]
    ], dtype=np.float32)
    return m


# ══════════════════════════════════════════════════════════════════════════════
#  Transform
# ══════════════════════════════════════════════════════════════════════════════

class Transform:
    """Position, rotation (Euler degrees), and scale for a GameObject."""
    def __init__(self):
        self.position = Vec3(0, 0, 0)
        self.rotation = Vec3(0, 0, 0)   # Euler angles in degrees
        self.scale    = Vec3(1, 1, 1)

    def matrix(self) -> np.ndarray:
        rx = math.radians(self.rotation.x)
        ry = math.radians(self.rotation.y)
        rz = math.radians(self.rotation.z)
        T  = _mat4_translate(self.position)
        Rx = _mat4_rotate_x(rx)
        Ry = _mat4_rotate_y(ry)
        Rz = _mat4_rotate_z(rz)
        S  = _mat4_scale(self.scale)
        return T @ Ry @ Rx @ Rz @ S


# ══════════════════════════════════════════════════════════════════════════════
#  Mesh  (VAO/VBO backed)
# ══════════════════════════════════════════════════════════════════════════════

class Mesh:
    """
    Vertex layout: [x,y,z,  nx,ny,nz,  u,v]  → 8 floats per vertex
    Indices are unsigned ints.
    """
    def __init__(self, vertices: np.ndarray, indices: np.ndarray):
        self.vertices = np.array(vertices, dtype=np.float32)
        self.indices  = np.array(indices,  dtype=np.uint32)
        self._vao = None
        self._vbo = None
        self._ebo = None

    # ── GPU upload ─────────────────────────────────────────────────────────
    def _upload(self):
        if self._vao is not None:
            return
        self._vao = glGenVertexArrays(1)
        self._vbo = glGenBuffers(1)
        self._ebo = glGenBuffers(1)

        glBindVertexArray(self._vao)

        glBindBuffer(GL_ARRAY_BUFFER, self._vbo)
        glBufferData(GL_ARRAY_BUFFER, self.vertices.nbytes, self.vertices, GL_STATIC_DRAW)

        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, self._ebo)
        glBufferData(GL_ELEMENT_ARRAY_BUFFER, self.indices.nbytes, self.indices, GL_STATIC_DRAW)

        stride = 8 * 4  # 8 floats × 4 bytes
        # position
        glEnableVertexAttribArray(0)
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(0))
        # normal
        glEnableVertexAttribArray(1)
        glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(12))
        # uv
        glEnableVertexAttribArray(2)
        glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(24))

        glBindVertexArray(0)

    def draw(self):
        self._upload()
        glBindVertexArray(self._vao)
        glDrawElements(GL_TRIANGLES, len(self.indices), GL_UNSIGNED_INT, None)
        glBindVertexArray(0)

    def destroy(self):
        if self._vao:
            glDeleteVertexArrays(1, [self._vao])
            glDeleteBuffers(1, [self._vbo])
            glDeleteBuffers(1, [self._ebo])
            self._vao = None

    # ── Built-in shapes ────────────────────────────────────────────────────
    @staticmethod
    def cube():
        """Unit cube centred at origin."""
        # fmt: x y z  nx ny nz  u v
        v = np.array([
            # Front  +Z
            -0.5,-0.5, 0.5,  0, 0, 1,  0,0,
             0.5,-0.5, 0.5,  0, 0, 1,  1,0,
             0.5, 0.5, 0.5,  0, 0, 1,  1,1,
            -0.5, 0.5, 0.5,  0, 0, 1,  0,1,
            # Back   -Z
             0.5,-0.5,-0.5,  0, 0,-1,  0,0,
            -0.5,-0.5,-0.5,  0, 0,-1,  1,0,
            -0.5, 0.5,-0.5,  0, 0,-1,  1,1,
             0.5, 0.5,-0.5,  0, 0,-1,  0,1,
            # Right  +X
             0.5,-0.5, 0.5,  1, 0, 0,  0,0,
             0.5,-0.5,-0.5,  1, 0, 0,  1,0,
             0.5, 0.5,-0.5,  1, 0, 0,  1,1,
             0.5, 0.5, 0.5,  1, 0, 0,  0,1,
            # Left   -X
            -0.5,-0.5,-0.5, -1, 0, 0,  0,0,
            -0.5,-0.5, 0.5, -1, 0, 0,  1,0,
            -0.5, 0.5, 0.5, -1, 0, 0,  1,1,
            -0.5, 0.5,-0.5, -1, 0, 0,  0,1,
            # Top    +Y
            -0.5, 0.5, 0.5,  0, 1, 0,  0,0,
             0.5, 0.5, 0.5,  0, 1, 0,  1,0,
             0.5, 0.5,-0.5,  0, 1, 0,  1,1,
            -0.5, 0.5,-0.5,  0, 1, 0,  0,1,
            # Bottom -Y
            -0.5,-0.5,-0.5,  0,-1, 0,  0,0,
             0.5,-0.5,-0.5,  0,-1, 0,  1,0,
             0.5,-0.5, 0.5,  0,-1, 0,  1,1,
            -0.5,-0.5, 0.5,  0,-1, 0,  0,1,
        ], dtype=np.float32)

        idx = []
        for f in range(6):
            b = f * 4
            idx += [b,b+1,b+2, b,b+2,b+3]
        return Mesh(v, np.array(idx, dtype=np.uint32))

    @staticmethod
    def plane(size=1.0, divisions=1):
        """Flat XZ plane centred at origin."""
        half = size / 2
        n = divisions
        verts = []
        for j in range(n+1):
            for i in range(n+1):
                x = -half + i * size / n
                z = -half + j * size / n
                u = i / n
                v = j / n
                verts += [x, 0.0, z,  0,1,0,  u,v]
        idx = []
        for j in range(n):
            for i in range(n):
                tl = j*(n+1)+i
                tr = tl+1
                bl = tl+(n+1)
                br = bl+1
                idx += [tl,bl,tr, tr,bl,br]
        return Mesh(np.array(verts, dtype=np.float32),
                    np.array(idx,   dtype=np.uint32))

    @staticmethod
    def sphere(radius=0.5, rings=16, sectors=16):
        """UV sphere."""
        verts = []
        idx   = []
        for r in range(rings+1):
            lat = math.pi/2 - r * math.pi / rings
            y   = radius * math.sin(lat)
            xz  = radius * math.cos(lat)
            for s in range(sectors+1):
                lon = s * 2*math.pi / sectors
                x   = xz * math.cos(lon)
                z   = xz * math.sin(lon)
                nx, ny, nz = x/radius, y/radius, z/radius
                u = s / sectors
                v = r / rings
                verts += [x, y, z,  nx, ny, nz,  u, v]
        for r in range(rings):
            for s in range(sectors):
                cur  = r*(sectors+1)+s
                nxt  = cur + sectors+1
                idx += [cur, nxt, cur+1, cur+1, nxt, nxt+1]
        return Mesh(np.array(verts, dtype=np.float32),
                    np.array(idx,   dtype=np.uint32))

    @staticmethod
    def from_obj(path: str) -> "Mesh":
        """Load a .obj file (positions + normals + UVs)."""
        pos, nrm, uvs = [], [], []
        faces = []
        with open(path) as f:
            for line in f:
                tok = line.split()
                if not tok: continue
                if tok[0] == "v":
                    pos.append([float(t) for t in tok[1:4]])
                elif tok[0] == "vn":
                    nrm.append([float(t) for t in tok[1:4]])
                elif tok[0] == "vt":
                    uvs.append([float(t) for t in tok[1:3]])
                elif tok[0] == "f":
                    face = []
                    for t in tok[1:]:
                        parts = t.split("/")
                        pi = int(parts[0])-1
                        ti = int(parts[1])-1 if len(parts)>1 and parts[1] else 0
                        ni = int(parts[2])-1 if len(parts)>2 and parts[2] else 0
                        face.append((pi,ti,ni))
                    # fan-triangulate
                    for i in range(1, len(face)-1):
                        faces.append((face[0], face[i], face[i+1]))

        verts = []
        indices = []
        cache: Dict[tuple,int] = {}
        for tri in faces:
            for (pi,ti,ni) in tri:
                key = (pi,ti,ni)
                if key not in cache:
                    p = pos[pi]
                    n = nrm[ni] if nrm else [0,1,0]
                    u = uvs[ti] if uvs else [0,0]
                    verts += p + n + u
                    cache[key] = len(cache)
                indices.append(cache[key])
        return Mesh(np.array(verts, dtype=np.float32),
                    np.array(indices, dtype=np.uint32))


# ══════════════════════════════════════════════════════════════════════════════
#  Texture
# ══════════════════════════════════════════════════════════════════════════════

class Texture:
    def __init__(self, path: str):
        self._id = None
        self._path = path

    def _upload(self):
        if self._id is not None:
            return
        img  = Image.open(self._path).transpose(Image.FLIP_TOP_BOTTOM).convert("RGBA")
        data = img.tobytes()
        self._id = glGenTextures(1)
        glBindTexture(GL_TEXTURE_2D, self._id)
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.width, img.height,
                     0, GL_RGBA, GL_UNSIGNED_BYTE, data)
        glGenerateMipmap(GL_TEXTURE_2D)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
        glBindTexture(GL_TEXTURE_2D, 0)

    def bind(self, unit=0):
        self._upload()
        glActiveTexture(GL_TEXTURE0 + unit)
        glBindTexture(GL_TEXTURE_2D, self._id)

    def destroy(self):
        if self._id:
            glDeleteTextures(1, [self._id])
            self._id = None

    @staticmethod
    def solid_color(r=1.0, g=1.0, b=1.0, a=1.0) -> "Texture":
        """Create a 1×1 pixel texture with the given colour."""
        t = _SolidTexture(r, g, b, a)
        return t


class _SolidTexture(Texture):
    def __init__(self, r, g, b, a):
        super().__init__("")
        self._r, self._g, self._b, self._a = r, g, b, a

    def _upload(self):
        if self._id is not None:
            return
        data = bytes([
            int(self._r*255), int(self._g*255),
            int(self._b*255), int(self._a*255)
        ])
        self._id = glGenTextures(1)
        glBindTexture(GL_TEXTURE_2D, self._id)
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, data)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
        glBindTexture(GL_TEXTURE_2D, 0)


# ══════════════════════════════════════════════════════════════════════════════
#  Lighting
# ══════════════════════════════════════════════════════════════════════════════

@dataclass
class DirectionalLight:
    direction: Vec3  = field(default_factory=lambda: Vec3(-1,-1,-1))
    color:     Color = field(default_factory=Color.white)
    intensity: float = 1.0


# ══════════════════════════════════════════════════════════════════════════════
#  Material  (wraps a shader program + uniforms)
# ══════════════════════════════════════════════════════════════════════════════

_VERT_SRC = """
#version 330 core
layout(location=0) in vec3 aPos;
layout(location=1) in vec3 aNormal;
layout(location=2) in vec2 aUV;

uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
uniform mat3 uNormalMat;

out vec3 vWorldPos;
out vec3 vNormal;
out vec2 vUV;

void main() {
    vec4 worldPos = uModel * vec4(aPos, 1.0);
    vWorldPos = worldPos.xyz;
    vNormal   = normalize(uNormalMat * aNormal);
    vUV       = aUV;
    gl_Position = uProjection * uView * worldPos;
}
"""

_FRAG_SRC = """
#version 330 core
in vec3 vWorldPos;
in vec3 vNormal;
in vec2 vUV;

uniform sampler2D uTexture;
uniform vec3  uAmbient;
uniform vec3  uLightDir;
uniform vec3  uLightColor;
uniform float uLightIntensity;
uniform vec4  uColor;

out vec4 FragColor;

void main() {
    vec4 texColor = texture(uTexture, vUV) * uColor;

    // Diffuse (Lambert)
    vec3  ld      = normalize(-uLightDir);
    float diff    = max(dot(vNormal, ld), 0.0);
    vec3  diffuse = diff * uLightColor * uLightIntensity;

    vec3 result = (uAmbient + diffuse) * texColor.rgb;
    FragColor   = vec4(result, texColor.a);
}
"""

class Material:
    _shared_program: Optional[int] = None

    def __init__(self, texture: Optional[Texture] = None, color: Optional[Color] = None):
        self.texture = texture or Texture.solid_color(1,1,1)
        self.color   = color   or Color.white()

    @classmethod
    def _get_program(cls) -> int:
        if cls._shared_program is None:
            vert = compileShader(_VERT_SRC, GL_VERTEX_SHADER)
            frag = compileShader(_FRAG_SRC, GL_FRAGMENT_SHADER)
            cls._shared_program = compileProgram(vert, frag)
        return cls._shared_program

    def bind(self, model_mat, view_mat, proj_mat, scene):
        prog = self._get_program()
        glUseProgram(prog)

        # matrices
        glUniformMatrix4fv(glGetUniformLocation(prog, "uModel"),      1, GL_TRUE, model_mat)
        glUniformMatrix4fv(glGetUniformLocation(prog, "uView"),       1, GL_TRUE, view_mat)
        glUniformMatrix4fv(glGetUniformLocation(prog, "uProjection"), 1, GL_TRUE, proj_mat)

        # normal matrix (inverse transpose of upper-left 3×3)
        nm = np.linalg.inv(model_mat[:3,:3]).T.astype(np.float32)
        glUniformMatrix3fv(glGetUniformLocation(prog, "uNormalMat"), 1, GL_FALSE, nm)

        # lighting
        amb = scene.ambient_light
        glUniform3f(glGetUniformLocation(prog, "uAmbient"),
                    amb.r, amb.g, amb.b)

        dl = scene.directional_light
        if dl:
            d = dl.direction.normalized()
            glUniform3f(glGetUniformLocation(prog, "uLightDir"),
                        d.x, d.y, d.z)
            glUniform3f(glGetUniformLocation(prog, "uLightColor"),
                        dl.color.r, dl.color.g, dl.color.b)
            glUniform1f(glGetUniformLocation(prog, "uLightIntensity"), dl.intensity)
        else:
            glUniform3f(glGetUniformLocation(prog, "uLightDir"), 0,-1,0)
            glUniform3f(glGetUniformLocation(prog, "uLightColor"), 0,0,0)
            glUniform1f(glGetUniformLocation(prog, "uLightIntensity"), 0)

        # colour tint
        c = self.color
        glUniform4f(glGetUniformLocation(prog, "uColor"), c.r, c.g, c.b, c.a)

        # texture
        self.texture.bind(0)
        glUniform1i(glGetUniformLocation(prog, "uTexture"), 0)


# ══════════════════════════════════════════════════════════════════════════════
#  Components
# ══════════════════════════════════════════════════════════════════════════════

class Component:
    """Base class for all components attached to a GameObject."""
    owner: "GameObject" = None  # set when added

    def on_start(self): pass
    def on_update(self, dt: float): pass
    def on_destroy(self): pass


class MeshRenderer(Component):
    def __init__(self, mesh: Mesh, material: Optional[Material] = None):
        self.mesh     = mesh
        self.material = material or Material()

    def draw(self, view_mat, proj_mat, scene):
        model = self.owner.transform.matrix()
        self.material.bind(model, view_mat, proj_mat, scene)
        self.mesh.draw()
        glUseProgram(0)

    def on_destroy(self):
        self.mesh.destroy()


@dataclass
class BoxCollider(Component):
    """Axis-aligned bounding box collider in local space."""
    half_extents: Vec3 = field(default_factory=lambda: Vec3(0.5, 0.5, 0.5))

    def world_min(self) -> Vec3:
        p = self.owner.transform.position
        h = self.half_extents
        return Vec3(p.x-h.x, p.y-h.y, p.z-h.z)

    def world_max(self) -> Vec3:
        p = self.owner.transform.position
        h = self.half_extents
        return Vec3(p.x+h.x, p.y+h.y, p.z+h.z)

    def overlaps(self, other: "BoxCollider") -> bool:
        a0, a1 = self.world_min(), self.world_max()
        b0, b1 = other.world_min(), other.world_max()
        return (a0.x <= b1.x and a1.x >= b0.x and
                a0.y <= b1.y and a1.y >= b0.y and
                a0.z <= b1.z and a1.z >= b0.z)


# ══════════════════════════════════════════════════════════════════════════════
#  GameObject
# ══════════════════════════════════════════════════════════════════════════════

class GameObject:
    def __init__(self, name="GameObject"):
        self.name       = name
        self.active     = True
        self.transform  = Transform()
        self._components: List[Component] = []
        self.tags: List[str] = []

    def add_component(self, comp: Component) -> Component:
        comp.owner = self
        self._components.append(comp)
        return comp

    def get_component(self, cls):
        for c in self._components:
            if isinstance(c, cls):
                return c
        return None

    def _start(self):
        for c in self._components:
            c.on_start()

    def _update(self, dt):
        if not self.active: return
        for c in self._components:
            c.on_update(dt)

    def _draw(self, view_mat, proj_mat, scene):
        if not self.active: return
        for c in self._components:
            if isinstance(c, MeshRenderer):
                c.draw(view_mat, proj_mat, scene)

    def destroy(self):
        for c in self._components:
            c.on_destroy()


# ══════════════════════════════════════════════════════════════════════════════
#  Scene
# ══════════════════════════════════════════════════════════════════════════════

class Scene:
    def __init__(self):
        self._objects: List[GameObject] = []
        self.ambient_light    = Color(0.2, 0.2, 0.25, 1.0)
        self.directional_light: Optional[DirectionalLight] = DirectionalLight(
            direction=Vec3(-1,-2,-1),
            color=Color(1.0, 0.95, 0.85),
            intensity=1.0
        )

    def spawn(self, name="GameObject") -> GameObject:
        go = GameObject(name)
        self._objects.append(go)
        return go

    def destroy(self, go: GameObject):
        go.destroy()
        self._objects.remove(go)

    def find(self, name: str) -> Optional[GameObject]:
        for o in self._objects:
            if o.name == name:
                return o
        return None

    def find_by_tag(self, tag: str) -> List[GameObject]:
        return [o for o in self._objects if tag in o.tags]

    def get_colliders(self) -> List[BoxCollider]:
        result = []
        for o in self._objects:
            c = o.get_component(BoxCollider)
            if c: result.append(c)
        return result

    def check_collision(self, a: BoxCollider) -> List[BoxCollider]:
        return [b for b in self.get_colliders() if b is not a and a.overlaps(b)]

    def _start_all(self):
        for o in self._objects:
            o._start()

    def _update_all(self, dt):
        for o in list(self._objects):
            o._update(dt)

    def _draw_all(self, view_mat, proj_mat):
        for o in self._objects:
            o._draw(view_mat, proj_mat, self)


# ══════════════════════════════════════════════════════════════════════════════
#  Camera base + FlyCamera + OrbitCamera
# ══════════════════════════════════════════════════════════════════════════════

class Camera:
    def __init__(self, fov=75.0, near=0.1, far=1000.0):
        self.fov  = fov
        self.near = near
        self.far  = far
        self.position = Vec3(0, 2, 6)

    def get_view(self) -> np.ndarray:
        raise NotImplementedError

    def get_projection(self, width, height) -> np.ndarray:
        aspect = width / max(height, 1)
        return _mat4_perspective(self.fov, aspect, self.near, self.far)

    def handle_event(self, event): pass
    def update(self, dt, keys): pass


class FlyCamera(Camera):
    """WASD + mouse look free-fly camera."""
    def __init__(self, speed=5.0, sensitivity=0.15, **kw):
        super().__init__(**kw)
        self.speed       = speed
        self.sensitivity = sensitivity
        self.yaw         = -90.0
        self.pitch       = 0.0
        self._first_mouse = True
        self._last_mx     = 0
        self._last_my     = 0
        self._captured    = False

    def _front(self) -> Vec3:
        y, p = math.radians(self.yaw), math.radians(self.pitch)
        return Vec3(math.cos(p)*math.cos(y),
                    math.sin(p),
                    math.cos(p)*math.sin(y)).normalized()

    def get_view(self) -> np.ndarray:
        f = self._front()
        return _mat4_look_at(self.position, self.position + f, Vec3.up())

    def handle_event(self, event):
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 3:
            self._captured = True
            pygame.mouse.set_visible(False)
            pygame.event.set_grab(True)
        if event.type == pygame.MOUSEBUTTONUP and event.button == 3:
            self._captured = False
            pygame.mouse.set_visible(True)
            pygame.event.set_grab(False)
        if event.type == pygame.MOUSEMOTION and self._captured:
            dx, dy = event.rel
            self.yaw   += dx * self.sensitivity
            self.pitch  = max(-89, min(89, self.pitch - dy * self.sensitivity))

    def update(self, dt, keys):
        f = self._front()
        r = f.cross(Vec3.up()).normalized()
        spd = self.speed * dt
        if keys[pygame.K_w] or keys[pygame.K_UP]:    self.position = self.position + f * spd
        if keys[pygame.K_s] or keys[pygame.K_DOWN]:  self.position = self.position - f * spd
        if keys[pygame.K_a] or keys[pygame.K_LEFT]:  self.position = self.position - r * spd
        if keys[pygame.K_d] or keys[pygame.K_RIGHT]: self.position = self.position + r * spd
        if keys[pygame.K_q]:                          self.position = self.position - Vec3.up() * spd
        if keys[pygame.K_e]:                          self.position = self.position + Vec3.up() * spd


class OrbitCamera(Camera):
    """Orbit around a target point with mouse drag + scroll zoom."""
    def __init__(self, target=None, distance=8.0, **kw):
        super().__init__(**kw)
        self.target   = target or Vec3.zero()
        self.distance = distance
        self.yaw      = 0.0
        self.pitch    = 20.0
        self._dragging = False

    def _calc_pos(self):
        y, p = math.radians(self.yaw), math.radians(self.pitch)
        x = self.distance * math.cos(p) * math.sin(y)
        yl= self.distance * math.sin(p)
        z = self.distance * math.cos(p) * math.cos(y)
        return Vec3(self.target.x+x, self.target.y+yl, self.target.z+z)

    def get_view(self) -> np.ndarray:
        self.position = self._calc_pos()
        return _mat4_look_at(self.position, self.target, Vec3.up())

    def handle_event(self, event):
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 3:
            self._dragging = True
            pygame.mouse.set_visible(False)
            pygame.event.set_grab(True)
        if event.type == pygame.MOUSEBUTTONUP and event.button == 3:
            self._dragging = False
            pygame.mouse.set_visible(True)
            pygame.event.set_grab(False)
        if event.type == pygame.MOUSEMOTION and self._dragging:
            dx, dy = event.rel
            self.yaw   += dx * 0.4
            self.pitch  = max(-85, min(85, self.pitch + dy * 0.4))
        if event.type == pygame.MOUSEWHEEL:
            self.distance = max(0.5, self.distance - event.y * 0.5)


# ══════════════════════════════════════════════════════════════════════════════
#  Input  (thin wrapper – your scripts can also use pygame directly)
# ══════════════════════════════════════════════════════════════════════════════

class Input:
    def __init__(self):
        self._keys_down: set = set()
        self._keys_just: set = set()
        self._mouse_buttons: set = set()
        self._mouse_pos = (0, 0)
        self._mouse_delta = (0, 0)

    def _process_event(self, event):
        if event.type == pygame.KEYDOWN:
            self._keys_down.add(event.key)
            self._keys_just.add(event.key)
        if event.type == pygame.KEYUP:
            self._keys_down.discard(event.key)
        if event.type == pygame.MOUSEBUTTONDOWN:
            self._mouse_buttons.add(event.button)
        if event.type == pygame.MOUSEBUTTONUP:
            self._mouse_buttons.discard(event.button)
        if event.type == pygame.MOUSEMOTION:
            self._mouse_pos   = event.pos
            self._mouse_delta = event.rel

    def _end_frame(self):
        self._keys_just.clear()
        self._mouse_delta = (0, 0)

    def get_key(self, key) -> bool:        return key in self._keys_down
    def get_key_down(self, key) -> bool:   return key in self._keys_just
    def get_mouse_button(self, btn) -> bool: return btn in self._mouse_buttons
    @property
    def mouse_pos(self): return self._mouse_pos
    @property
    def mouse_delta(self): return self._mouse_delta


# ══════════════════════════════════════════════════════════════════════════════
#  Engine  (the main class)
# ══════════════════════════════════════════════════════════════════════════════

class Engine:
    """
    The top-level engine object.

    Usage:
        engine = pygine3d.Engine(800, 600, "My Game")
        # ... set up scene ...
        engine.run()

    Lifecycle callbacks (attach to engine):
        engine.on_start   = lambda: ...
        engine.on_update  = lambda dt: ...
        engine.on_gui     = lambda: ...          # called after scene draw
        engine.on_quit    = lambda: ...
    """

    def __init__(self, width=800, height=600, title="pygine3d", fps=60,
                 clear_color: Optional[Color] = None):
        self.width  = width
        self.height = height
        self.title  = title
        self.target_fps = fps
        self.clear_color = clear_color or Color(0.1, 0.1, 0.15, 1.0)

        # public API
        self.scene  = Scene()
        self.camera: Camera = FlyCamera()
        self.input  = Input()
        self.time   = 0.0       # total elapsed seconds
        self.frame  = 0

        # lifecycle hooks (assign lambdas or functions)
        self.on_start:  Optional[Callable[[], None]]      = None
        self.on_update: Optional[Callable[[float], None]] = None
        self.on_gui:    Optional[Callable[[], None]]      = None
        self.on_quit:   Optional[Callable[[], None]]      = None

        self._running      = False
        self._window       = None
        self._clock        = None
        self._custom_icon  = None   # set via engine.set_icon(surface_or_path)
        self.hud           = HUD(self)    # 2D overlay — draw in on_gui

    def _make_default_icon(self):
        """Generate a simple P3 logo icon as a pygame Surface (no file needed)."""
        size = 32
        icon = pygame.Surface((size, size), pygame.SRCALPHA)
        icon.fill((0, 0, 0, 0))
        # Dark background
        pygame.draw.rect(icon, (30, 30, 40, 255), (0, 0, size, size), border_radius=6)
        # Orange accent square
        pygame.draw.rect(icon, (220, 120, 30, 255), (4, 4, size-8, size-8), border_radius=4)
        # White P letter (simplified as rects)
        pygame.draw.rect(icon, (255, 255, 255, 255), (9,  8, 4, 16))   # vertical bar
        pygame.draw.rect(icon, (255, 255, 255, 255), (9,  8, 9,  4))   # top of P
        pygame.draw.rect(icon, (255, 255, 255, 255), (9, 14, 9,  4))   # middle of P
        pygame.draw.rect(icon, (255, 255, 255, 255), (18, 8, 4,  10))  # right curve of P
        # 3 dots for "3D"
        for i, x in enumerate([22, 25, 22]):
            y = 18 + i * 4
            pygame.draw.rect(icon, (255, 255, 255, 255), (x, y, 3, 3))
        return icon

    def _init_pygame(self):
        pygame.init()
        pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3)
        pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3)
        pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK,
                                        pygame.GL_CONTEXT_PROFILE_CORE)
        pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLEBUFFERS, 1)
        pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLESAMPLES, 4)
        print(f"[PyGine3D] Opening window: {self.width}x{self.height}  '{self.title}'")
        self._window = pygame.display.set_mode(
            (self.width, self.height),
            pygame.OPENGL | pygame.DOUBLEBUF | pygame.RESIZABLE
        )
        pygame.display.set_caption(self.title)
        # Set icon — use custom one if provided, otherwise generate default
        if self._custom_icon is not None:
            pygame.display.set_icon(self._custom_icon)
        else:
            pygame.display.set_icon(self._make_default_icon())
        self._clock = pygame.time.Clock()
        print(f"[PyGine3D] OpenGL 3.3 Core context created")

    def _init_opengl(self):
        glEnable(GL_DEPTH_TEST)
        glEnable(GL_MULTISAMPLE)
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
        glEnable(GL_CULL_FACE)
        glCullFace(GL_BACK)
        cc = self.clear_color
        glClearColor(cc.r, cc.g, cc.b, cc.a)

    def set_icon(self, path_or_surface):
        """Set a custom window icon before engine.run().
        Accepts a file path (str) or a pygame.Surface.
        If not called, PyGine3D uses its default P3 logo icon.
        """
        if isinstance(path_or_surface, str):
            self._custom_icon = pygame.image.load(path_or_surface).convert_alpha()
        else:
            self._custom_icon = path_or_surface

    def run(self):
        """Start the main loop."""
        self._init_pygame()
        self._init_opengl()
        print(f"[PyGine3D] Scene starting...")

        # user on_start
        if self.on_start:
            self.on_start()
        self.scene._start_all()

        self._running = True
        prev = time.perf_counter()

        while self._running:
            now = time.perf_counter()
            dt  = min(now - prev, 0.1)  # cap dt to avoid spiral of death
            prev = now
            self.time  += dt
            self.frame += 1

            # ── events ──
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    self._running = False
                if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
                    self._running = False
                if event.type == pygame.VIDEORESIZE:
                    self.width, self.height = event.w, event.h
                self.input._process_event(event)
                self.camera.handle_event(event)

            keys = pygame.key.get_pressed()

            # ── update ──
            self.camera.update(dt, keys)
            self.scene._update_all(dt)
            if self.on_update:
                self.on_update(dt)

            # ── render ──
            glViewport(0, 0, self.width, self.height)
            glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

            view = self.camera.get_view()
            proj = self.camera.get_projection(self.width, self.height)
            self.scene._draw_all(view, proj)

            # ── 2D HUD overlay ──
            self.hud._begin()
            if self.on_gui:
                self.on_gui()
            self.hud._end()

            pygame.display.flip()
            self.input._end_frame()
            self._clock.tick(self.target_fps)

        # ── shutdown ──
        if self.on_quit:
            self.on_quit()
        pygame.quit()

    def quit(self):
        """Call from anywhere to stop the engine."""
        self._running = False


# ══════════════════════════════════════════════════════════════════════════════
#  Convenience helpers
# ══════════════════════════════════════════════════════════════════════════════

def quick_cube(scene: Scene,
               position: Vec3 = None,
               color:    Color = None,
               texture:  Optional[Texture] = None,
               scale:    float = 1.0,
               name:     str = "Cube",
               auto_collision: bool = True) -> GameObject:
    """Spawn a coloured (or textured) cube in one line.
    auto_collision=True automatically adds a BoxCollider sized to the cube.
    Set auto_collision=False for purely decorative objects.
    """
    go = scene.spawn(name)
    mat = Material(texture=texture, color=color or Color.white())
    go.add_component(MeshRenderer(Mesh.cube(), mat))
    if auto_collision:
        go.add_component(BoxCollider(Vec3(scale*0.5, scale*0.5, scale*0.5)))
    if position:
        go.transform.position = position
    go.transform.scale = Vec3(scale, scale, scale)
    return go


def quick_plane(scene: Scene,
                position: Vec3 = None,
                size: float = 10.0,
                color: Color = None,
                texture: Optional[Texture] = None,
                name: str = "Plane",
                auto_collision: bool = True) -> GameObject:
    """Spawn a flat ground plane in one line.
    auto_collision=True automatically adds a BoxCollider covering the plane.
    Set auto_collision=False for purely decorative/background planes.
    """
    go = scene.spawn(name)
    mat = Material(texture=texture, color=color or Color(0.4, 0.4, 0.4))
    go.add_component(MeshRenderer(Mesh.plane(size=size, divisions=4), mat))
    if auto_collision:
        go.add_component(BoxCollider(Vec3(size*0.5, 0.05, size*0.5)))
    if position:
        go.transform.position = position
    return go


def quick_box(scene: Scene,
              position: Vec3 = None,
              size: Vec3 = None,
              color: Color = None,
              texture: Optional[Texture] = None,
              name: str = "Box",
              auto_collision: bool = True) -> GameObject:
    """Spawn an arbitrarily-sized box (non-uniform scale) in one line.
    Unlike quick_cube which uses a uniform scale, this lets you set
    width/height/depth independently — useful for platforms and walls.
    auto_collision=True adds a matching BoxCollider automatically.
    """
    go = scene.spawn(name)
    sz = size or Vec3(1, 1, 1)
    mat = Material(texture=texture, color=color or Color.white())
    go.add_component(MeshRenderer(Mesh.cube(), mat))
    if auto_collision:
        go.add_component(BoxCollider(Vec3(sz.x*0.5, sz.y*0.5, sz.z*0.5)))
    if position:
        go.transform.position = position
    go.transform.scale = sz
    return go




# ══════════════════════════════════════════════════════════════════════════════
#  2D HUD / Overlay system
#  Renders in screen space on top of the 3D scene.
#  Uses pygame to draw onto a Surface, then blits it as an OpenGL texture.
#
#  Usage:
#      hud = engine.hud                         # always available
#      def on_gui():
#          hud.rect(10, 10, 200, 40, color=(40,40,40,180))
#          hud.text("Health: 100", 18, 18, size=20, color=(255,255,255))
#          hud.image(my_texture_or_path, 10, 60, 64, 64)
#      engine.on_gui = on_gui
# ══════════════════════════════════════════════════════════════════════════════

class HUD:
    """
    2D screen-space overlay drawn on top of the 3D scene every frame.
    All coordinates are in pixels, origin top-left.
    Call draw methods inside engine.on_gui.
    """

    # Minimal shaders for blitting the HUD texture as a fullscreen quad
    _HUD_VERT = """
#version 330 core
out vec2 vUV;
void main() {
    // Full-screen triangle trick — no VBO needed
    vec2 pos = vec2((gl_VertexID & 1) * 4.0 - 1.0,
                    (gl_VertexID & 2) * 2.0 - 1.0);
    vUV = pos * 0.5 + 0.5;
    vUV.y = 1.0 - vUV.y;   // flip Y so pygame top-left matches GL bottom-left
    gl_Position = vec4(pos, 0.0, 1.0);
}
"""
    _HUD_FRAG = """
#version 330 core
in vec2 vUV;
uniform sampler2D uTex;
out vec4 FragColor;
void main() { FragColor = texture(uTex, vUV); }
"""

    def __init__(self, engine: "Engine"):
        self._engine    = engine
        self._surface   = None
        self._tex_id    = None
        self._prog      = None   # HUD shader program
        self._vao       = None   # empty VAO for the fullscreen triangle
        self._fonts: Dict[int, "pygame.font.Font"] = {}
        self._img_cache: Dict[str, "pygame.Surface"] = {}

    # ── internal ─────────────────────────────────────────────────────────────

    def _ensure_gpu(self):
        """Lazy-init texture, VAO and shader — called after OpenGL context exists."""
        if self._prog is not None:
            return
        from OpenGL.GL.shaders import compileShader, compileProgram
        vert = compileShader(self._HUD_VERT, GL_VERTEX_SHADER)
        frag = compileShader(self._HUD_FRAG, GL_FRAGMENT_SHADER)
        self._prog  = compileProgram(vert, frag)
        self._vao   = glGenVertexArrays(1)
        self._tex_id = glGenTextures(1)
        glBindTexture(GL_TEXTURE_2D, self._tex_id)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
        glBindTexture(GL_TEXTURE_2D, 0)

    def _ensure_surface(self):
        w, h = self._engine.width, self._engine.height
        if self._surface is None or self._surface.get_size() != (w, h):
            self._surface = pygame.Surface((w, h), pygame.SRCALPHA)

    def _get_font(self, size: int) -> "pygame.font.Font":
        if size not in self._fonts:
            self._fonts[size] = pygame.font.SysFont("segoeui", size)
        return self._fonts[size]

    def _begin(self):
        self._ensure_gpu()
        self._ensure_surface()
        self._surface.fill((0, 0, 0, 0))

    def _end(self):
        """Upload pygame surface to GPU and blit as a fullscreen quad."""
        surf = self._surface
        w, h = surf.get_size()
        raw  = pygame.image.tostring(surf, "RGBA", False)

        # Upload texture
        glBindTexture(GL_TEXTURE_2D, self._tex_id)
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0,
                     GL_RGBA, GL_UNSIGNED_BYTE, raw)
        glBindTexture(GL_TEXTURE_2D, 0)

        # Draw fullscreen quad with HUD shader
        glDisable(GL_DEPTH_TEST)
        glDisable(GL_CULL_FACE)
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

        glUseProgram(self._prog)
        glActiveTexture(GL_TEXTURE0)
        glBindTexture(GL_TEXTURE_2D, self._tex_id)
        glUniform1i(glGetUniformLocation(self._prog, "uTex"), 0)

        glBindVertexArray(self._vao)
        glDrawArrays(GL_TRIANGLES, 0, 3)
        glBindVertexArray(0)

        glUseProgram(0)
        glBindTexture(GL_TEXTURE_2D, 0)
        glEnable(GL_DEPTH_TEST)
        glEnable(GL_CULL_FACE)

    # ── Public draw API ───────────────────────────────────────────────────────

    def rect(self, x: int, y: int, w: int, h: int,
             color=(0, 0, 0, 180), border_radius: int = 0,
             border_color=None, border_width: int = 2):
        """Draw a filled rectangle. color is (R,G,B) or (R,G,B,A)."""
        c = color if len(color) == 4 else (*color, 255)
        pygame.draw.rect(self._surface, c, (x, y, w, h),
                         border_radius=border_radius)
        if border_color:
            bc = border_color if len(border_color) == 4 else (*border_color, 255)
            pygame.draw.rect(self._surface, bc, (x, y, w, h),
                             width=border_width, border_radius=border_radius)

    def text(self, txt: str, x: int, y: int,
             size: int = 18, color=(255, 255, 255),
             shadow: bool = False, shadow_color=(0, 0, 0)):
        """Draw text at (x, y). origin is top-left of the text."""
        font = self._get_font(size)
        c    = color if len(color) == 4 else (*color, 255)
        if shadow:
            sc   = shadow_color if len(shadow_color) == 4 else (*shadow_color, 200)
            surf = font.render(txt, True, sc[:3])
            self._surface.blit(surf, (x+1, y+1))
        surf = font.render(txt, True, c[:3])
        self._surface.blit(surf, (x, y))

    def text_centered(self, txt: str, x: int, y: int, w: int, h: int,
                      size: int = 18, color=(255, 255, 255)):
        """Draw text centred inside a rectangle (x,y,w,h)."""
        font = self._get_font(size)
        surf = font.render(txt, True, color[:3] if len(color) >= 3 else color)
        tx   = x + (w - surf.get_width())  // 2
        ty   = y + (h - surf.get_height()) // 2
        self._surface.blit(surf, (tx, ty))

    def image(self, source, x: int, y: int, w: int = None, h: int = None,
              alpha: int = 255):
        """Draw an image. source can be a file path (str) or pygame.Surface."""
        if isinstance(source, str):
            if source not in self._img_cache:
                self._img_cache[source] = pygame.image.load(source).convert_alpha()
            surf = self._img_cache[source]
        else:
            surf = source
        if w is not None and h is not None:
            surf = pygame.transform.scale(surf, (w, h))
        if alpha != 255:
            surf = surf.copy()
            surf.set_alpha(alpha)
        self._surface.blit(surf, (x, y))

    def line(self, x1: int, y1: int, x2: int, y2: int,
             color=(255, 255, 255), width: int = 1):
        """Draw a line."""
        pygame.draw.line(self._surface, color, (x1, y1), (x2, y2), width)

    def circle(self, x: int, y: int, radius: int,
               color=(255, 255, 255, 255), border_color=None, border_width=2):
        """Draw a filled circle centred at (x, y)."""
        c = color if len(color) == 4 else (*color, 255)
        pygame.draw.circle(self._surface, c, (x, y), radius)
        if border_color:
            bc = border_color if len(border_color) == 4 else (*border_color, 255)
            pygame.draw.circle(self._surface, bc, (x, y), radius, border_width)

    def crosshair(self, size: int = 10, thickness: int = 2,
                  color=(255, 255, 255, 200), gap: int = 4):
        """Draw a crosshair at the centre of the screen."""
        cx = self._engine.width  // 2
        cy = self._engine.height // 2
        c  = color if len(color) == 4 else (*color, 255)
        # horizontal
        pygame.draw.rect(self._surface, c,
                         (cx - size - gap, cy - thickness//2,
                          size, thickness))
        pygame.draw.rect(self._surface, c,
                         (cx + gap, cy - thickness//2,
                          size, thickness))
        # vertical
        pygame.draw.rect(self._surface, c,
                         (cx - thickness//2, cy - size - gap,
                          thickness, size))
        pygame.draw.rect(self._surface, c,
                         (cx - thickness//2, cy + gap,
                          thickness, size))

    def panel(self, x: int, y: int, w: int, h: int,
              bg=(20, 20, 30, 210), border=(80, 80, 100, 255),
              border_radius: int = 6, border_width: int = 2):
        """Draw a dark semi-transparent panel (good for inventory windows)."""
        self.rect(x, y, w, h, color=bg,
                  border_color=border, border_radius=border_radius,
                  border_width=border_width)

    @property
    def width(self) -> int:  return self._engine.width
    @property
    def height(self) -> int: return self._engine.height


# ══════════════════════════════════════════════════════════════════════════════
#  Inventory widget  (MC-style hotbar + grid inventory)
# ══════════════════════════════════════════════════════════════════════════════

class InventoryItem:
    """Represents one stack of items in a slot."""
    def __init__(self, name: str, count: int = 1,
                 icon=None, color=(180, 120, 60)):
        self.name  = name
        self.count = count
        self.icon  = icon    # pygame.Surface or file path, optional
        self.color = color   # fallback colour if no icon

    def __repr__(self):
        return f"InventoryItem({self.name!r} x{self.count})"


class Inventory:
    """
    A Minecraft-style inventory: hotbar + optional grid panel.

    Usage:
        inv = pygine3d.Inventory(engine, cols=9, rows=4)
        inv.hotbar[0] = pygine3d.InventoryItem("Sword", icon="sword.png")
        inv.add_item(pygine3d.InventoryItem("Stone", count=32))

        def on_gui():
            inv.draw()          # draws hotbar (always) + grid if open
        engine.on_gui = on_gui

        def on_update(dt):
            inv.update()        # handles E key to open/close, scroll to select
        engine.on_update = on_update
    """

    SLOT_SIZE    = 52
    SLOT_PAD     = 4
    SLOT_RADIUS  = 5

    def __init__(self, engine: "Engine", cols: int = 9, rows: int = 4,
                 open_key=None):
        self._engine    = engine
        self.cols       = cols
        self.rows       = rows
        self.open_key   = open_key or pygame.K_e
        self.hotbar: List[Optional[InventoryItem]] = [None] * cols
        self.grid:   List[Optional[InventoryItem]] = [None] * (cols * rows)
        self.selected   = 0   # hotbar selected slot index
        self.is_open    = False

    # ── item management ───────────────────────────────────────────────────────

    def add_item(self, item: InventoryItem) -> bool:
        """Add item to first empty grid slot. Returns True if added."""
        for i, slot in enumerate(self.grid):
            if slot is None:
                self.grid[i] = item
                return True
        return False

    def remove_item(self, name: str) -> Optional[InventoryItem]:
        """Remove first matching item from grid. Returns item or None."""
        for i, slot in enumerate(self.grid):
            if slot and slot.name == name:
                self.grid[i] = None
                return slot
        return None

    def selected_item(self) -> Optional[InventoryItem]:
        """Return whatever item is in the currently selected hotbar slot."""
        return self.hotbar[self.selected]

    # ── input ────────────────────────────────────────────────────────────────

    def update(self):
        """Call once per frame inside on_update to handle keyboard/scroll."""
        inp = self._engine.input
        # E toggles inventory open/close
        if inp.get_key_down(self.open_key):
            self.is_open = not self.is_open

        # number keys 1-9 select hotbar slot
        for i, key in enumerate([pygame.K_1, pygame.K_2, pygame.K_3,
                                  pygame.K_4, pygame.K_5, pygame.K_6,
                                  pygame.K_7, pygame.K_8, pygame.K_9]):
            if i < self.cols and inp.get_key_down(key):
                self.selected = i

        # mouse wheel scrolls hotbar selection
        # (reads from pygame events directly since Input doesn't track wheel)
        for event in pygame.event.get(pygame.MOUSEWHEEL):
            self.selected = (self.selected - event.y) % self.cols
            pygame.event.post(event)   # re-post so engine input can still see it

    # ── drawing ───────────────────────────────────────────────────────────────

    def _draw_slot(self, hud: HUD, x: int, y: int,
                   item: Optional[InventoryItem], selected: bool = False,
                   hovered: bool = False):
        S  = self.SLOT_SIZE
        bg = (60, 60, 70, 220) if not selected else (200, 160, 40, 240)
        bd = (120, 120, 140, 255) if not selected else (255, 220, 80, 255)
        if hovered:
            bg = (80, 80, 100, 230)
        hud.rect(x, y, S, S, color=bg, border_color=bd,
                 border_radius=self.SLOT_RADIUS, border_width=2)

        if item:
            if item.icon:
                hud.image(item.icon, x+4, y+4, S-8, S-8)
            else:
                # coloured square as fallback icon
                r, g, b = item.color[:3]
                hud.rect(x+6, y+6, S-12, S-12,
                         color=(r, g, b, 220),
                         border_radius=3)
            if item.count > 1:
                hud.text(str(item.count),
                         x + S - 18, y + S - 20,
                         size=14, color=(255, 255, 255),
                         shadow=True)

    def draw(self):
        """Call inside engine.on_gui to render the inventory."""
        hud = self._engine.hud
        S   = self.SLOT_SIZE
        P   = self.SLOT_PAD
        W   = self._engine.width
        H   = self._engine.height

        # ── Hotbar (always visible, bottom-centre) ────────────────────────
        bar_w = self.cols * (S + P) - P
        bx    = (W - bar_w) // 2
        by    = H - S - 12

        hud.panel(bx - 6, by - 6, bar_w + 12, S + 12,
                  border_radius=8)

        mx, my = pygame.mouse.get_pos()
        for i in range(self.cols):
            sx      = bx + i * (S + P)
            hovered = (sx <= mx <= sx+S and by <= my <= by+S)
            self._draw_slot(hud, sx, by, self.hotbar[i],
                            selected=(i == self.selected),
                            hovered=hovered)

        # selected item name tooltip
        sel = self.selected_item()
        if sel:
            hud.text(sel.name,
                     W // 2 - len(sel.name) * 5,
                     by - 26,
                     size=16, color=(220, 220, 220),
                     shadow=True)

        # ── Grid panel (visible when is_open) ─────────────────────────────
        if not self.is_open:
            return

        grid_w = self.cols * (S + P) - P
        grid_h = self.rows * (S + P) - P
        gx     = (W - grid_w) // 2
        gy     = (H - grid_h) // 2 - 40

        hud.panel(gx - 12, gy - 36, grid_w + 24, grid_h + 48,
                  border_radius=10)
        hud.text("Inventory", gx, gy - 28, size=16,
                 color=(200, 200, 220), shadow=True)

        for row in range(self.rows):
            for col in range(self.cols):
                idx     = row * self.cols + col
                sx      = gx + col * (S + P)
                sy      = gy + row * (S + P)
                hovered = (sx <= mx <= sx+S and sy <= my <= sy+S)
                item    = self.grid[idx] if idx < len(self.grid) else None
                self._draw_slot(hud, sx, sy, item, hovered=hovered)

# ── Key constants re-exported for convenience ─────────────────────────────
K_W     = pygame.K_w
K_A     = pygame.K_a
K_S     = pygame.K_s
K_D     = pygame.K_d
K_UP    = pygame.K_UP
K_DOWN  = pygame.K_DOWN
K_LEFT  = pygame.K_LEFT
K_RIGHT = pygame.K_RIGHT
K_SPACE = pygame.K_SPACE
K_SHIFT = pygame.K_LSHIFT
K_ESC   = pygame.K_ESCAPE

__all__ = [
    "Engine", "Scene", "GameObject", "Transform", "Component",
    "MeshRenderer", "BoxCollider",
    "Mesh", "Texture", "Material",
    "Camera", "FlyCamera", "OrbitCamera",
    "DirectionalLight", "Input",
    "Vec3", "Color",
    "quick_cube", "quick_plane", "quick_box",
    "HUD", "Inventory", "InventoryItem",
    "K_W","K_A","K_S","K_D","K_UP","K_DOWN","K_LEFT","K_RIGHT",
    "K_SPACE","K_SHIFT","K_ESC",
]