#!/usr/bin/env python3

import argparse
import code
import contextlib
import importlib
import json
import sys
import time
from pathlib import Path

from PIL import Image

def import_compiled_slughorn():
	repo_root = Path(__file__).resolve().parents[1]

	def valid(mod) -> bool:
		return hasattr(mod, "Key")

	try:
		mod = importlib.import_module("slughorn")

		if valid(mod):
			return mod

	except ImportError:
		pass

	sys.modules.pop("slughorn", None)

	pruned = []
	for entry in sys.path:
		if entry == "":
			if Path.cwd().resolve() == repo_root:
				continue
		elif Path(entry).resolve() == repo_root:
			continue

		pruned.append(entry)

	sys.path[:] = pruned

	try:
		mod = importlib.import_module("slughorn")
	except ImportError as exc:
		print(f"ERROR: failed to import compiled slughorn module: {exc}", file=sys.stderr)
		sys.exit(1)

	if not valid(mod):
		print("ERROR: imported 'slughorn' is not the compiled extension module", file=sys.stderr)
		sys.exit(1)

	return mod

slughorn = import_compiled_slughorn()

def fail(message: str, code_: int=1) -> None:
	print(f"ERROR: {message}", file=sys.stderr)
	raise SystemExit(code_)

def make_key(raw: str) -> slughorn.Key:
	try:
		return slughorn.Key(int(raw, 0))

	except ValueError:
		return slughorn.Key(raw)

def resolve_key_arg(raw: str | None, ord_char: str | None) -> slughorn.Key:
	if ord_char is not None:
		if len(ord_char) != 1:
			fail(f"--ord requires a single character, got: {ord_char!r}")

		return slughorn.Key(ord(ord_char))

	if raw is None:
		fail("a key argument or --ord is required")

	return make_key(raw)

_FORMAT_BYTES_PER_TEXEL = {"RGBA32F": 16, "RGBA16F": 8, "RGBA16UI": 8, "RG16UI": 4, "RGBA8": 4}

def tex_size_str(tex) -> str:
	bpt = _FORMAT_BYTES_PER_TEXEL.get(tex.format, 0)
	total = tex.width * tex.height * bpt

	if total >= 1024 * 1024:
		return f"{total / (1024 * 1024):.1f} MiB"

	if total >= 1024:
		return f"{total // 1024} KiB"

	return f"{total} B"

def key_type_name(key) -> str:
	type_obj = key.type
	return type_obj.name if hasattr(type_obj, "name") else str(type_obj)

def key_to_serial_json(key) -> dict:
	if key_type_name(key) == "Codepoint":
		return {"type": "codepoint", "value": key.codepoint}

	return {"type": "name", "value": key.name}

def format_key(key) -> str:
	type_name = key_type_name(key)

	if type_name == "Codepoint":
		cp = key.codepoint
		label = repr(chr(cp)) if 32 <= cp <= 0x10FFFF else ""
		return f"U+{cp:04X}" + (f" {label}" if label else "")

	return key.name

def load_atlas(path: str):
	if not hasattr(slughorn, "read"):
		fail("slughorn.read() is not available; build with SERIAL enabled", 2)

	try:
		return slughorn.read(path)

	except Exception as exc:  # pragma: no cover
		fail(f"failed to load atlas from {path}: {exc}")

def read_serial_json(path: str) -> str:
	try:
		data = Path(path).read_bytes()

	except OSError as exc:
		fail(f"failed to read atlas from {path}: {exc}")

	stripped = data.lstrip()

	if stripped.startswith(b"{"):
		return data.decode("utf-8")

	if not data.startswith(b"SLUG"):
		fail("unrecognised format (expected '{' for .slug or 'SLUG' for .slugb)", 2)

	if len(data) < 20:
		fail("invalid .slugb file: truncated header", 2)

	json_length = int.from_bytes(data[12:16], "little")
	json_type = data[16:20]

	if json_type != b"JSON":
		fail("invalid .slugb file: expected JSON chunk first", 2)

	json_start = 20
	json_end = json_start + json_length

	if len(data) < json_end:
		fail("invalid .slugb file: truncated JSON chunk", 2)

	return data[json_start:json_end].decode("utf-8").rstrip()

