// Metal backend for the GPU protocol. iOS-only for now; macOS later.
//
// Linking is per-target via the consumer's build.sx:
//   opts.add_framework("Metal")
//   opts.add_framework("QuartzCore")   // CAMetalLayer lives here
// `#framework "Metal"` below adds it to iOS-target link lines automatically;
// non-iOS targets don't reach the Metal-touching code paths.

#import "modules/std.sx";
#import "modules/std/objc.sx";
#import "modules/compiler.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";

#framework "Metal";

// MTLCreateSystemDefaultDevice lives in the Metal framework as a plain C
// function. Returns id<MTLDevice> retained +1; we leak it for now since
// the device lives for the whole process.
MTLCreateSystemDefaultDevice :: () -> *void #foreign;

// Pixel formats.
MTL_PIXEL_FORMAT_BGRA8_UNORM :u64: 80;
MTL_PIXEL_FORMAT_RGBA8_UNORM :u64: 70;
MTL_PIXEL_FORMAT_R8_UNORM    :u64: 10;

// MTLLoadAction / MTLStoreAction.
MTL_LOAD_ACTION_CLEAR  :u64: 2;
MTL_STORE_ACTION_STORE :u64: 1;

// MTLStorageMode. For UI atlases + sprites the CPU needs to write pixels
// and the GPU needs to sample — `.shared` is the safe default. On iOS-sim
// under Apple Silicon the convenience class method's default storage
// isn't reliably shared, so we set it explicitly in metal_create_texture_ios.
MTL_STORAGE_MODE_SHARED :u64: 0;

// MTLPrimitiveType.
MTL_PRIMITIVE_TYPE_TRIANGLE :u64: 3;

// MTLBlendFactor — the subset used for normal alpha blending.
MTL_BLEND_FACTOR_SRC_ALPHA       :u64: 4;
MTL_BLEND_FACTOR_ONE_MINUS_SRC_A :u64: 5;

// CGSize is a 2-element f64 HFA. arm64 Apple ABI puts it in d0,d1 — direct
// fn-pointer cast on objc_msgSend with a CGSize arg does the right thing.
CGSize :: struct { width: f64; height: f64; }

// MTLClearColor is a 4-element f64 HFA. Same ABI story — passes in d0..d3.
MTLClearColor :: struct {
    red:   f64;
    green: f64;
    blue:  f64;
    alpha: f64;
}

// MTLOrigin / MTLSize / MTLRegion / MTLScissorRect — integer aggregates.
// MTLRegion is 48 bytes and MTLScissorRect is 32 bytes; both are passed
// by value to the Obj-C runtime, which the compiler marshals as
// `ptr byval(<T>)` via the C-ABI byval coercion. The fn-pointer cast
// must spell `callconv(.c)` so the indirect call applies that coercion.
MTLOrigin :: struct { x: u64; y: u64; z: u64; }
MTLSize   :: struct { width: u64; height: u64; depth: u64; }
MTLRegion :: struct { origin: MTLOrigin; size: MTLSize; }
MTLScissorRect :: struct { x: u64; y: u64; width: u64; height: u64; }

// Pixel sub-format storage for textures. Tracks the bytes-per-pixel for the
// upload path (replaceRegion needs bytesPerRow which is bpp × width).
TextureSlot :: struct {
    tex: *void = null;
    bytes_per_pixel: u32 = 0;
}

MetalGPU :: struct {
    device:  *void = null;     // id<MTLDevice>
    queue:   *void = null;     // id<MTLCommandQueue>
    layer:   *void = null;     // CAMetalLayer*
    pixel_w: s32   = 0;
    pixel_h: s32   = 0;

    // Per-frame transients. Live only between begin_frame and end_frame.
    drawable:   *void = null;  // id<CAMetalDrawable>
    cmd_buffer: *void = null;  // id<MTLCommandBuffer>
    encoder:    *void = null;  // id<MTLRenderCommandEncoder>

    // Resource tables. Handles are 1-based indices (0 = invalid).
    shaders:  List(*void)       = .{};  // MTLRenderPipelineState*
    buffers:  List(*void)       = .{};  // MTLBuffer*
    textures: List(TextureSlot) = .{};
}

