package draw

import "base:runtime"
import "core:c"
import "core:log"
import "core:math"

import "core:strings"
import sdl "vendor:sdl3"
import sdl_ttf "vendor:sdl3/ttf"

import clay "../vendor/clay"

when ODIN_OS == .Darwin {
	PLATFORM_SHADER_FORMAT_FLAG :: sdl.GPUShaderFormatFlag.MSL
	SHADER_ENTRY :: cstring("main0")
	BASE_VERT_2D_RAW :: #load("shaders/generated/base_2d.vert.metal")
	BASE_FRAG_2D_RAW :: #load("shaders/generated/base_2d.frag.metal")
} else {
	PLATFORM_SHADER_FORMAT_FLAG :: sdl.GPUShaderFormatFlag.SPIRV
	SHADER_ENTRY :: cstring("main")
	BASE_VERT_2D_RAW :: #load("shaders/generated/base_2d.vert.spv")
	BASE_FRAG_2D_RAW :: #load("shaders/generated/base_2d.frag.spv")
}
PLATFORM_SHADER_FORMAT :: sdl.GPUShaderFormat{PLATFORM_SHADER_FORMAT_FLAG}

BUFFER_INIT_SIZE :: 256
INITIAL_LAYER_SIZE :: 5
INITIAL_SCISSOR_SIZE :: 10

// Sentinel value: when passed as msaa_samples, `init` will use the maximum MSAA sample count
// supported by the GPU for the swapchain format.
MSAA_MAX :: sdl.GPUSampleCount(0xFF)

// ----- Default parameter values -----
// Named constants for non-zero default procedure parameters. Centralizes magic numbers
// so they can be tuned in one place and referenced by name in proc signatures.
DFT_FEATHER_PX :: 1 // Total AA feather width in physical pixels (half on each side of boundary).
DFT_STROKE_THICKNESS :: 1 // Default line/stroke thickness in logical pixels.
DFT_FONT_SIZE :: 44 // Default font size in points for text rendering.
DFT_CIRC_END_ANGLE :: 360 // Full-circle end angle in degrees (ring/arc).
DFT_UV_RECT :: Rectangle{0, 0, 1, 1} // Full-texture UV rect (rectangle_texture).
DFT_TINT :: WHITE // Default texture tint (rectangle_texture, clay_image).
DFT_TEXT_COLOR :: BLACK // Default text color.
DFT_CLEAR_COLOR :: BLACK // Default clear color for end().
DFT_SAMPLER :: Sampler_Preset.Linear_Clamp // Default texture sampler preset.

GLOB: Global