def filter_serial_json_by_key(serial_json: str, key) -> str:
	target = key_to_serial_json(key)

	try:
		data = json.loads(serial_json)

	except json.JSONDecodeError as exc:
		fail(f"failed to parse serialized atlas JSON: {exc}")

	shapes = [s for s in data.get("shapes", []) if s.get("key") == target]
	composites = [c for c in data.get("composites", []) if c.get("key") == target]

	if not shapes and not composites:
		fail(f"key not found: {format_key(key)}")

	result = {"key": target}

	if shapes:
		result["shapes"] = shapes

	if composites:
		result["composites"] = composites

		shape_by_key = {json.dumps(s.get("key"), sort_keys=True): s for s in data.get("shapes", [])}
		layer_shapes = []
		seen = set()

		for composite in composites:
			for layer in composite.get("layers", []):
				serialized_key = json.dumps(layer.get("key"), sort_keys=True)

				if serialized_key in seen:
					continue

				shape = shape_by_key.get(serialized_key)

				if shape is not None:
					layer_shapes.append(shape)
					seen.add(serialized_key)

		if layer_shapes:
			result["layer_shapes"] = layer_shapes

	return json.dumps(result, separators=(",", ":"))

def ensure_serial_support() -> None:
	if not hasattr(slughorn, "write"):
		fail("slughorn.write() is not available; build with SERIAL enabled", 2)

def ensure_freetype_support() -> None:
	if not hasattr(slughorn, "freetype"):
		fail("slughorn.freetype is not available; build with FREETYPE enabled", 2)

def clamp(x: float, lo: float, hi: float) -> float:
	return lo if x < lo else hi if x > hi else x

def compute_render_size(shape, size_hint: int=256) -> tuple[int, int]:
	w = shape.width
	h = shape.height

	if w <= 0.0 or h <= 0.0:
		raise ValueError(f"Invalid shape dimensions: width={w}, height={h}")

	scale = size_hint / max(w, h)
	out_w = max(1, int(round(w * scale)))
	out_h = max(1, int(round(h * scale)))

	return out_w, out_h

class EmTransform:
	def __init__(self, shape, width: int, height: int, margin: float=0.0):
		ox, oy = shape.em_origin
		sx, sy = shape.em_size

		ox -= margin * sx
		oy -= margin * sy
		sx *= (1.0 + 2.0 * margin)
		sy *= (1.0 + 2.0 * margin)

		self.ox = ox
		self.oy = oy
		self.sx = sx
		self.sy = sy
		self.width = width
		self.height = height

	def em_to_px(self, ex: float, ey: float) -> tuple[int, int]:
		u = (ex - self.ox) / self.sx
		v = (ey - self.oy) / self.sy

		return int(u * self.width), int(v * self.height)

@contextlib.contextmanager
def timer(enabled: bool, label: str):
	start = time.perf_counter()

	yield

	if enabled:
		print(f"{label}: {time.perf_counter() - start:.3f}s")

def save_image(grid, filename: str, flip_y: bool=False) -> str:
	if flip_y:
		grid = grid[::-1]

	h, w = grid.shape
	img = Image.new("L", (w, h))
	px = img.load()

	for y in range(h):
		for x in range(w):
			px[x, y] = int(clamp(float(grid[y, x]), 0.0, 1.0) * 255)

	img.save(filename)

	return f"Saved {w}x{h} image -> {filename}"