impl GPU for MetalGPU {
    // Two-phase init: callers can `init(null, 0, 0)` first to allocate
    // device + queue eagerly (lets the UI pipeline compile shaders before
    // UIKit hands us a layer), then re-call `init(layer, w, h)` once the
    // CAMetalLayer is available. The second call only updates the layer
    // ref + dims; device/queue are preserved.
    init :: (self: *MetalGPU, target: *void, pixel_w: s32, pixel_h: s32) -> bool {
        inline if OS != .ios { return false; }
        if target != null {
            self.layer   = target;
            self.pixel_w = pixel_w;
            self.pixel_h = pixel_h;
        }
        metal_init_ios(self);
    }

    shutdown :: (self: *MetalGPU) {
        // Metal objects clean up at process exit on iOS. A real shutdown
        // would send `release` to queue + device.
    }

    resize :: (self: *MetalGPU, pixel_w: s32, pixel_h: s32) {
        self.pixel_w = pixel_w;
        self.pixel_h = pixel_h;
        inline if OS == .ios {
            metal_resize_ios(self);
        }
    }

    begin_frame :: (self: *MetalGPU, clear: ClearColor) -> bool {
        inline if OS != .ios { return false; }
        metal_begin_frame_ios(self, clear);
    }

    end_frame :: (self: *MetalGPU, target_time: f64) {
        inline if OS == .ios {
            metal_end_frame_ios(self, target_time);
        }
    }

    // ── Resources ────────────────────────────────────────────────────────
    // Handle = 1-based index into the backing List (0 = invalid). The bulk
    // of each method lives in an iOS-only helper for readability — the impl
    // method just guards non-iOS and delegates.

    create_shader :: (self: *MetalGPU, vsrc: string, fsrc: string) -> ShaderHandle {
        inline if OS != .ios { return 0; }
        metal_create_shader_ios(self, vsrc);
    }

    create_buffer :: (self: *MetalGPU, size_bytes: s64) -> BufferHandle {
        inline if OS != .ios { return 0; }
        metal_create_buffer_ios(self, size_bytes);
    }

    update_buffer :: (self: *MetalGPU, buf: BufferHandle, data: *void, size_bytes: s64) {
        inline if OS == .ios {
            metal_update_buffer_ios(self, buf, data, size_bytes);
        }
    }

    update_buffer_at :: (self: *MetalGPU, buf: BufferHandle, data: *void, size_bytes: s64, byte_offset: s64) {
        inline if OS == .ios {
            metal_update_buffer_at_ios(self, buf, data, size_bytes, byte_offset);
        }
    }

    create_texture :: (self: *MetalGPU, w: s32, h: s32, format: TextureFormat, pixels: *void) -> TextureHandle {
        inline if OS != .ios { return 0; }
        metal_create_texture_ios(self, w, h, format, pixels);
    }

    update_texture_region :: (self: *MetalGPU, tex: TextureHandle, x: s32, y: s32, w: s32, h: s32, pixels: *void) {
        inline if OS == .ios {
            metal_update_texture_region_ios(self, tex, x, y, w, h, pixels);
        }
    }

    destroy_shader :: (self: *MetalGPU, sh: ShaderHandle) {
        inline if OS == .ios {
            metal_destroy_shader_ios(self, sh);
        }
    }

    destroy_buffer :: (self: *MetalGPU, buf: BufferHandle) {
        inline if OS == .ios {
            metal_destroy_buffer_ios(self, buf);
        }
    }

    destroy_texture :: (self: *MetalGPU, tex: TextureHandle) {
        inline if OS == .ios {
            metal_destroy_texture_ios(self, tex);
        }
    }

    // ── Per-draw state ───────────────────────────────────────────────────
    // All operate on `self.encoder`, which is live only between begin_frame
    // and end_frame. Calling these outside that window is a silent no-op.