Global :: struct {
	// -- Per-frame staging (hottest — touched by every prepare/upload/clear cycle) --
	tmp_shape_verts:          [dynamic]Vertex, // Tessellated shape vertices staged for GPU upload.
	tmp_text_verts:           [dynamic]Vertex, // Text vertices staged for GPU upload.
	tmp_text_indices:         [dynamic]c.int, // Text index buffer staged for GPU upload.
	tmp_text_batches:         [dynamic]TextBatch, // Text atlas batch metadata for indexed drawing.
	tmp_primitives:           [dynamic]Primitive, // SDF primitives staged for GPU storage buffer upload.
	tmp_sub_batches:          [dynamic]Sub_Batch, // Sub-batch records that drive draw call dispatch.
	tmp_uncached_text:        [dynamic]^sdl_ttf.Text, // Uncached TTF_Text objects destroyed after end() submits.
	layers:                   [dynamic]Layer, // Draw layers, each with its own scissor stack.
	scissors:                 [dynamic]Scissor, // Scissor rects that clip drawing within each layer.

	// -- Per-frame scalars (accessed during prepare and draw_layer) --
	curr_layer_index:         uint, // Index of the currently active layer.
	dpi_scaling:              f32, // Window DPI scale factor applied to all pixel coordinates.
	clay_z_index:             i16, // Tracks z-index for layer splitting during Clay batch processing.
	cleared:                  bool, // Whether the render target has been cleared this frame.

	// -- Pipeline (accessed every draw_layer call) --
	pipeline_2d_base:         Pipeline_2D_Base, // The unified 2D GPU pipeline (shaders, buffers, samplers).
	device:                   ^sdl.GPUDevice, // GPU device handle, stored at init.
	samplers:                 [SAMPLER_PRESET_COUNT]^sdl.GPUSampler, // Lazily-created sampler objects, one per Sampler_Preset.

	// -- Deferred release (processed once per frame at frame boundary) --
	pending_texture_releases: [dynamic]Texture_Id, // Deferred GPU texture releases, processed next frame.
	pending_text_releases:    [dynamic]^sdl_ttf.Text, // Deferred TTF_Text destroys, processed next frame.

	// -- Textures (registration is occasional, binding is per draw call) --
	texture_slots:            [dynamic]Texture_Slot, // Registered texture slots indexed by Texture_Id.
	texture_free_list:        [dynamic]u32, // Recycled slot indices available for reuse.

	// -- MSAA (once per frame in end()) --
	msaa_texture:             ^sdl.GPUTexture, // Intermediate render target for multi-sample resolve.
	msaa_width:               u32, // Cached width to detect when MSAA texture needs recreation.
	msaa_height:              u32, // Cached height to detect when MSAA texture needs recreation.
	sample_count:             sdl.GPUSampleCount, // Sample count chosen at init (._1 means MSAA disabled).

	// -- Clay (once per frame in prepare_clay_batch) --
	clay_memory:              [^]u8, // Raw memory block backing Clay's internal arena.

	// -- Text (occasional — font registration and text cache lookups) --
	text_cache:               Text_Cache, // Font registry, SDL_ttf engine, and cached TTF_Text objects.

	// -- Resize tracking (cold — checked once per frame in resize_global) --
	max_layers:               int, // High-water marks for dynamic array shrink heuristic.
	max_scissors:             int,
	max_shape_verts:          int,
	max_text_verts:           int,
	max_text_indices:         int,
	max_text_batches:         int,
	max_primitives:           int,
	max_sub_batches:          int,

	// -- Init-only (coldest — set once at init, never written again) --
	odin_context:             runtime.Context, // Odin context captured at init for use in callbacks.
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Core types --------------------
// ---------------------------------------------------------------------------------------------------------------------

// A 2D position in world space. Non-distinct alias for [2]f32 — bare literals like {100, 200}
// work at non-ambiguous call sites.
//
// Coordinate system: origin is the top-left corner of the window/layer. X increases rightward,
// Y increases downward. This matches SDL, HTML Canvas, and most 2D UI coordinate conventions.
// All position parameters in the draw API (center, origin, start_position, end_position, etc.)
// use this coordinate system.
//
// Units are logical pixels (pre-DPI-scaling). The renderer multiplies by dpi_scaling internally
// before uploading to the GPU. A Vec2{100, 50} refers to the same visual location regardless of
// display DPI.
Vec2 :: [2]f32

// An RGBA color with 8 bits per channel. Distinct type over [4]u8 so that proc-group
// overloads can disambiguate Color from other 4-byte structs.
//
// Channel order: R, G, B, A (indices 0, 1, 2, 3). Alpha 255 is fully opaque, 0 is fully
// transparent. This matches the GPU-side layout: the shader unpacks via unpackUnorm4x8 which
// reads the bytes in memory order as R, G, B, A and normalizes each to [0, 1].
//
// When used in the Primitive struct (Primitive.color), the 4 bytes are stored as a u32 in
// native byte order and unpacked by the shader.
Color :: [4]u8

BLACK :: Color{0, 0, 0, 255}
WHITE :: Color{255, 255, 255, 255}
RED :: Color{255, 0, 0, 255}
GREEN :: Color{0, 255, 0, 255}
BLUE :: Color{0, 0, 255, 255}
BLANK :: Color{0, 0, 0, 0}

// Per-corner rounding radii for rectangles, specified clockwise from top-left.
// All values are in logical pixels (pre-DPI-scaling).
Rectangle_Radii :: struct {
	top_left:     f32,
	top_right:    f32,
	bottom_right: f32,
	bottom_left:  f32,
}

// A linear gradient between two colors along an arbitrary angle.
// The `end_color` is the color at the end of the gradient direction; the shape's fill `color`
// parameter acts as the start color. `angle` is in degrees: 0 = left-to-right, 90 = top-to-bottom.
Linear_Gradient :: struct {
	end_color: Color,
	angle:     f32,
}

// A radial gradient between two colors from center to edge.
// The `outer_color` is the color at the shape's edge; the shape's fill `color` parameter
// acts as the inner (center) color.
Radial_Gradient :: struct {
	outer_color: Color,
}

// Tagged union for specifying a gradient on any shape. Defaults to `nil` (no gradient).
// When a gradient is active, the shape's `color` parameter becomes the start/inner color,
// and the gradient struct carries the end/outer color plus any type-specific parameters.
//
// Gradient and Textured are mutually exclusive on the same primitive. If a shape uses
// `rectangle_texture`, gradients are not applicable — use the tint color instead.
Gradient :: union {
	Linear_Gradient,
	Radial_Gradient,
}

// Convert clay.Color ([4]c.float in 0–255 range) to Color.
color_from_clay :: #force_inline proc(clay_color: clay.Color) -> Color {
	return Color{u8(clay_color[0]), u8(clay_color[1]), u8(clay_color[2]), u8(clay_color[3])}
}

// Convert Color to [4]f32 in 0.0–1.0 range. Useful for SDL interop (e.g. clear color).
color_to_f32 :: proc(color: Color) -> [4]f32 {
	INV :: 1.0 / 255.0
	return {f32(color[0]) * INV, f32(color[1]) * INV, f32(color[2]) * INV, f32(color[3]) * INV}
}

// Pre-multiply RGB channels by alpha. The tessellated vertex path and text path require
// premultiplied colors because the blend state is ONE, ONE_MINUS_SRC_ALPHA and the
// tessellated fragment shader passes vertex color through without further modification.
// Users who construct Vertex structs manually for prepare_shape must premultiply their colors.
premultiply_color :: #force_inline proc(color: Color) -> Color {
	a := u32(color[3])
	return Color {
		u8((u32(color[0]) * a + 127) / 255),
		u8((u32(color[1]) * a + 127) / 255),
		u8((u32(color[2]) * a + 127) / 255),
		color[3],
	}
}

Rectangle :: struct {
	x:      f32,
	y:      f32,
	width:  f32,
	height: f32,
}

Sub_Batch_Kind :: enum u8 {
	Tessellated, // non-indexed, white texture or user texture, mode 0
	Text, // indexed, atlas texture, mode 0
	SDF, // instanced unit quad, white texture or user texture, mode 1
}

Sub_Batch :: struct {
	kind:       Sub_Batch_Kind,
	offset:     u32, // Tessellated: vertex offset; Text: text_batch index; SDF: primitive index
	count:      u32, // Tessellated: vertex count; Text: always 1; SDF: primitive count
	texture_id: Texture_Id,
	sampler:    Sampler_Preset,
}

Layer :: struct {
	bounds:          Rectangle,
	sub_batch_start: u32,
	sub_batch_len:   u32,
	scissor_start:   u32,
	scissor_len:     u32,
}

Scissor :: struct {
	bounds:          sdl.Rect,
	sub_batch_start: u32,
	sub_batch_len:   u32,
}