def build_curves_svg_string(
	decoded,
	atlas,
	key,
	scale: int=1024,
	margin: float=0.0,
	flip_y: bool=True,
) -> str:
	shape = decoded.shape
	indir_x = bytes(decoded.indir_x)
	indir_y = bytes(decoded.indir_y)
	indir_size = len(indir_x)
	contours = atlas.get_shape_contours(key)

	w, h = compute_render_size(shape, scale)
	xf = EmTransform(shape, w, h, margin)

	ox, oy = shape.em_origin
	sx, sy = shape.em_size

	def em_to_px(ex: float, ey: float) -> tuple[int, int]:
		px, py = xf.em_to_px(ex, ey)

		if flip_y:
			py = h - 1 - py

		return px, py

	elements: list[str] = []

	def line(x1, y1, x2, y2, color, width=1):
		elements.append(
			f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
			f'stroke="{color}" stroke-width="{width}" />'
		)

	def circle(cx, cy, r, fill=None, stroke=None):
		parts = [f'cx="{cx}"', f'cy="{cy}"', f'r="{r}"']

		if fill:
			parts.append(f'fill="{fill}"')
		else:
			parts.append('fill="none"')

		if stroke:
			parts.append(f'stroke="{stroke}"')

		elements.append(f'<circle {" ".join(parts)} />')

	def quad_path(p1, p2, p3, color):
		x1, y1 = em_to_px(*p1)
		x2, y2 = em_to_px(*p2)
		x3, y3 = em_to_px(*p3)

		elements.append(
			f'<path d="M {x1},{y1} Q {x2},{y2} {x3},{y3}" '
			f'stroke="{color}" fill="none" stroke-width="1" />'
		)

	for contour in contours:
		# One closed SVG path per contour (outer outline, counter, etc.)
		parts = []

		# Flattens the (K, 6) buffer to 1D, zero-copy, still stdlib memoryview -- no numpy needed.
		# Plain `for row in contour` fails: CPython's built-in memoryview cannot produce row
		# sub-views for any ndim>1 buffer at all, not via slicing, not via single-index (the same
		# limitation ShapeContours.__getitem__ had to work around by constructing a fresh view
		# directly from a C++ pointer instead of slicing). Two-step cast through a byte format is
		# required: memoryview.cast() only allows a format change when at least one side is a
		# byte format ('B'/'b'/'c') -- 'f' directly to 'f' with a new shape is NOT allowed, even
		# though the element format isn't really changing. Only works because our buffer is
		# C-contiguous (row stride == 6 * itemsize, guaranteed by flatView2D/curveView2D) --
		# cast() requires that.
		flat = contour.cast("B").cast("f")

		for i in range(len(contour)):
			base = i * 6
			x1e, y1e, x2e, y2e, x3e, y3e = flat[base:base + 6]
			px1, py1 = em_to_px(x1e, y1e)
			px2, py2 = em_to_px(x2e, y2e)
			px3, py3 = em_to_px(x3e, y3e)

			if i == 0:
				parts.append(f"M {px1},{py1}")

			parts.append(f"Q {px2},{py2} {px3},{py3}")

			# Control point decorations
			line(px1, py1, px2, py2, "#5050B4")
			line(px2, py2, px3, py3, "#5050B4")
			circle(px1, py1, 2, fill="#50DC50")
			circle(px3, py3, 2, fill="#50DC50")
			circle(px2, py2, 3, stroke="#6464FF")

		parts.append("Z")
		elements.append(
			f'<path d="{" ".join(parts)}" stroke="#CCCCCC" fill="none" stroke-width="1" />'
		)

	ys = [
		(q + 1 - shape.band_offset_y) / shape.band_scale_y
		for q in range(indir_size - 1)
		if indir_y[q] != indir_y[q + 1]
	]
	xs = [
		(q + 1 - shape.band_offset_x) / shape.band_scale_x
		for q in range(indir_size - 1)
		if indir_x[q] != indir_x[q + 1]
	]

	for y in ys:
		x0, y0 = em_to_px(ox, y)
		x1, y1 = em_to_px(ox + sx, y)
		line(x0, y0, x1, y1, "#FF0000", width=1)

	for x in xs:
		x0, y0 = em_to_px(x, oy)
		x1, y1 = em_to_px(x, oy + sy)
		line(x0, y0, x1, y1, "#FF0000", width=1)

	ox_em = shape.bearing_x + shape.origin_x
	oy_em = (shape.bearing_y - shape.height) + shape.origin_y
	opx, opy = em_to_px(ox_em, oy_em)
	circle(opx, opy, 5, fill="#FFD700", stroke="#FF8C00")

	svg = " ".join((
		'<svg xmlns="http://www.w3.org/2000/svg"',
		f'width="{w}" height="{h}"',
		f'viewBox="0 0 {w} {h}">\n'
	))
	svg += '\t<rect width="100%" height="100%" fill="#1E1E1E"/>\n'
	svg += '\n'.join(f"\t{e}" for e in elements)
	svg += "\n</svg>"

	return svg