    set_shader :: (self: *MetalGPU, sh: ShaderHandle) {
        inline if OS == .ios {
            metal_set_shader_ios(self, sh);
        }
    }

    set_vertex_buffer :: (self: *MetalGPU, buf: BufferHandle) {
        inline if OS == .ios {
            metal_set_vertex_buffer_ios(self, buf);
        }
    }

    set_texture :: (self: *MetalGPU, slot: u32, tex: TextureHandle) {
        inline if OS == .ios {
            metal_set_texture_ios(self, slot, tex);
        }
    }

    set_vertex_constants :: (self: *MetalGPU, slot: u32, data: *void, size_bytes: s64) {
        inline if OS == .ios {
            metal_set_vertex_constants_ios(self, slot, data, size_bytes);
        }
    }

    set_scissor :: (self: *MetalGPU, x: s32, y: s32, w: s32, h: s32) {
        inline if OS == .ios {
            metal_set_scissor_ios(self, x, y, w, h);
        }
    }

    disable_scissor :: (self: *MetalGPU) {
        inline if OS == .ios {
            metal_disable_scissor_ios(self);
        }
    }

    draw_triangles :: (self: *MetalGPU, vertex_offset: s32, vertex_count: s32) {
        inline if OS == .ios {
            metal_draw_triangles_ios(self, vertex_offset, vertex_count);
        }
    }
}

// ───────────────────────────────────────────────────────────────────────────
// iOS-only helpers — only reachable from `inline if OS == .ios` call sites,
// so non-iOS builds never reference the unresolved Metal symbols below.
// ───────────────────────────────────────────────────────────────────────────

// init() may be called twice: once with target==null to create device +
// queue eagerly (so the UI pipeline can compile shaders before UIKit
// has a layer for us), then again with target=CAMetalLayer once
// `-[SxAppDelegate didFinishLaunching:]` has installed the view.
// Both calls go through this helper; it's idempotent on the device/queue
// and only touches the layer when one's been supplied.
metal_init_ios :: (self: *MetalGPU) -> bool {
    inline if OS != .ios { return false; }

    if self.device == null {
        self.device = MTLCreateSystemDefaultDevice();
        if self.device == null { return false; }
    }

    msg_oo    : (*void, *void, *void)  -> void  = xx objc_msgSend;
    msg_ou    : (*void, *void, u64)    -> void  = xx objc_msgSend;
    msg_ob    : (*void, *void, u8)     -> void  = xx objc_msgSend;
    msg_osize : (*void, *void, CGSize) -> void  = xx objc_msgSend;
    msg_o     : (*void, *void)         -> *void = xx objc_msgSend;

    if self.queue == null {
        self.queue = msg_o(self.device, sel_registerName("newCommandQueue".ptr));
        if self.queue == null { return false; }
    }

    if self.layer != null {
        msg_oo(self.layer, sel_registerName("setDevice:".ptr), self.device);
        msg_ou(self.layer, sel_registerName("setPixelFormat:".ptr), MTL_PIXEL_FORMAT_BGRA8_UNORM);
        msg_ob(self.layer, sel_registerName("setFramebufferOnly:".ptr), 1);

        // setDrawableSize:(0,0) makes nextDrawable abort via XPC. Skip the
        // size set when dims are not yet known — the layer's drawableSize
        // defaults to its bounds×contentsScale until we override it, which
        // also lets the first frame render at the natural backing size.
        if self.pixel_w > 0 and self.pixel_h > 0 {
            size := CGSize.{ width = xx self.pixel_w, height = xx self.pixel_h };
            msg_osize(self.layer, sel_registerName("setDrawableSize:".ptr), size);
        }
    }

    true;
}

metal_resize_ios :: (self: *MetalGPU) {
    inline if OS != .ios { return; }
    if self.layer == null { return; }

    msg_osize : (*void, *void, CGSize) -> void = xx objc_msgSend;
    size := CGSize.{ width = xx self.pixel_w, height = xx self.pixel_h };
    msg_osize(self.layer, sel_registerName("setDrawableSize:".ptr), size);
}