Init_Options :: struct {
	// MSAA sample count. Default is ._1 (no MSAA). SDF rendering does not benefit from MSAA
	// because SDF fragments compute coverage analytically via `smoothstep`. MSAA helps for
	// text glyph edges and tessellated user geometry. Set to ._4 or ._8 for text-heavy UIs,
	// or use `MSAA_MAX` to request the highest sample count the GPU supports for the swapchain
	// format.
	msaa_samples: sdl.GPUSampleCount,
}

// Initialize the renderer. Returns false if GPU pipeline or text engine creation fails.
@(require_results)
init :: proc(
	device: ^sdl.GPUDevice,
	window: ^sdl.Window,
	options: Init_Options = {},
	allocator := context.allocator,
	odin_context := context,
) -> (
	ok: bool,
) {
	min_memory_size: c.size_t = cast(c.size_t)clay.MinMemorySize()
	resolved_sample_count := options.msaa_samples
	if resolved_sample_count == MSAA_MAX {
		resolved_sample_count = max_sample_count(device, window)
	}

	pipeline, pipeline_ok := create_pipeline_2d_base(device, window, resolved_sample_count)
	if !pipeline_ok {
		return false
	}

	text_cache, text_ok := init_text_cache(device, allocator)
	if !text_ok {
		destroy_pipeline_2d_base(device, &pipeline)
		return false
	}

	GLOB = Global {
		layers                   = make([dynamic]Layer, 0, INITIAL_LAYER_SIZE, allocator = allocator),
		scissors                 = make([dynamic]Scissor, 0, INITIAL_SCISSOR_SIZE, allocator = allocator),
		tmp_shape_verts          = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator),
		tmp_text_verts           = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator),
		tmp_text_indices         = make([dynamic]c.int, 0, BUFFER_INIT_SIZE, allocator = allocator),
		tmp_text_batches         = make([dynamic]TextBatch, 0, BUFFER_INIT_SIZE, allocator = allocator),
		tmp_primitives           = make([dynamic]Primitive, 0, BUFFER_INIT_SIZE, allocator = allocator),
		tmp_sub_batches          = make([dynamic]Sub_Batch, 0, BUFFER_INIT_SIZE, allocator = allocator),
		tmp_uncached_text        = make([dynamic]^sdl_ttf.Text, 0, 16, allocator = allocator),
		device                   = device,
		texture_slots            = make([dynamic]Texture_Slot, 0, 16, allocator = allocator),
		texture_free_list        = make([dynamic]u32, 0, 16, allocator = allocator),
		pending_texture_releases = make([dynamic]Texture_Id, 0, 16, allocator = allocator),
		pending_text_releases    = make([dynamic]^sdl_ttf.Text, 0, 16, allocator = allocator),
		odin_context             = odin_context,
		dpi_scaling              = sdl.GetWindowDisplayScale(window),
		clay_memory              = make([^]u8, min_memory_size, allocator = allocator),
		sample_count             = resolved_sample_count,
		pipeline_2d_base         = pipeline,
		text_cache               = text_cache,
	}

	// Reserve slot 0 for INVALID_TEXTURE
	append(&GLOB.texture_slots, Texture_Slot{})
	log.debug("Window DPI scaling:", GLOB.dpi_scaling)
	arena := clay.CreateArenaWithCapacityAndMemory(min_memory_size, GLOB.clay_memory)
	window_width, window_height: c.int
	sdl.GetWindowSize(window, &window_width, &window_height)

	clay.Initialize(arena, {f32(window_width), f32(window_height)}, {handler = clay_error_handler})
	clay.SetMeasureTextFunction(measure_text_clay, nil)
	return true
}

// TODO Either every x frames nuke max values in case of edge cases where max gets set very high
// or leave to application code to decide the right time for resize
resize_global :: proc() {
	if len(GLOB.layers) > GLOB.max_layers do GLOB.max_layers = len(GLOB.layers)
	shrink(&GLOB.layers, GLOB.max_layers)
	if len(GLOB.scissors) > GLOB.max_scissors do GLOB.max_scissors = len(GLOB.scissors)
	shrink(&GLOB.scissors, GLOB.max_scissors)
	if len(GLOB.tmp_shape_verts) > GLOB.max_shape_verts do GLOB.max_shape_verts = len(GLOB.tmp_shape_verts)
	shrink(&GLOB.tmp_shape_verts, GLOB.max_shape_verts)
	if len(GLOB.tmp_text_verts) > GLOB.max_text_verts do GLOB.max_text_verts = len(GLOB.tmp_text_verts)
	shrink(&GLOB.tmp_text_verts, GLOB.max_text_verts)
	if len(GLOB.tmp_text_indices) > GLOB.max_text_indices do GLOB.max_text_indices = len(GLOB.tmp_text_indices)
	shrink(&GLOB.tmp_text_indices, GLOB.max_text_indices)
	if len(GLOB.tmp_text_batches) > GLOB.max_text_batches do GLOB.max_text_batches = len(GLOB.tmp_text_batches)
	shrink(&GLOB.tmp_text_batches, GLOB.max_text_batches)
	if len(GLOB.tmp_primitives) > GLOB.max_primitives do GLOB.max_primitives = len(GLOB.tmp_primitives)
	shrink(&GLOB.tmp_primitives, GLOB.max_primitives)
	if len(GLOB.tmp_sub_batches) > GLOB.max_sub_batches do GLOB.max_sub_batches = len(GLOB.tmp_sub_batches)
	shrink(&GLOB.tmp_sub_batches, GLOB.max_sub_batches)
}