def save_curves_svg(
	decoded,
	atlas,
	key,
	filename: str,
	scale: int=1024,
	margin: float=0.0,
	flip_y: bool=True,
) -> str:
	svg = build_curves_svg_string(decoded, atlas, key, scale=scale, margin=margin, flip_y=flip_y)

	with open(filename, "w", encoding="utf-8") as f:
		f.write(svg)

	return f"Saved SVG debug -> {filename}"

def default_output_path(raw_key: str, suffix: str) -> str:
	safe = raw_key.replace("/", "_")
	return str(Path(safe).with_suffix(suffix))

def parse_codepoint(raw: str) -> int:
	text = raw.strip()

	if not text:
		raise ValueError("empty codepoint")

	upper = text.upper()

	if upper.startswith("U+"):
		return int(upper[2:], 16)

	return int(text, 0)

def parse_codepoint_range(raw: str) -> tuple[int, int]:
	text = raw.strip()

	if "-" not in text:
		raise ValueError(f"range must use START-END syntax: {raw!r}")

	start_text, end_text = text.split("-", 1)
	start = validate_codepoint(parse_codepoint(start_text))
	end = validate_codepoint(parse_codepoint(end_text))

	if end < start:
		raise ValueError(f"range end precedes start: {raw!r}")

	return start, end

def validate_codepoint(cp: int) -> int:
	if not (0 <= cp <= 0x10FFFF):
		raise ValueError(f"codepoint out of Unicode range: U+{cp:X}")
	return cp

def dedupe_codepoints(codepoints: list[int]) -> list[int]:
	seen: set[int] = set()
	ordered: list[int] = []

	for cp in codepoints:
		cp = validate_codepoint(cp)
		if cp in seen:
			continue
		seen.add(cp)
		ordered.append(cp)

	return ordered

def build_font_codepoint_selection(args) -> tuple[str, list[int] | None]:
	selections = sum(bool(x) for x in (args.ascii, args.range, args.chars))

	if selections > 1:
		fail("choose at most one of --ascii, --range, or --chars", 2)

	if args.ascii:
		return "ascii", list(range(32, 127))

	if args.range is not None:
		try:
			start, end = parse_codepoint_range(args.range)

		except ValueError as exc:
			fail(str(exc), 2)

		return "range", list(range(start, end + 1))

	if args.chars is not None:
		return "chars", dedupe_codepoints([ord(ch) for ch in args.chars])

	return "all", None

def get_shape_or_fail(atlas, key):
	shape = atlas.get_shape(key)

	if shape is None:
		available = ", ".join(format_key(k) for k in atlas.get_shapes().keys())
		fail(f"key not found: {format_key(key)}\nAvailable shapes: {available or '(none)'}")

	return shape

def decode_or_fail(atlas, key):
	shape = atlas.get_shape(key)

	if shape is not None:
		return atlas.decode(key)

	composite = atlas.get_composite_shape(key)

	if composite is not None:
		if len(composite.layers) == 1:
			layer_key = composite.layers[0].key
			if atlas.get_shape(layer_key) is not None:
				return atlas.decode(layer_key)

		layer_keys = ", ".join(format_key(l.key) for l in composite.layers)
		fail(f"'{format_key(key)}' is a {len(composite.layers)}-layer composite ({layer_keys}); render each shape individually")

	available = ", ".join(format_key(k) for k in atlas.get_shapes().keys())
	fail(f"key not found: {format_key(key)}\nAvailable shapes: {available or '(none)'}")

