package main

import "core:bytes"
import "core:c"
import "core:encoding/json"
import "core:fmt"
import "core:image"
import "core:image/png"
import "core:math"
import "core:os"
import "core:path/filepath"
import "core:slice"
import "core:strconv"
import "core:strings"
import stbi "vendor:stb/image"
import stbrp "vendor:stb/rect_pack"

// Authoring-side clip block from manifest.json
Clip_Manifest :: struct {
	folder: string,
	fps:    f32,
	loop:   bool,
}

// Authoring-side character manifest
Manifest :: struct {
	id:              string,
	pixels_per_unit: f32,
	pivot:           [2]f32,
	clips:           map[string]Clip_Manifest,
}

// Runtime frame description written to *.char.json
Frame_Def :: struct {
	rect:        [4]int, // x, y, w, h in atlas pixels
	source_size: [2]int,
	trim_offset: [2]int,
}

// Runtime clip description
Clip_Def :: struct {
	loop:   bool,
	fps:    f32,
	frames: []Frame_Def,
}

// Runtime character package metadata
Char_Def :: struct {
	version:         int,
	id:              string,
	atlas:           string, // filename relative to this .char.json
	pixels_per_unit: f32,
	pivot:           [2]f32,
	clips:           map[string]Clip_Def,
}

// Packed atlas pixels + per-frame rects (indexed like the combined frames list)
Atlas :: struct {
	pixels:        []u8,
	width:         int,
	height:        int,
	rects:         [][4]int,
	source_sizes:  [][2]int,
	trim_offsets:  [][2]int,
}

// Where one clip's frames sit inside the packed atlas frame list
Clip_Range :: struct {
	name:  string,
	clip:  Clip_Manifest,
	start: int, // index into the combined frames / atlas rects
	count: int,
}

main :: proc() {
	if len(os.args) < 3 {
		fmt.eprintfln("usage: assetbake <character dir> <output dir>")
		os.exit(1)
	}
	bake_character(os.args[1], os.args[2])
}

// bake_character is the full pipeline for one character folder.
bake_character :: proc(in_dir, out_dir: string) {
	manifest := load_manifest(in_dir)
	if len(manifest.clips) == 0 {
		fmt.eprintfln("manifest has no clips")
		os.exit(1)
	}

	// Load every clip from the manifest into one frame list (deterministic clip order)
	frames, ranges := load_all_clips(in_dir, manifest)
	defer destroy_frames(frames)
	defer delete(ranges)

	// Clips may differ in size (idle vs walk); pack with a real rectangle packer
	atlas := pack_atlas(frames)
	defer delete(atlas.pixels)
	defer delete(atlas.rects)
	defer delete(atlas.source_sizes)
	defer delete(atlas.trim_offsets)

	ensure_dir(out_dir)

	atlas_name := fmt.tprintf("%s.atlas.png", manifest.id)
	write_atlas_png(out_dir, atlas_name, atlas)

	char_def := build_char_def(manifest, ranges, atlas_name, atlas)
	defer destroy_char_def(char_def)

	write_char_json(out_dir, fmt.tprintf("%s.char.json", manifest.id), char_def)
	fmt.printfln("baked %s (%d clips, %d frames)", manifest.id, len(ranges), len(frames))
}

// load_manifest reads content/.../manifest.json into a Manifest.
load_manifest :: proc(character_dir: string) -> Manifest {
	path, join_err := filepath.join({character_dir, "manifest.json"})
	if join_err != nil {
		fmt.eprintfln("join failed: %v", join_err)
		os.exit(1)
	}
	defer delete(path)

	data, read_err := os.read_entire_file(path, context.allocator)
	if read_err != nil {
		fmt.eprintfln("failed to read %s: %v", path, read_err)
		os.exit(1)
	}
	defer delete(data)

	manifest: Manifest
	if err := json.unmarshal(data, &manifest); err != nil {
		fmt.eprintfln("json unmarshal failed: %v", err)
		os.exit(1)
	}
	return manifest
}