metal_begin_frame_ios :: (self: *MetalGPU, clear: ClearColor) -> bool {
    inline if OS != .ios { return false; }
    if self.layer == null { return false; }
    if self.queue == null { return false; }
    if self.pixel_w <= 0 or self.pixel_h <= 0 { return false; }

    msg_o      : (*void, *void)                -> *void = xx objc_msgSend;
    msg_oo     : (*void, *void, *void)         -> void  = xx objc_msgSend;
    msg_oo_ret : (*void, *void, *void)         -> *void = xx objc_msgSend;
    msg_ou     : (*void, *void, u64)           -> void  = xx objc_msgSend;
    msg_ouret  : (*void, *void, u64)           -> *void = xx objc_msgSend;
    msg_oclear : (*void, *void, MTLClearColor) -> void  = xx objc_msgSend;

    // drawable = [layer nextDrawable]
    self.drawable = msg_o(self.layer, sel_registerName("nextDrawable".ptr));
    if self.drawable == null { return false; }

    // tex = [drawable texture]
    drawable_texture := msg_o(self.drawable, sel_registerName("texture".ptr));

    // pass = [MTLRenderPassDescriptor renderPassDescriptor]  (autoreleased)
    MTLRenderPassDescriptor := objc_getClass("MTLRenderPassDescriptor".ptr);
    pass := msg_o(MTLRenderPassDescriptor, sel_registerName("renderPassDescriptor".ptr));

    // color0 = pass.colorAttachments[0]
    attachments := msg_o(pass, sel_registerName("colorAttachments".ptr));
    color0 := msg_ouret(attachments, sel_registerName("objectAtIndexedSubscript:".ptr), 0);

    msg_oo(color0, sel_registerName("setTexture:".ptr), drawable_texture);
    msg_ou(color0, sel_registerName("setLoadAction:".ptr), MTL_LOAD_ACTION_CLEAR);
    msg_ou(color0, sel_registerName("setStoreAction:".ptr), MTL_STORE_ACTION_STORE);

    mtl_clear := MTLClearColor.{
        red   = xx clear.r,
        green = xx clear.g,
        blue  = xx clear.b,
        alpha = xx clear.a,
    };
    msg_oclear(color0, sel_registerName("setClearColor:".ptr), mtl_clear);

    // cmd = [queue commandBuffer]  (autoreleased)
    self.cmd_buffer = msg_o(self.queue, sel_registerName("commandBuffer".ptr));
    if self.cmd_buffer == null { self.drawable = null; return false; }

    // encoder = [cmd renderCommandEncoderWithDescriptor:pass]  (autoreleased)
    self.encoder = msg_oo_ret(self.cmd_buffer,
        sel_registerName("renderCommandEncoderWithDescriptor:".ptr), pass);
    if self.encoder == null { self.cmd_buffer = null; self.drawable = null; return false; }

    true;
}

metal_end_frame_ios :: (self: *MetalGPU, target_time: f64) {
    inline if OS != .ios { return; }
    if self.encoder == null { return; }
    if self.cmd_buffer == null { return; }
    if self.drawable == null { return; }

    msg_v   : (*void, *void)             -> void = xx objc_msgSend;
    msg_oo  : (*void, *void, *void)      -> void = xx objc_msgSend;
    msg_ood : (*void, *void, *void, f64) -> void = xx objc_msgSend;

    msg_v(self.encoder, sel_registerName("endEncoding".ptr));

    // target_time > 0 → presentDrawable:atTime: (lockstep path).
    // target_time == 0 → fall back to presentDrawable: (immediate).
    if target_time > 0.0 {
        msg_ood(self.cmd_buffer, sel_registerName("presentDrawable:atTime:".ptr),
            self.drawable, target_time);
    } else {
        msg_oo(self.cmd_buffer, sel_registerName("presentDrawable:".ptr), self.drawable);
    }

    msg_v(self.cmd_buffer, sel_registerName("commit".ptr));

    self.encoder    = null;
    self.cmd_buffer = null;
    self.drawable   = null;
}