def command_info(args) -> int:
	if args.json:
		serial_json = read_serial_json(args.atlas)

		if args.key is not None or args.ord is not None:
			key = resolve_key_arg(args.key, args.ord)
			serial_json = filter_serial_json_by_key(serial_json, key)

		print(serial_json)

		return 0

	atlas = load_atlas(args.atlas)
	shapes = atlas.get_shapes()
	composites = atlas.get_composite_shapes()
	curve_tex = atlas.curve_texture
	band_tex = atlas.band_texture
	grad_tex = atlas.gradient_texture
	has_key = args.key is not None or args.ord is not None

	if not has_key:
		print(f"Atlas: {args.atlas}")
		print(f"  shapes:       {len(shapes)}")
		print(f"  composites:   {len(composites)}")
		print(f"  curve tex:    {curve_tex.width}x{curve_tex.height} {curve_tex.format}  ({tex_size_str(curve_tex)})")
		print(f"  band tex:     {band_tex.width}x{band_tex.height} {band_tex.format}  ({tex_size_str(band_tex)})")

		if grad_tex.width > 0:
			print(f"  gradient tex: {grad_tex.width}x{grad_tex.height} {grad_tex.format}  ({tex_size_str(grad_tex)})")

		print(f"  packing:      {atlas.packing_stats}")

	if has_key:
		key = resolve_key_arg(args.key, args.ord)
		shape = atlas.get_shape(key)
		composite = atlas.get_composite_shape(key)

		print(f"Key: {format_key(key)}")

		if shape is not None:
			print(f"  shape:       {shape}")
			print(f"  user_origin: {shape.origin}")
			print(f"  em_origin:   {shape.em_origin}")
			print(f"  em_size:     {shape.em_size}")

			try:
				decoded = atlas.decode(key)
				indir_x = bytes(decoded.indir_x)
				indir_y = bytes(decoded.indir_y)
				print(f"  curves:      {len(decoded.curves)}")
				print(f"  bands:       {len(indir_x)}x{len(indir_y)}")

			except Exception:
				pass

		if composite is not None:
			print(f"  composite:   layers={len(composite.layers)} advance={composite.advance}")

			for i, layer in enumerate(composite.layers):
				lshape = atlas.get_shape(layer.key)
				print(f"\n  Layer {i}: {format_key(layer.key)}")

				if lshape is not None:
					print(f"    shape:       {lshape}")
					print(f"    user_origin: {lshape.origin}")
					print(f"    em_origin:   {lshape.em_origin}")
					print(f"    em_size:     {lshape.em_size}")

					try:
						ldecoded = atlas.decode(layer.key)
						indir_x = bytes(ldecoded.indir_x)
						indir_y = bytes(ldecoded.indir_y)
						print(f"    curves:    {len(ldecoded.curves)}")
						print(f"    bands:     {len(indir_x)}x{len(indir_y)}")

					except Exception:
						pass

				else:
					print(f"    (shape not found in atlas)")

		if shape is None and composite is None:
			fail(f"key not found: {format_key(key)}")

	return 0

def command_list(args) -> int:
	atlas = load_atlas(args.atlas)
	shapes = atlas.get_shapes()
	composites = atlas.get_composite_shapes()
	show_shapes = args.shapes or not args.composites
	show_composites = args.composites or not args.shapes

	if show_shapes:
		print("Shapes:")

		for key in sorted(shapes.keys(), key=format_key):
			print(f"  {format_key(key)}")

	if show_shapes and show_composites:
		print("")

	if show_composites:
		print("Composites:")

		for key in sorted(composites.keys(), key=format_key):
			print(f"  {format_key(key)}")

	return 0

def command_render(args) -> int:
	atlas = load_atlas(args.atlas)
	key = resolve_key_arg(args.key, args.ord)
	decoded = decode_or_fail(atlas, key)
	output = args.output or default_output_path(args.key or args.ord, ".png")

	with timer(args.time, "render"):
		grid = decoded.render_grid(size=args.size, margin=args.margin, banded=not args.reference)

	with timer(args.time, "write"):
		message = save_image(grid, output, flip_y=args.flip_y)

	print(message)

	return 0