// load_all_clips walks every manifest clip (names sorted), loads PNGs, and records atlas ranges.
// Within a clip, all frames must share one size. Different clips may use different sizes.
// Caller owns frames (destroy_frames) and ranges (delete).
load_all_clips :: proc(character_dir: string, manifest: Manifest) -> ([]^image.Image, []Clip_Range) {
	clip_names := sorted_clip_names(manifest)
	defer delete(clip_names)

	loaded: [dynamic]^image.Image
	ranges := make([]Clip_Range, len(clip_names))

	for name, i in clip_names {
		clip := manifest.clips[name]
		paths := list_sorted_frame_paths(character_dir, clip.folder)
		defer delete_paths(paths)

		start := len(loaded)
		for path in paths {
			append(&loaded, load_frame(path))
		}

		// Enforce uniform size inside this clip only
		validate_clip_frame_sizes(name, loaded[start:])

		ranges[i] = Clip_Range {
			name  = name,
			clip  = clip,
			start = start,
			count = len(paths),
		}
		fw := loaded[start].width
		fh := loaded[start].height
		fmt.printfln(
			"clip %s: %d frames %dx%d (atlas slots %d..%d)",
			name,
			len(paths),
			fw,
			fh,
			start,
			start + len(paths) - 1,
		)
	}

	if len(loaded) == 0 {
		fmt.eprintfln("no frames loaded")
		os.exit(1)
	}

	frames := make([]^image.Image, len(loaded))
	copy(frames, loaded[:])
	delete(loaded)

	fmt.printfln("loaded %d frames total across %d clips", len(frames), len(ranges))
	return frames, ranges
}

// validate_clip_frame_sizes requires every frame in one clip to match the first frame's size.
validate_clip_frame_sizes :: proc(clip_name: string, frames: []^image.Image) {
	if len(frames) == 0 {
		return
	}
	fw := frames[0].width
	fh := frames[0].height
	for frame, i in frames[1:] {
		if frame.width != fw || frame.height != fh {
			fmt.eprintfln(
				"clip %s: frame size mismatch at %d: got %dx%d, expected %dx%d",
				clip_name,
				i + 1,
				frame.width,
				frame.height,
				fw,
				fh,
			)
			os.exit(1)
		}
	}
}

// sorted_clip_names returns manifest clip keys in alphabetical order for stable bakes.
sorted_clip_names :: proc(manifest: Manifest) -> []string {
	names := make([]string, len(manifest.clips))
	i := 0
	for name in manifest.clips {
		names[i] = name // keys owned by the map; do not delete individually
		i += 1
	}
	slice.sort(names)
	return names
}

// list_sorted_frame_paths globs *.png under folder and sorts by trailing frame number.
// Caller owns the returned paths (use delete_paths).
list_sorted_frame_paths :: proc(character_dir, folder: string) -> []string {
	clip_dir, clip_join_err := filepath.join({character_dir, folder})
	if clip_join_err != nil {
		fmt.eprintfln("join failed: %v", clip_join_err)
		os.exit(1)
	}
	defer delete(clip_dir)

	pattern, pattern_err := filepath.join({clip_dir, "*.png"})
	if pattern_err != nil {
		fmt.eprintfln("pattern join failed: %v", pattern_err)
		os.exit(1)
	}
	defer delete(pattern)

	matches, glob_err := filepath.glob(pattern)
	if glob_err != nil {
		fmt.eprintfln("glob failed: %v", glob_err)
		os.exit(1)
	}
	if len(matches) == 0 {
		fmt.eprintfln("no pngs in %s", clip_dir)
		os.exit(1)
	}

	// Sort by trailing frame number so _10 comes after _9, not after _1
	slice.sort_by(matches, proc(a, b: string) -> bool {
		return frame_index(a) < frame_index(b)
	})
	return matches
}

delete_paths :: proc(paths: []string) {
	for p in paths {
		delete(p)
	}
	delete(paths)
}

load_frame :: proc(path: string) -> ^image.Image {
	img, err := png.load_from_file(path)
	if err != nil {
		fmt.eprintfln("image load failed (%s): %v", path, err)
		os.exit(1)
	}
	if img.channels != 4 {
		fmt.eprintfln("expected RGBA for %s, got %d channels", path, img.channels)
		os.exit(1)
	}
	return img
}