destroy :: proc(device: ^sdl.GPUDevice, allocator := context.allocator) {
	delete(GLOB.layers)
	delete(GLOB.scissors)
	delete(GLOB.tmp_shape_verts)
	delete(GLOB.tmp_text_verts)
	delete(GLOB.tmp_text_indices)
	delete(GLOB.tmp_text_batches)
	delete(GLOB.tmp_primitives)
	delete(GLOB.tmp_sub_batches)
	for ttf_text in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(ttf_text)
	delete(GLOB.tmp_uncached_text)
	free(GLOB.clay_memory, allocator)
	if GLOB.msaa_texture != nil {
		sdl.ReleaseGPUTexture(device, GLOB.msaa_texture)
	}
	process_pending_texture_releases()
	destroy_all_textures()
	destroy_sampler_pool()
	for ttf_text in GLOB.pending_text_releases do sdl_ttf.DestroyText(ttf_text)
	delete(GLOB.pending_text_releases)
	destroy_pipeline_2d_base(device, &GLOB.pipeline_2d_base)
	destroy_text_cache()
}

// Internal
clear_global :: proc() {
	// Process deferred texture releases from the previous frame
	process_pending_texture_releases()
	// Process deferred text releases from the previous frame
	for ttf_text in GLOB.pending_text_releases do sdl_ttf.DestroyText(ttf_text)
	clear(&GLOB.pending_text_releases)

	GLOB.curr_layer_index = 0
	GLOB.clay_z_index = 0
	GLOB.cleared = false
	// Destroy uncached TTF_Text objects from the previous frame (after end() has submitted draw data)
	for ttf_text in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(ttf_text)
	clear(&GLOB.tmp_uncached_text)
	clear(&GLOB.layers)
	clear(&GLOB.scissors)
	clear(&GLOB.tmp_shape_verts)
	clear(&GLOB.tmp_text_verts)
	clear(&GLOB.tmp_text_indices)
	clear(&GLOB.tmp_text_batches)
	clear(&GLOB.tmp_primitives)
	clear(&GLOB.tmp_sub_batches)
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Text measurement (Clay) -------
// ---------------------------------------------------------------------------------------------------------------------

@(private = "file")
measure_text_clay :: proc "c" (
	text: clay.StringSlice,
	config: ^clay.TextElementConfig,
	user_data: rawptr,
) -> clay.Dimensions {
	context = GLOB.odin_context
	text := string(text.chars[:text.length])
	c_text := strings.clone_to_cstring(text, context.temp_allocator)
	defer delete(c_text, context.temp_allocator)
	width, height: c.int
	if !sdl_ttf.GetStringSize(get_font(config.fontId, config.fontSize), c_text, 0, &width, &height) {
		log.panicf("Failed to measure text: %s", sdl.GetError())
	}

	return clay.Dimensions{width = f32(width) / GLOB.dpi_scaling, height = f32(height) / GLOB.dpi_scaling}
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Frame lifecycle ---------------
// ---------------------------------------------------------------------------------------------------------------------

// Sets up renderer to begin upload to the GPU. Returns starting `Layer` to begin processing primitives for.
begin :: proc(bounds: Rectangle) -> ^Layer {
	// Cleanup
	clear_global()

	// Begin new layer + start a new scissor
	scissor := Scissor {
		bounds = sdl.Rect {
			x = i32(bounds.x * GLOB.dpi_scaling),
			y = i32(bounds.y * GLOB.dpi_scaling),
			w = i32(bounds.width * GLOB.dpi_scaling),
			h = i32(bounds.height * GLOB.dpi_scaling),
		},
	}
	append(&GLOB.scissors, scissor)

	layer := Layer {
		bounds      = bounds,
		scissor_len = 1,
	}
	append(&GLOB.layers, layer)
	return &GLOB.layers[GLOB.curr_layer_index]
}

// Creates a new layer
new_layer :: proc(prev_layer: ^Layer, bounds: Rectangle) -> ^Layer {
	layer := Layer {
		bounds          = bounds,
		sub_batch_start = prev_layer.sub_batch_start + prev_layer.sub_batch_len,
		scissor_start   = prev_layer.scissor_start + prev_layer.scissor_len,
		scissor_len     = 1,
	}
	append(&GLOB.layers, layer)
	GLOB.curr_layer_index += 1
	log.debug("Added new layer; curr index", GLOB.curr_layer_index)

	scissor := Scissor {
		sub_batch_start = u32(len(GLOB.tmp_sub_batches)),
		bounds = sdl.Rect {
			x = i32(bounds.x * GLOB.dpi_scaling),
			y = i32(bounds.y * GLOB.dpi_scaling),
			w = i32(bounds.width * GLOB.dpi_scaling),
			h = i32(bounds.height * GLOB.dpi_scaling),
		},
	}
	append(&GLOB.scissors, scissor)
	return &GLOB.layers[GLOB.curr_layer_index]
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Built-in primitive processing --
// ---------------------------------------------------------------------------------------------------------------------

// Submit shape vertices (colored triangles) to the given layer for rendering.
// TODO: Should probably be renamed to better match tesselated naming conventions in the library.
prepare_shape :: proc(layer: ^Layer, vertices: []Vertex) {
	if len(vertices) == 0 do return
	offset := u32(len(GLOB.tmp_shape_verts))
	append(&GLOB.tmp_shape_verts, ..vertices)
	scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
	append_or_extend_sub_batch(scissor, layer, .Tessellated, offset, u32(len(vertices)))
}

// Submit an SDF primitive to the given layer for rendering.
prepare_sdf_primitive :: proc(layer: ^Layer, prim: Primitive) {
	offset := u32(len(GLOB.tmp_primitives))
	append(&GLOB.tmp_primitives, prim)
	scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
	append_or_extend_sub_batch(scissor, layer, .SDF, offset, 1)
}

// Submit a text element to the given layer for rendering.
// Copies SDL_ttf vertices directly (with baked position) and copies indices for indexed drawing.
prepare_text :: proc(layer: ^Layer, text: Text) {
	data := sdl_ttf.GetGPUTextDrawData(text.sdl_text)
	if data == nil {
		return // nil is normal for empty text
	}

	scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]

	// Snap base position to integer physical pixels to avoid atlas sub-pixel
	// sampling blur (and the off-by-one bottom-row clip that comes with it).
	base_x := math.round(text.position[0] * GLOB.dpi_scaling)
	base_y := math.round(text.position[1] * GLOB.dpi_scaling)

	// Premultiply text color once — reused across all glyph vertices.
	pm_color := premultiply_color(text.color)

	for data != nil {
		vertex_start := u32(len(GLOB.tmp_text_verts))
		index_start := u32(len(GLOB.tmp_text_indices))

		// Copy vertices with baked position offset
		for i in 0 ..< data.num_vertices {
			pos := data.xy[i]
			uv := data.uv[i]
			append(
				&GLOB.tmp_text_verts,
				Vertex{position = {pos.x + base_x, -pos.y + base_y}, uv = {uv.x, uv.y}, color = pm_color},
			)
		}

		// Copy indices directly
		append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices])

		batch_idx := u32(len(GLOB.tmp_text_batches))
		append(
			&GLOB.tmp_text_batches,
			TextBatch {
				atlas_texture = data.atlas_texture,
				vertex_start = vertex_start,
				vertex_count = u32(data.num_vertices),
				index_start = index_start,
				index_count = u32(data.num_indices),
			},
		)

		// Each atlas chunk is a separate sub-batch (different atlas textures can't coalesce)
		append_or_extend_sub_batch(scissor, layer, .Text, batch_idx, 1)

		data = data.next
	}
}