// ── Shader (MSL pipeline state) ──────────────────────────────────────────
// Compile the MSL source, look up the conventional entry points `vmain`
// (vertex) and `fmain` (fragment), and produce an `MTLRenderPipelineState`
// targeted at the layer's BGRA8 surface with standard alpha blending.
// The fsrc parameter is ignored — Metal's library is one MSL file with
// both functions; pass the combined source as vsrc.

metal_create_shader_ios :: (self: *MetalGPU, src: string) -> u32 {
    inline if OS != .ios { return 0; }
    if self.device == null { return 0; }

    msg_o    : (*void, *void)               -> *void = xx objc_msgSend;
    msg_oo   : (*void, *void, *void)        -> void  = xx objc_msgSend;
    msg_oo_r : (*void, *void, *void)        -> *void = xx objc_msgSend;
    msg_ou   : (*void, *void, u64)          -> void  = xx objc_msgSend;
    msg_ouret: (*void, *void, u64)          -> *void = xx objc_msgSend;
    msg_ob   : (*void, *void, u8)           -> void  = xx objc_msgSend;

    // [device newLibraryWithSource:src options:nil error:&err]
    msg_lib : (*void, *void, *void, *void, **void) -> *void = xx objc_msgSend;
    src_ns := ns_string(src.ptr);
    err : *void = null;
    library := msg_lib(self.device,
        sel_registerName("newLibraryWithSource:options:error:".ptr),
        src_ns, xx 0, @err);
    if library == null {
        NSLog(ns_string("[metal] MSL compile failed\n".ptr));
        return 0;
    }

    vfn := msg_oo_r(library, sel_registerName("newFunctionWithName:".ptr),
        ns_string("vmain".ptr));
    ffn := msg_oo_r(library, sel_registerName("newFunctionWithName:".ptr),
        ns_string("fmain".ptr));
    if vfn == null { NSLog(ns_string("[metal] missing vmain in MSL\n".ptr)); return 0; }
    if ffn == null { NSLog(ns_string("[metal] missing fmain in MSL\n".ptr)); return 0; }

    MTLRenderPipelineDescriptor := objc_getClass("MTLRenderPipelineDescriptor".ptr);
    desc := msg_o(MTLRenderPipelineDescriptor, sel_registerName("alloc".ptr));
    desc = msg_o(desc, sel_registerName("init".ptr));

    msg_oo(desc, sel_registerName("setVertexFunction:".ptr), vfn);
    msg_oo(desc, sel_registerName("setFragmentFunction:".ptr), ffn);

    // colorAttachments[0]: pixel format + alpha blending.
    atts := msg_o(desc, sel_registerName("colorAttachments".ptr));
    att0 := msg_ouret(atts, sel_registerName("objectAtIndexedSubscript:".ptr), 0);
    msg_ou(att0, sel_registerName("setPixelFormat:".ptr), MTL_PIXEL_FORMAT_BGRA8_UNORM);
    msg_ob(att0, sel_registerName("setBlendingEnabled:".ptr), 1);
    msg_ou(att0, sel_registerName("setSourceRGBBlendFactor:".ptr), MTL_BLEND_FACTOR_SRC_ALPHA);
    msg_ou(att0, sel_registerName("setDestinationRGBBlendFactor:".ptr), MTL_BLEND_FACTOR_ONE_MINUS_SRC_A);
    msg_ou(att0, sel_registerName("setSourceAlphaBlendFactor:".ptr), MTL_BLEND_FACTOR_SRC_ALPHA);
    msg_ou(att0, sel_registerName("setDestinationAlphaBlendFactor:".ptr), MTL_BLEND_FACTOR_ONE_MINUS_SRC_A);

    msg_pipe : (*void, *void, *void, **void) -> *void = xx objc_msgSend;
    err2 : *void = null;
    state := msg_pipe(self.device,
        sel_registerName("newRenderPipelineStateWithDescriptor:error:".ptr),
        desc, @err2);
    if state == null {
        NSLog(ns_string("[metal] pipeline state creation failed\n".ptr));
        return 0;
    }

    self.shaders.append(state);
    xx self.shaders.len;
}