destroy_frames :: proc(frames: []^image.Image) {
	for f in frames {
		image.destroy(f)
	}
	delete(frames)
}

opaque_bounds_rgba :: proc(pixels: []u8, iw, ih: int, alpha_min: u8 = 1) -> (x, y, w, h: int) {
	min_x, min_y := iw, ih
	max_x, max_y := -1, -1

	for py in 0 ..< ih {
		for px in 0 ..< iw {
			i := (py * iw + px) * 4
			if i + 3 >= len(pixels) do continue
			a := pixels[i + 3]
			if a >= alpha_min {
				if px < min_x do min_x = px
				if py < min_y do min_y = py
				if px > max_x do max_x = px
				if py > max_y do max_y = py
			}
		}
	}

	if max_x < min_x {
		return 0, 0, 1, 1
	}
	return min_x, min_y, max_x - min_x + 1, max_y - min_y + 1
}

opaque_bounds :: proc(img: ^image.Image, alpha_min: u8 = 1) -> (x, y, w, h: int) {
	iw, ih := int(img.width), int(img.height)
	pixels := bytes.buffer_to_bytes(&img.pixels)
	return opaque_bounds_rgba(pixels, iw, ih, alpha_min)
}

pack_atlas :: proc(frames: []^image.Image) -> Atlas {
	PADDING :: 1

	pack_rects := make([]stbrp.Rect, len(frames))
	defer delete(pack_rects)

	trims := make([][4]int, len(frames))
	defer delete(trims)
	source_sizes := make([][2]int, len(frames))
	trim_offsets := make([][2]int, len(frames))

	total_area := 0
	max_w := 0
	max_h := 0
	for frame, i in frames {
		tx, ty, tw, th := opaque_bounds(frame)
		trims[i] = {tx, ty, tw, th}
		source_sizes[i] = {int(frame.width), int(frame.height)}
		trim_offsets[i] = {tx, ty}

		w := tw + PADDING
		h := th + PADDING
		pack_rects[i] = stbrp.Rect {
			id = c.int(i),
			w  = stbrp.Coord(w),
			h  = stbrp.Coord(h),
		}
		total_area += w * h
		max_w = max(max_w, w)
		max_h = max(max_h, h)
	}

	side := max(max_w, max_h, int(math.ceil(math.sqrt(f64(total_area)))))
	atlas_w, atlas_h := 0, 0
	packed := false
	for attempt in 0 ..< 16 {
		atlas_w = side
		atlas_h = side
		if try_pack_rects(atlas_w, atlas_h, pack_rects) {
			packed = true
			break
		}
		side = max(side + 64, int(math.ceil(f64(side) * 1.25)))
		_ = attempt
	}
	if !packed {
		fmt.eprintfln("failed to pack %d frames into an atlas", len(frames))
		os.exit(1)
	}

	pixels := make([]u8, atlas_w * atlas_h * 4)
	rects := make([][4]int, len(frames))

	for pr in pack_rects {
		if !pr.was_packed {
			fmt.eprintfln("frame %d was not packed", pr.id)
			os.exit(1)
		}
		idx := int(pr.id)
		frame := frames[idx]
		trim := trims[idx]
		tx, ty, tw, th := trim[0], trim[1], trim[2], trim[3]
		dst_x := int(pr.x)
		dst_y := int(pr.y)

		src := bytes.buffer_to_bytes(&frame.pixels)
		src_w := int(frame.width)
		for y in 0 ..< th {
			src_row := src[((ty + y) * src_w + tx) * 4:][:tw * 4]
			dst_i := ((dst_y + y) * atlas_w + dst_x) * 4
			copy(pixels[dst_i:], src_row)
		}

		rects[idx] = {dst_x, dst_y, tw, th}
	}

	fmt.printfln("packed atlas %dx%d", atlas_w, atlas_h)
	return Atlas {
		pixels       = pixels,
		width        = atlas_w,
		height       = atlas_h,
		rects        = rects,
		source_sizes = source_sizes,
		trim_offsets = trim_offsets,
	}
}