// Submit a text element with a 2D affine transform applied to vertices.
// Used by the high-level `text` proc when rotation or a non-zero origin is specified.
// NOTE: xform must be in physical (DPI-scaled) pixel space — the caller pre-scales
// pos and origin by GLOB.dpi_scaling before building the transform.
prepare_text_transformed :: proc(layer: ^Layer, text: Text, transform: Transform_2D) {
	data := sdl_ttf.GetGPUTextDrawData(text.sdl_text)
	if data == nil {
		return
	}

	scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]

	// Premultiply text color once — reused across all glyph vertices.
	pm_color := premultiply_color(text.color)

	for data != nil {
		vertex_start := u32(len(GLOB.tmp_text_verts))
		index_start := u32(len(GLOB.tmp_text_indices))

		for i in 0 ..< data.num_vertices {
			pos := data.xy[i]
			uv := data.uv[i]
			// SDL_ttf gives glyph positions in physical pixels relative to text origin.
			// The transform is already in physical-pixel space (caller pre-scaled),
			// so we apply directly — no per-vertex DPI divide/multiply.
			append(
				&GLOB.tmp_text_verts,
				Vertex{position = apply_transform(transform, {pos.x, -pos.y}), uv = {uv.x, uv.y}, color = pm_color},
			)
		}

		append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices])

		batch_idx := u32(len(GLOB.tmp_text_batches))
		append(
			&GLOB.tmp_text_batches,
			TextBatch {
				atlas_texture = data.atlas_texture,
				vertex_start = vertex_start,
				vertex_count = u32(data.num_vertices),
				index_start = index_start,
				index_count = u32(data.num_indices),
			},
		)

		append_or_extend_sub_batch(scissor, layer, .Text, batch_idx, 1)

		data = data.next
	}
}