// ── Buffers ──────────────────────────────────────────────────────────────
// Shared-memory MTLBuffer (CPU + GPU visible on UMA hardware). `contents`
// returns the mapped pointer for memcpy uploads.

metal_create_buffer_ios :: (self: *MetalGPU, size_bytes: s64) -> u32 {
    inline if OS != .ios { return 0; }
    if self.device == null { return 0; }
    if size_bytes <= 0 { return 0; }

    // MTLResourceStorageModeShared is the default (option value 0).
    msg_buf : (*void, *void, u64, u64) -> *void = xx objc_msgSend;
    buf := msg_buf(self.device,
        sel_registerName("newBufferWithLength:options:".ptr),
        xx size_bytes, 0);
    if buf == null { return 0; }

    self.buffers.append(buf);
    xx self.buffers.len;
}

metal_update_buffer_ios :: (self: *MetalGPU, handle: u32, data: *void, size_bytes: s64) {
    inline if OS != .ios { return; }
    buf := metal_lookup_buffer(self, handle);
    if buf == null { return; }
    if data == null { return; }
    if size_bytes <= 0 { return; }

    msg_o : (*void, *void) -> *void = xx objc_msgSend;
    dst := msg_o(buf, sel_registerName("contents".ptr));
    if dst == null { return; }
    memcpy(dst, data, size_bytes);
}

metal_update_buffer_at_ios :: (self: *MetalGPU, handle: u32, data: *void, size_bytes: s64, byte_offset: s64) {
    inline if OS != .ios { return; }
    buf := metal_lookup_buffer(self, handle);
    if buf == null { return; }
    if data == null { return; }
    if size_bytes <= 0 { return; }
    if byte_offset < 0 { return; }

    msg_o : (*void, *void) -> *void = xx objc_msgSend;
    base := msg_o(buf, sel_registerName("contents".ptr));
    if base == null { return; }
    // Add byte_offset via integer arithmetic — `@dst[i]` on `[*]u8`
    // already does this, but we keep this form explicit so a future
    // pointer-arithmetic regression here can't hide.
    base_i : s64 = xx base;
    dst_at : *void = xx (base_i + byte_offset);
    memcpy(dst_at, data, size_bytes);
}

metal_lookup_buffer :: (self: *MetalGPU, handle: u32) -> *void {
    inline if OS != .ios { return null; }
    if handle == 0 { return null; }
    h64 : s64 = xx handle;
    if h64 > self.buffers.len { return null; }
    self.buffers.items[handle - 1];
}

metal_lookup_shader :: (self: *MetalGPU, handle: u32) -> *void {
    inline if OS != .ios { return null; }
    if handle == 0 { return null; }
    h64 : s64 = xx handle;
    if h64 > self.shaders.len { return null; }
    self.shaders.items[handle - 1];
}

// ── Textures ─────────────────────────────────────────────────────────────

metal_create_texture_ios :: (self: *MetalGPU, w: s32, h: s32, format: TextureFormat, pixels: *void) -> u32 {
    inline if OS != .ios { return 0; }
    if self.device == null { return 0; }
    if w <= 0 { return 0; }
    if h <= 0 { return 0; }

    pixel_format : u64 = 0;
    bytes_per_pixel : u32 = 0;
    if format == .rgba8 {
        pixel_format = MTL_PIXEL_FORMAT_RGBA8_UNORM;
        bytes_per_pixel = 4;
    } else {
        pixel_format = MTL_PIXEL_FORMAT_R8_UNORM;
        bytes_per_pixel = 1;
    }

    // [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:width:height:mipmapped:]
    MTLTextureDescriptor := objc_getClass("MTLTextureDescriptor".ptr);
    msg_desc : (*void, *void, u64, u64, u64, u8) -> *void = xx objc_msgSend;
    desc := msg_desc(MTLTextureDescriptor,
        sel_registerName("texture2DDescriptorWithPixelFormat:width:height:mipmapped:".ptr),
        pixel_format, xx w, xx h, 0);
    if desc == null { return 0; }

    // Force shared storage so the CPU can keep writing pixels (atlas updates,
    // sprite uploads). On iOS-sim under Apple Silicon the convenience class
    // method's default storage isn't reliably shared for every format.
    msg_ou_void : (*void, *void, u64) -> void = xx objc_msgSend;
    msg_ou_void(desc, sel_registerName("setStorageMode:".ptr), MTL_STORAGE_MODE_SHARED);

    msg_oo : (*void, *void, *void) -> *void = xx objc_msgSend;
    tex := msg_oo(self.device, sel_registerName("newTextureWithDescriptor:".ptr), desc);
    if tex == null { return 0; }

    slot : TextureSlot = .{ tex = tex, bytes_per_pixel = bytes_per_pixel };
    self.textures.append(slot);

    if pixels != null {
        handle : u32 = xx self.textures.len;
        metal_update_texture_region_ios(self, handle, 0, 0, w, h, pixels);
    }

    xx self.textures.len;
}