// try_pack_rects attempts to place all rects into a width×height bin.
try_pack_rects :: proc(width, height: int, rects: []stbrp.Rect) -> bool {
	nodes := make([]stbrp.Node, width)
	defer delete(nodes)

	ctx: stbrp.Context
	stbrp.init_target(&ctx, c.int(width), c.int(height), raw_data(nodes), c.int(len(nodes)))
	stbrp.setup_allow_out_of_mem(&ctx, true)

	return stbrp.pack_rects(&ctx, raw_data(rects), c.int(len(rects))) != 0
}

ensure_dir :: proc(path: string) {
	if err := os.make_directory_all(path); err != nil && err != .Exist {
		fmt.eprintfln("failed to create output dir %s: %v", path, err)
		os.exit(1)
	}
}

// write_atlas_png writes atlas pixels to out_dir/atlas_name via stb_image_write.
write_atlas_png :: proc(out_dir, atlas_name: string, atlas: Atlas) {
	atlas_path, atlas_join_err := filepath.join({out_dir, atlas_name})
	if atlas_join_err != nil {
		fmt.eprintfln("atlas path join failed: %v", atlas_join_err)
		os.exit(1)
	}
	defer delete(atlas_path)

	atlas_cpath := strings.clone_to_cstring(atlas_path)
	defer delete(atlas_cpath)

	if stbi.write_png(
		atlas_cpath,
		i32(atlas.width),
		i32(atlas.height),
		4,
		raw_data(atlas.pixels),
		i32(atlas.width * 4),
	) == 0 {
		fmt.eprintfln("failed to write atlas: %s", atlas_path)
		os.exit(1)
	}
	fmt.printfln("wrote %s (%dx%d)", atlas_path, atlas.width, atlas.height)
}

// build_char_def builds runtime metadata for every baked clip using packed rects.
build_char_def :: proc(
	manifest: Manifest,
	ranges: []Clip_Range,
	atlas_name: string,
	atlas: Atlas,
) -> Char_Def {
	char_clips := make(map[string]Clip_Def)

	for range in ranges {
		frame_defs := make([]Frame_Def, range.count)
		for i in 0 ..< range.count {
			slot := range.start + i
			r := atlas.rects[slot]
			frame_defs[i] = Frame_Def {
				rect        = r,
				source_size = atlas.source_sizes[slot],
				trim_offset = atlas.trim_offsets[slot],
			}
		}

		char_clips[range.name] = Clip_Def {
			loop   = range.clip.loop,
			fps    = range.clip.fps,
			frames = frame_defs,
		}
	}

	return Char_Def {
		version         = 1,
		id              = manifest.id,
		atlas           = atlas_name,
		pixels_per_unit = manifest.pixels_per_unit,
		pivot           = manifest.pivot,
		clips           = char_clips,
	}
}

destroy_char_def :: proc(char_def: Char_Def) {
	for _, clip in char_def.clips {
		delete(clip.frames)
	}
	delete(char_def.clips)
}

write_char_json :: proc(out_dir, char_name: string, char_def: Char_Def) {
	char_json, marshal_err := json.marshal(char_def, {pretty = true, use_spaces = true})
	if marshal_err != nil {
		fmt.eprintfln("json marshal failed: %v", marshal_err)
		os.exit(1)
	}
	defer delete(char_json)

	char_path, char_join_err := filepath.join({out_dir, char_name})
	if char_join_err != nil {
		fmt.eprintfln("char path join failed: %v", char_join_err)
		os.exit(1)
	}
	defer delete(char_path)

	if err := os.write_entire_file(char_path, char_json); err != nil {
		fmt.eprintfln("failed to write %s: %v", char_path, err)
		os.exit(1)
	}
	fmt.printfln("wrote %s", char_path)
}

// frame_index pulls the trailing number from names like skeleton-Idle_17.png
frame_index :: proc(path: string) -> int {
	name := filepath.stem(path)
	idx := strings.last_index_byte(name, '_')
	if idx < 0 {
		return 0
	}
	n, _ := strconv.parse_int(name[idx + 1:])
	return n
}