// Append a new sub-batch or extend the last one if same kind and contiguous.
@(private)
append_or_extend_sub_batch :: proc(
	scissor: ^Scissor,
	layer: ^Layer,
	kind: Sub_Batch_Kind,
	offset: u32,
	count: u32,
	texture_id: Texture_Id = INVALID_TEXTURE,
	sampler: Sampler_Preset = DFT_SAMPLER,
) {
	if scissor.sub_batch_len > 0 {
		last := &GLOB.tmp_sub_batches[scissor.sub_batch_start + scissor.sub_batch_len - 1]
		if last.kind == kind &&
		   kind != .Text &&
		   last.offset + last.count == offset &&
		   last.texture_id == texture_id &&
		   last.sampler == sampler {
			last.count += count
			return
		}
	}
	append(
		&GLOB.tmp_sub_batches,
		Sub_Batch{kind = kind, offset = offset, count = count, texture_id = texture_id, sampler = sampler},
	)
	scissor.sub_batch_len += 1
	layer.sub_batch_len += 1
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Clay ------------------------
// ---------------------------------------------------------------------------------------------------------------------

@(private = "file")
clay_error_handler :: proc "c" (errorData: clay.ErrorData) {
	context = GLOB.odin_context
	log.error("Clay error:", errorData.errorType, errorData.errorText)
}

// Called for each Clay `RenderCommandType.Custom` render command that
// `prepare_clay_batch` encounters.
//
// - `layer` is the layer the command belongs to (post-z-index promotion).
// - `bounds` is already translated into the active layer's coordinate system
//   and pre-DPI, matching what the built-in shape procs expect.
// - `render_data` is Clay's `CustomRenderData` for the element, exposing
//   `backgroundColor`, `cornerRadius`, and the `customData` pointer the caller
//   attached to `clay.CustomElementConfig.customData`.
//
// The callback must not call `new_layer` or `prepare_clay_batch`.
Custom_Draw :: #type proc(layer: ^Layer, bounds: Rectangle, render_data: clay.CustomRenderData)

ClayBatch :: struct {
	bounds: Rectangle,
	cmds:   clay.ClayArray(clay.RenderCommand),
}

// Process Clay render commands into shape and text primitives.
prepare_clay_batch :: proc(
	base_layer: ^Layer,
	batch: ^ClayBatch,
	mouse_wheel_delta: [2]f32,
	frame_time: f32 = 0,
	custom_draw: Custom_Draw = nil,
	temp_allocator := context.temp_allocator,
) {
	mouse_pos: [2]f32
	mouse_flags := sdl.GetMouseState(&mouse_pos.x, &mouse_pos.y)

	// Update clay internals
	clay.SetPointerState(
		clay.Vector2{mouse_pos.x - base_layer.bounds.x, mouse_pos.y - base_layer.bounds.y},
		.LEFT in mouse_flags,
	)
	clay.UpdateScrollContainers(true, mouse_wheel_delta, frame_time)

	layer := base_layer

	// Parse render commands
	for i in 0 ..< int(batch.cmds.length) {
		render_command := clay.RenderCommandArray_Get(&batch.cmds, cast(i32)i)

		// Translate bounding box of the primitive by the layer position
		bounds := Rectangle {
			x      = render_command.boundingBox.x + layer.bounds.x,
			y      = render_command.boundingBox.y + layer.bounds.y,
			width  = render_command.boundingBox.width,
			height = render_command.boundingBox.height,
		}

		if render_command.zIndex > GLOB.clay_z_index {
			log.debug("Higher zIndex found, creating new layer & setting z_index to", render_command.zIndex)
			layer = new_layer(layer, bounds)
			// Update bounds to new layer offset
			bounds.x = render_command.boundingBox.x + layer.bounds.x
			bounds.y = render_command.boundingBox.y + layer.bounds.y
			GLOB.clay_z_index = render_command.zIndex
		}

		switch (render_command.commandType) {
		case clay.RenderCommandType.None:
			log.errorf(
					"Received render command with type None. This generally means we're in some kind of fucked up state.",
				)
		case clay.RenderCommandType.Text:
			render_data := render_command.renderData.text
			txt := string(render_data.stringContents.chars[:render_data.stringContents.length])
			c_text := strings.clone_to_cstring(txt, temp_allocator)
			defer delete(c_text, temp_allocator)
			// Clay render-command IDs are derived via Clay's internal HashNumber (Jenkins-family)
			// and namespaced with .Clay so they can never collide with user-provided custom text IDs.
			sdl_text := cache_get_or_update(
				Cache_Key{render_command.id, .Clay},
				c_text,
				get_font(render_data.fontId, render_data.fontSize),
			)
			prepare_text(layer, Text{sdl_text, {bounds.x, bounds.y}, color_from_clay(render_data.textColor)})
		case clay.RenderCommandType.Image:
			// Any texture
			render_data := render_command.renderData.image
			if render_data.imageData == nil do continue
			img_data := (^Clay_Image_Data)(render_data.imageData)^
			cr := render_data.cornerRadius
			radii := Rectangle_Radii {
				top_left     = cr.topLeft,
				top_right    = cr.topRight,
				bottom_right = cr.bottomRight,
				bottom_left  = cr.bottomLeft,
			}

			// Background color behind the image (Clay allows it)
			bg := color_from_clay(render_data.backgroundColor)
			if bg[3] > 0 {
				rectangle(layer, bounds, bg, radii = radii)
			}

			// Compute fit UVs
			uv, sampler, inner := fit_params(img_data.fit, bounds, img_data.texture_id)

			// Draw the image
			rectangle_texture(layer, inner, img_data.texture_id, img_data.tint, uv, sampler, radii)
		case clay.RenderCommandType.ScissorStart:
			if bounds.width == 0 || bounds.height == 0 do continue

			curr_scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]

			if curr_scissor.sub_batch_len != 0 {
				// Scissor has some content, need to make a new scissor
				new := Scissor {
					sub_batch_start = curr_scissor.sub_batch_start + curr_scissor.sub_batch_len,
					bounds          = sdl.Rect {
						c.int(bounds.x * GLOB.dpi_scaling),
						c.int(bounds.y * GLOB.dpi_scaling),
						c.int(bounds.width * GLOB.dpi_scaling),
						c.int(bounds.height * GLOB.dpi_scaling),
					},
				}
				append(&GLOB.scissors, new)
				layer.scissor_len += 1
			} else {
				curr_scissor.bounds = sdl.Rect {
					c.int(bounds.x * GLOB.dpi_scaling),
					c.int(bounds.y * GLOB.dpi_scaling),
					c.int(bounds.width * GLOB.dpi_scaling),
					c.int(bounds.height * GLOB.dpi_scaling),
				}
			}
		case clay.RenderCommandType.ScissorEnd:
		case clay.RenderCommandType.Rectangle:
			render_data := render_command.renderData.rectangle
			cr := render_data.cornerRadius
			color := color_from_clay(render_data.backgroundColor)
			radii := Rectangle_Radii {
				top_left     = cr.topLeft,
				top_right    = cr.topRight,
				bottom_right = cr.bottomRight,
				bottom_left  = cr.bottomLeft,
			}

			rectangle(layer, bounds, color, radii = radii)
		case clay.RenderCommandType.Border:
			render_data := render_command.renderData.border
			cr := render_data.cornerRadius
			color := color_from_clay(render_data.color)
			thickness := f32(render_data.width.top)
			radii := Rectangle_Radii {
				top_left     = cr.topLeft,
				top_right    = cr.topRight,
				bottom_right = cr.bottomRight,
				bottom_left  = cr.bottomLeft,
			}

			rectangle(layer, bounds, BLANK, outline_color = color, outline_width = thickness, radii = radii)
		case clay.RenderCommandType.Custom: if custom_draw != nil {
					custom_draw(layer, bounds, render_command.renderData.custom)
				} else {
					log.error("Received clay render command of type custom but no custom_draw proc provided.")
				}
		}
	}
}