metal_update_texture_region_ios :: (self: *MetalGPU, handle: u32, x: s32, y: s32, w: s32, h: s32, pixels: *void) {
    inline if OS != .ios { return; }
    if handle == 0 { return; }
    h64 : s64 = xx handle;
    if h64 > self.textures.len { return; }
    slot := self.textures.items[handle - 1];
    if slot.tex == null { return; }
    if pixels == null { return; }
    if w <= 0 { return; }
    if h <= 0 { return; }

    region : MTLRegion = .{
        origin = .{ x = xx x, y = xx y, z = 0 },
        size   = .{ width = xx w, height = xx h, depth = 1 },
    };
    bytes_per_row : u64 = xx (slot.bytes_per_pixel * cast(u32) w);

    // [tex replaceRegion:region mipmapLevel:0 withBytes:pixels bytesPerRow:bytes_per_row]
    msg_replace : (*void, *void, MTLRegion, u64, *void, u64) -> void callconv(.c) = xx objc_msgSend;
    msg_replace(slot.tex,
        sel_registerName("replaceRegion:mipmapLevel:withBytes:bytesPerRow:".ptr),
        region, 0, pixels, bytes_per_row);
}

// ── Destroy ──────────────────────────────────────────────────────────────
// `release` on a Metal object whose retain count reaches zero deallocates
// it. Our resource Lists hold the only strong reference (Metal returns
// retained-+1 objects from `new*` and our `append` doesn't retain), so a
// single `release` is correct. Slots are null'd so a subsequent lookup
// short-circuits; the List length doesn't shrink (would break later
// handle->index mapping).

metal_destroy_shader_ios :: (self: *MetalGPU, handle: u32) {
    inline if OS != .ios { return; }
    if handle == 0 { return; }
    h64 : s64 = xx handle;
    if h64 > self.shaders.len { return; }
    obj := self.shaders.items[handle - 1];
    if obj == null { return; }
    msg : (*void, *void) -> void = xx objc_msgSend;
    msg(obj, sel_registerName("release".ptr));
    self.shaders.items[handle - 1] = null;
}

metal_destroy_buffer_ios :: (self: *MetalGPU, handle: u32) {
    inline if OS != .ios { return; }
    if handle == 0 { return; }
    h64 : s64 = xx handle;
    if h64 > self.buffers.len { return; }
    obj := self.buffers.items[handle - 1];
    if obj == null { return; }
    msg : (*void, *void) -> void = xx objc_msgSend;
    msg(obj, sel_registerName("release".ptr));
    self.buffers.items[handle - 1] = null;
}

metal_destroy_texture_ios :: (self: *MetalGPU, handle: u32) {
    inline if OS != .ios { return; }
    if handle == 0 { return; }
    h64 : s64 = xx handle;
    if h64 > self.textures.len { return; }
    obj := self.textures.items[handle - 1].tex;
    if obj == null { return; }
    msg : (*void, *void) -> void = xx objc_msgSend;
    msg(obj, sel_registerName("release".ptr));
    self.textures.items[handle - 1].tex = null;
    self.textures.items[handle - 1].bytes_per_pixel = 0;
}