def command_svg(args) -> int:
	atlas = load_atlas(args.atlas)
	key = resolve_key_arg(args.key, args.ord)
	decoded = decode_or_fail(atlas, key)
	output = args.output or default_output_path(args.key or args.ord, ".svg")

	with timer(args.time, "write"):
		message = save_curves_svg(
			decoded,
			atlas,
			key,
			output,
			scale=args.size,
			margin=args.margin,
			flip_y=not args.no_flip_y,
		)

	print(message)

	return 0

def command_shell(args) -> int:
	atlas = load_atlas(args.atlas)
	ns = {
		"slughorn": slughorn,
		"atlas": atlas,
		"path": args.atlas,
		"make_key": make_key,
	}

	if args.key is not None:
		key = resolve_key_arg(args.key, args.ord)
		ns["key"] = key
		ns["shape"] = atlas.get_shape(key)
		if ns["shape"] is not None:
			ns["decoded"] = atlas.decode(key)

	banner = "\n".join((
		f"slughorn shell: {args.atlas}",
		"Available names: slughorn, atlas, path, make_key"
		+ (", key, shape, decoded" if args.key is not None and ns.get("shape") is not None else ""),
	))

	if args.plain:
		code.interact(banner=banner, local=ns)

		return 0

	try:
		from IPython import start_ipython

	except ImportError:
		code.interact(banner=banner + "\n(IPython not installed; using stdlib REPL)", local=ns)

		return 0

	start_ipython(argv=[], user_ns=ns)

	return 0

def command_font(args) -> int:
	ensure_serial_support()
	ensure_freetype_support()

	if not hasattr(slughorn.freetype, "load_font_glyphs") or not hasattr(slughorn.freetype, "load_all_font_glyphs"):
		fail("slughorn.freetype is missing glyph-loading helpers; rebuild the extension", 2)

	mode, codepoints = build_font_codepoint_selection(args)
	atlas = slughorn.Atlas()

	if args.curve_precision == "16":
		atlas.set_curve_texture_format("RGBA16F")

	with timer(args.time, "load"):
		try:
			if mode == "all":
				loaded = slughorn.freetype.load_all_font_glyphs(args.font, atlas)

			else:
				if codepoints is None:
					fail("internal error: missing explicit codepoint selection", 2)

				loaded = slughorn.freetype.load_font_glyphs(args.font, codepoints, atlas)

		except Exception as exc:
			fail(f"failed to load font glyphs from {args.font}: {exc}")

	if loaded == 0:
		fail(f"no glyphs were loaded from {args.font}", 1)

	with timer(args.time, "build"):
		try:
			atlas.build()

		except Exception as exc:
			fail(f"failed to build atlas from {args.font}: {exc}")

	with timer(args.time, "write"):
		try:
			slughorn.write(atlas, args.output)

		except Exception as exc:
			fail(f"failed to write atlas to {args.output}: {exc}")

	label = {
		"ascii": "ASCII",
		"range": f"range {args.range}",
		"chars": f"{len(codepoints or [])} explicit chars",
		"all": "full font charmap",
	}[mode]

	print(f"Packed {loaded} glyphs from {args.font} ({label}) -> {args.output}")

	return 0