// Render primitives. clear_color is the background fill before any layers are drawn.
end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = DFT_CLEAR_COLOR) {
	cmd_buffer := sdl.AcquireGPUCommandBuffer(device)
	if cmd_buffer == nil {
		log.panicf("Failed to acquire GPU command buffer: %s", sdl.GetError())
	}

	// Upload primitives to GPU
	copy_pass := sdl.BeginGPUCopyPass(cmd_buffer)
	upload(device, copy_pass)
	sdl.EndGPUCopyPass(copy_pass)

	swapchain_texture: ^sdl.GPUTexture
	width, height: u32
	if !sdl.WaitAndAcquireGPUSwapchainTexture(cmd_buffer, window, &swapchain_texture, &width, &height) {
		log.panicf("Failed to acquire swapchain texture: %s", sdl.GetError())
	}

	if swapchain_texture == nil {
		// Window is minimized or not visible — submit and skip this frame
		if !sdl.SubmitGPUCommandBuffer(cmd_buffer) {
			log.panicf("Failed to submit GPU command buffer (minimized window): %s", sdl.GetError())
		}
		return
	}

	use_msaa := GLOB.sample_count != ._1
	render_texture := swapchain_texture

	if use_msaa {
		ensure_msaa_texture(device, sdl.GetGPUSwapchainTextureFormat(device, window), width, height)
		render_texture = GLOB.msaa_texture
	}

	// Premultiply clear color: the blend state is ONE, ONE_MINUS_SRC_ALPHA (premultiplied),
	// so the clear color must also be premultiplied for correct background compositing.
	clear_color_straight := color_to_f32(clear_color)
	clear_alpha := clear_color_straight[3]
	clear_color_f32 := [4]f32 {
		clear_color_straight[0] * clear_alpha,
		clear_color_straight[1] * clear_alpha,
		clear_color_straight[2] * clear_alpha,
		clear_alpha,
	}

	// Draw layers. One render pass per layer; sub-batches draw in submission order within each scissor.
	for &layer, index in GLOB.layers {
		log.debug("Drawing layer", index)
		draw_layer(device, window, cmd_buffer, render_texture, width, height, clear_color_f32, &layer)
	}

	// Resolve MSAA render texture to the swapchain.
	if use_msaa {
		resolve_pass := sdl.BeginGPURenderPass(
			cmd_buffer,
			&sdl.GPUColorTargetInfo {
				texture = render_texture,
				load_op = .LOAD,
				store_op = .RESOLVE,
				resolve_texture = swapchain_texture,
			},
			1,
			nil,
		)
		sdl.EndGPURenderPass(resolve_pass)
	}

	if !sdl.SubmitGPUCommandBuffer(cmd_buffer) {
		log.panicf("Failed to submit GPU command buffer: %s", sdl.GetError())
	}
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- MSAA --------------------------
// ---------------------------------------------------------------------------------------------------------------------

// Query the highest MSAA sample count supported by the GPU for the swapchain format.
max_sample_count :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window) -> sdl.GPUSampleCount {
	format := sdl.GetGPUSwapchainTextureFormat(device, window)
	counts := [?]sdl.GPUSampleCount{._8, ._4, ._2}
	for count in counts {
		if sdl.GPUTextureSupportsSampleCount(device, format, count) do return count
	}
	return ._1
}

@(private = "file")
ensure_msaa_texture :: proc(device: ^sdl.GPUDevice, format: sdl.GPUTextureFormat, width, height: u32) {
	if GLOB.msaa_texture != nil && GLOB.msaa_width == width && GLOB.msaa_height == height {
		return
	}
	if GLOB.msaa_texture != nil {
		sdl.ReleaseGPUTexture(device, GLOB.msaa_texture)
	}
	GLOB.msaa_texture = sdl.CreateGPUTexture(
		device,
		sdl.GPUTextureCreateInfo {
			type = .D2,
			format = format,
			usage = {.COLOR_TARGET},
			width = width,
			height = height,
			layer_count_or_depth = 1,
			num_levels = 1,
			sample_count = GLOB.sample_count,
		},
	)
	if GLOB.msaa_texture == nil {
		log.panicf("Failed to create MSAA texture (%dx%d): %s", width, height, sdl.GetError())
	}
	GLOB.msaa_width = width
	GLOB.msaa_height = height
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Utility -----------------------
// ---------------------------------------------------------------------------------------------------------------------

ortho_rh :: proc(left: f32, right: f32, bottom: f32, top: f32, near: f32, far: f32) -> matrix[4, 4]f32 {
	return matrix[4, 4]f32{
		2.0 / (right - left), 0.0, 0.0, -(right + left) / (right - left),
		0.0, 2.0 / (top - bottom), 0.0, -(top + bottom) / (top - bottom),
		0.0, 0.0, -2.0 / (far - near), -(far + near) / (far - near),
		0.0, 0.0, 0.0, 1.0,
	}
}

Draw_Mode :: enum u32 {
	Tessellated = 0,
	SDF         = 1,
}

Vertex_Uniforms :: struct {
	projection: matrix[4, 4]f32,
	scale:      f32,
	mode:       Draw_Mode,
}

// Push projection, dpi scale, and rendering mode as a single uniform block (slot 0).
push_globals :: proc(
	cmd_buffer: ^sdl.GPUCommandBuffer,
	width: f32,
	height: f32,
	mode: Draw_Mode = .Tessellated,
) {
	globals := Vertex_Uniforms {
		projection = ortho_rh(
			left = 0.0,
			top = 0.0,
			right = f32(width),
			bottom = f32(height),
			near = -1.0,
			far = 1.0,
		),
		scale      = GLOB.dpi_scaling,
		mode       = mode,
	}

	sdl.PushGPUVertexUniformData(cmd_buffer, 0, &globals, size_of(Vertex_Uniforms))
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Buffer ------------------------
// ---------------------------------------------------------------------------------------------------------------------

Buffer :: struct {
	gpu:      ^sdl.GPUBuffer,
	transfer: ^sdl.GPUTransferBuffer,
	size:     u32,
}

@(require_results)
create_buffer :: proc(
	device: ^sdl.GPUDevice,
	size: u32,
	gpu_usage: sdl.GPUBufferUsageFlags,
) -> (
	buffer: Buffer,
	ok: bool,
) {
	gpu := sdl.CreateGPUBuffer(device, sdl.GPUBufferCreateInfo{usage = gpu_usage, size = size})
	if gpu == nil {
		log.errorf("Failed to create GPU buffer (size=%d): %s", size, sdl.GetError())
		return buffer, false
	}
	transfer := sdl.CreateGPUTransferBuffer(
		device,
		sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size},
	)
	if transfer == nil {
		sdl.ReleaseGPUBuffer(device, gpu)
		log.errorf("Failed to create GPU transfer buffer (size=%d): %s", size, sdl.GetError())
		return buffer, false
	}
	return Buffer{gpu, transfer, size}, true
}

grow_buffer_if_needed :: proc(
	device: ^sdl.GPUDevice,
	buffer: ^Buffer,
	new_size: u32,
	gpu_usage: sdl.GPUBufferUsageFlags,
) {
	if new_size > buffer.size {
		log.debug("Resizing buffer from", buffer.size, "to", new_size)
		destroy_buffer(device, buffer)
		buffer.gpu = sdl.CreateGPUBuffer(device, sdl.GPUBufferCreateInfo{usage = gpu_usage, size = new_size})
		if buffer.gpu == nil {
			log.panicf("Failed to grow GPU buffer (new_size=%d): %s", new_size, sdl.GetError())
		}
		buffer.transfer = sdl.CreateGPUTransferBuffer(
			device,
			sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = new_size},
		)
		if buffer.transfer == nil {
			log.panicf("Failed to grow GPU transfer buffer (new_size=%d): %s", new_size, sdl.GetError())
		}
		buffer.size = new_size
	}
}