// ── Per-draw state ───────────────────────────────────────────────────────

metal_set_shader_ios :: (self: *MetalGPU, sh: u32) {
    inline if OS != .ios { return; }
    if self.encoder == null { return; }
    state := metal_lookup_shader(self, sh);
    if state == null { return; }
    msg : (*void, *void, *void) -> void = xx objc_msgSend;
    msg(self.encoder, sel_registerName("setRenderPipelineState:".ptr), state);
}

metal_set_vertex_buffer_ios :: (self: *MetalGPU, h: u32) {
    inline if OS != .ios { return; }
    if self.encoder == null { return; }
    buf := metal_lookup_buffer(self, h);
    if buf == null { return; }
    // [encoder setVertexBuffer:buf offset:0 atIndex:0]
    msg : (*void, *void, *void, u64, u64) -> void = xx objc_msgSend;
    msg(self.encoder, sel_registerName("setVertexBuffer:offset:atIndex:".ptr), buf, 0, 0);
}

metal_set_texture_ios :: (self: *MetalGPU, slot: u32, h: u32) {
    inline if OS != .ios { return; }
    if self.encoder == null { return; }
    if h == 0 { return; }
    h64 : s64 = xx h;
    if h64 > self.textures.len { return; }
    tex := self.textures.items[h - 1].tex;
    if tex == null { return; }
    // [encoder setFragmentTexture:tex atIndex:slot]
    msg : (*void, *void, *void, u64) -> void = xx objc_msgSend;
    msg(self.encoder, sel_registerName("setFragmentTexture:atIndex:".ptr), tex, xx slot);
}

metal_set_vertex_constants_ios :: (self: *MetalGPU, slot: u32, data: *void, size_bytes: s64) {
    inline if OS != .ios { return; }
    if self.encoder == null { return; }
    if data == null { return; }
    if size_bytes <= 0 { return; }
    // [encoder setVertexBytes:data length:size_bytes atIndex:slot]
    msg : (*void, *void, *void, u64, u64) -> void = xx objc_msgSend;
    msg(self.encoder, sel_registerName("setVertexBytes:length:atIndex:".ptr),
        data, xx size_bytes, xx slot);
}

metal_set_scissor_ios :: (self: *MetalGPU, x: s32, y: s32, w: s32, h: s32) {
    inline if OS != .ios { return; }
    if self.encoder == null { return; }
    rect : MTLScissorRect = .{ x = xx x, y = xx y, width = xx w, height = xx h };
    // [encoder setScissorRect:rect]  (MTLScissorRect is 32 bytes → ptr byval)
    msg : (*void, *void, MTLScissorRect) -> void callconv(.c) = xx objc_msgSend;
    msg(self.encoder, sel_registerName("setScissorRect:".ptr), rect);
}

metal_disable_scissor_ios :: (self: *MetalGPU) {
    inline if OS != .ios { return; }
    if self.encoder == null { return; }
    // Metal has no "disable scissor" — set the rect to cover the full
    // drawable so subsequent draws aren't clipped.
    rect : MTLScissorRect = .{ x = 0, y = 0, width = xx self.pixel_w, height = xx self.pixel_h };
    msg : (*void, *void, MTLScissorRect) -> void callconv(.c) = xx objc_msgSend;
    msg(self.encoder, sel_registerName("setScissorRect:".ptr), rect);
}

metal_draw_triangles_ios :: (self: *MetalGPU, vertex_offset: s32, vertex_count: s32) {
    inline if OS != .ios { return; }
    if self.encoder == null { return; }
    if vertex_count <= 0 { return; }
    // [encoder drawPrimitives:.triangle vertexStart:offset vertexCount:count]
    msg : (*void, *void, u64, u64, u64) -> void = xx objc_msgSend;
    msg(self.encoder, sel_registerName("drawPrimitives:vertexStart:vertexCount:".ptr),
        MTL_PRIMITIVE_TYPE_TRIANGLE, xx vertex_offset, xx vertex_count);
}