def build_parser() -> argparse.ArgumentParser:
	parser = argparse.ArgumentParser(
		prog="slughorn",
		description="CLI utility for inspecting and exporting slughorn atlas files.",
	)
	sub = parser.add_subparsers(dest="command", required=True)

	info = sub.add_parser("info", help="Show atlas summary, or details for one key.")
	info.add_argument("atlas", help="Path to a .slug or .slugb atlas file.")
	info.add_argument("key", nargs="?", help="Optional shape/composite key.")
	info.add_argument("--ord", metavar="CHAR", help="Resolve a single character to its codepoint (alternative to a hex key).")
	info.add_argument("--json", action="store_true", help="Print the serialized atlas JSON metadata.")
	info.set_defaults(func=command_info)

	list_cmd = sub.add_parser("list", help="List keys stored in an atlas.")
	list_cmd.add_argument("atlas", help="Path to a .slug or .slugb atlas file.")
	list_cmd.add_argument("--shapes", action="store_true", help="Show only shape keys.")
	list_cmd.add_argument("--composites", action="store_true", help="Show only composite keys.")
	list_cmd.set_defaults(func=command_list)

	render = sub.add_parser("render", help="Render a shape to grayscale PNG.")
	render.add_argument("atlas", help="Path to a .slug or .slugb atlas file.")
	render.add_argument("key", nargs="?", help="Shape key to render.")
	render.add_argument("output", nargs="?", help="Output PNG path. Default: <key>.png")
	render.add_argument("--size", type=int, default=256, help="Maximum output dimension.")
	render.add_argument("--margin", type=float, default=0.0, help="Margin fraction around the shape.")
	render.add_argument("--reference", action="store_true", help="Use the brute-force reference path instead of the banded path.")
	render.add_argument("--flip-y", action="store_true", help="Flip the output image vertically before saving.")
	render.add_argument("--time", action="store_true", help="Print timing for render/write stages.")
	render.add_argument("--ord", metavar="CHAR", help="Resolve a single character to its codepoint (alternative to a hex key).")
	render.set_defaults(func=command_render)

	svg = sub.add_parser("svg", help="Export decoded curves and band boundaries as SVG.")
	svg.add_argument("atlas", help="Path to a .slug or .slugb atlas file.")
	svg.add_argument("key", nargs="?", help="Shape key to export.")
	svg.add_argument("output", nargs="?", help="Output SVG path. Default: <key>.svg")
	svg.add_argument("--size", type=int, default=1024, help="Maximum output dimension.")
	svg.add_argument("--margin", type=float, default=0.0, help="Margin fraction around the shape.")
	svg.add_argument("--no-flip-y", action="store_true", help="Keep the authored Y direction instead of SVG-style flipped output.")
	svg.add_argument("--time", action="store_true", help="Print timing for write stage.")
	svg.add_argument("--ord", metavar="CHAR", help="Resolve a single character to its codepoint (alternative to a hex key).")
	svg.set_defaults(func=command_svg)

	shell = sub.add_parser("shell", help="Open an interactive shell with an atlas loaded.")
	shell.add_argument("atlas", help="Path to a .slug or .slugb atlas file.")
	shell.add_argument("key", nargs="?", help="Optional key to pre-decode.")
	shell.add_argument("--plain", action="store_true", help="Force the stdlib REPL even if IPython is installed.")
	shell.add_argument("--ord", metavar="CHAR", help="Resolve a single character to its codepoint (alternative to a hex key).")
	shell.set_defaults(func=command_shell)

	font = sub.add_parser("font", help="Pack font glyphs into a .slug or .slugb atlas.")
	font.add_argument("font", help="Path to an input font file.")
	font.add_argument("output", help="Output atlas path (.slug or .slugb).")
	font.add_argument("--ascii", action="store_true", help="Pack printable ASCII only (U+0020-U+007E).")
	font.add_argument("--range", help="Pack one inclusive Unicode range, e.g. U+0020-U+007E.")
	font.add_argument("--chars", help="Pack the unique codepoints present in this literal string.")
	font.add_argument("--time", action="store_true", help="Print timing for load/build/write stages.")
	font.add_argument(
		"--curve-precision", choices=["16", "32"], default="32",
		help="Curve texture float precision (DEFAULT 32). 16 halves curve-texture memory but is "
		"a real precision tradeoff -- can visibly degrade shape edges under heavy zoom."
	)
	font.set_defaults(func=command_font)

	return parser

def main(argv: list[str] | None=None) -> int:
	parser = build_parser()
	args = parser.parse_args(argv)
	return args.func(args)

if __name__ == "__main__":
	raise SystemExit(main())