destroy_buffer :: proc(device: ^sdl.GPUDevice, buffer: ^Buffer) {
	sdl.ReleaseGPUBuffer(device, buffer.gpu)
	sdl.ReleaseGPUTransferBuffer(device, buffer.transfer)
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Transform ------------------------
// ---------------------------------------------------------------------------------------------------------------------

// 2x3 affine transform for 2D pivot-rotation.
// Used internally by rotation-aware drawing procs.
Transform_2D :: struct {
	m00, m01: f32, // row 0: rotation/scale
	m10, m11: f32, // row 1: rotation/scale
	tx, ty:   f32, // translation
}

// Build a pivot-rotation transform.
//
// Semantics (raylib-style):
//   The point whose local coordinates equal `origin` lands at `pos` in world space.
//   The rest of the shape rotates around that pivot.
//
// Formula:  p_world = pos + R(θ) · (p_local - origin)
//
// Parameters:
//   pos          – world-space position where the pivot lands.
//   origin       – pivot point in local space (measured from the shape's natural reference point).
//   rotation_deg – rotation in degrees, counter-clockwise.
//
build_pivot_rotation :: proc(position: Vec2, origin: Vec2, rotation_deg: f32) -> Transform_2D {
	radians := math.to_radians(rotation_deg)
	cos_angle := math.cos(radians)
	sin_angle := math.sin(radians)
	return build_pivot_rotation_sc(position, origin, cos_angle, sin_angle)
}

// Variant of build_pivot_rotation that accepts pre-computed cos/sin values,
// avoiding redundant trigonometry when the caller has already computed them.
build_pivot_rotation_sc :: #force_inline proc(
	position: Vec2,
	origin: Vec2,
	cos_angle, sin_angle: f32,
) -> Transform_2D {
	return Transform_2D {
		m00 = cos_angle,
		m01 = -sin_angle,
		m10 = sin_angle,
		m11 = cos_angle,
		tx = position.x - (cos_angle * origin.x - sin_angle * origin.y),
		ty = position.y - (sin_angle * origin.x + cos_angle * origin.y),
	}
}

// Apply the transform to a local-space point, producing a world-space point.
apply_transform :: #force_inline proc(transform: Transform_2D, point: Vec2) -> Vec2 {
	return {
		transform.m00 * point.x + transform.m01 * point.y + transform.tx,
		transform.m10 * point.x + transform.m11 * point.y + transform.ty,
	}
}

// Fast-path check callers use BEFORE building a transform.
// Returns true if either the origin is non-zero or rotation is non-zero,
// meaning a transform actually needs to be computed.
needs_transform :: #force_inline proc(origin: Vec2, rotation: f32) -> bool {
	return origin != {0, 0} || rotation != 0
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Procedure Groups ------------------------
// ---------------------------------------------------------------------------------------------------------------------

center_of :: proc {
	center_of_rectangle,
	center_of_triangle,
	center_of_text,
}

top_left_of :: proc {
	top_left_of_rectangle,
	top_left_of_triangle,
	top_left_of_text,
}

top_of :: proc {
	top_of_rectangle,
	top_of_triangle,
	top_of_text,
}

top_right_of :: proc {
	top_right_of_rectangle,
	top_right_of_triangle,
	top_right_of_text,
}

left_of :: proc {
	left_of_rectangle,
	left_of_triangle,
	left_of_text,
}

right_of :: proc {
	right_of_rectangle,
	right_of_triangle,
	right_of_text,
}

bottom_left_of :: proc {
	bottom_left_of_rectangle,
	bottom_left_of_triangle,
	bottom_left_of_text,
}

bottom_of :: proc {
	bottom_of_rectangle,
	bottom_of_triangle,
	bottom_of_text,
}

bottom_right_of :: proc {
	bottom_right_of_rectangle,
	bottom_right_of_triangle,
	bottom_right_of_text,
}
