#!/usr/bin/env python3
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
from pathlib import Path


BUNDLES = [
    {
        "id": "stock",
        "preset": None,
        "passes": [{"shader": "stock.slang"}],
        "test_only": True,
    },
    {
        "id": "bilinear",
        "preset": "bilinear.slangp",
    },
    {
        "id": "crt-easymode",
        "preset": "crt/crt-easymode.slangp",
    },
    {
        # Two-pass xBR-style AA chain: pass0 renders to a viewport-sized
        # intermediate that pass1 samples as Source.  Phase 1 multipass fixture.
        "id": "aa-shader-4.0",
        "preset": "anti-aliasing/aa-shader-4.0.slangp",
    },
    {
        # Single-pass LUT fixture: samples a MASK lookup texture with repeat
        # wrap.  Phase 2 LUT-loading coverage on a real upstream preset.
        "id": "crt-potato-warm",
        "preset": "crt/crt-potato-warm.slangp",
    },
    {
        # Crafted Phase 2 fixture: two passes exercising a LUT plus a pass
        # alias.  Pass 0 is aliased "FirstPass"; pass 1 samples FirstPass and
        # a PALETTE LUT and reads FirstPassSize/PALETTESize uniforms.
        "id": "lut-alias-test",
        "preset": "lut-alias-test.slangp",
        "fixture": True,
    },
    {
        # Phase 3 history fixture: single pass mixing the current frame with
        # OriginalHistory1/OriginalHistory2 (history depth 2).
        "id": "history-test",
        "preset": "history-test.slangp",
        "fixture": True,
    },
    {
        # Phase 3 feedback fixture: pass 0 (alias "Accum") accumulates using its
        # own PassFeedback0; pass 1 reads AccumFeedback (alias feedback).
        "id": "feedback-test",
        "preset": "feedback-test.slangp",
        "fixture": True,
    },
    {
        # Phase 4 float fixture: pass 0 writes >1.0 into an R16F framebuffer;
        # pass 1 tonemaps it back into the swapchain range.
        "id": "float-test",
        "preset": "float-test.slangp",
        "fixture": True,
    },
    {
        # Phase 4 heavy preset: 12 passes with sRGB framebuffers, six mask LUTs,
        # and many aliases.  Exercises per-pass formats end to end.
        "id": "crt-royale",
        "preset": "crt/crt-royale.slangp",
    },
    {
        "id": "crt-guest-advanced",
        "preset": "crt/crt-guest-advanced.slangp",
        "override_dir_arg": "crt_guest_advanced_dir",
        "override_preset": "crt-guest-advanced.slangp",
        "allow_unused_params": True,
    },
    {
        "id": "crt-guest-advanced-fast",
        "preset": "crt/crt-guest-advanced-fast.slangp",
        "override_dir_arg": "crt_guest_advanced_dir",
        "override_preset": "crt-guest-advanced-fast.slangp",
        "allow_unused_params": True,
    },
    {
        "id": "crt-guest-advanced-fastest",
        "preset": "crt/crt-guest-advanced-fastest.slangp",
        "override_dir_arg": "crt_guest_advanced_dir",
        "override_preset": "crt-guest-advanced-fastest.slangp",
        "allow_unused_params": True,
    },
    {
        "id": "crt-guest-advanced-ntsc",
        "preset": "crt-guest-advanced-ntsc.slangp",
        "local_preset": "src/shaders/crt-guest-advanced-ntsc/crt-guest-advanced-ntsc.slangp",
        "allow_unused_params": True,
    },
]


def parse_extra_bundle(spec):
    if "=" not in spec:
        raise argparse.ArgumentTypeError(
                "--extra-bundle must be in the form id=/path/to/preset.slangp")

    bundle_id, preset = spec.split("=", 1)
    bundle_id = bundle_id.strip()
    preset_path = Path(preset).expanduser().resolve()

    if not bundle_id:
        raise argparse.ArgumentTypeError("--extra-bundle id cannot be empty")
    if not preset_path.exists():
        raise argparse.ArgumentTypeError(
                f"--extra-bundle preset does not exist: {preset_path}")

    return {
        "id": bundle_id,
        "preset": preset_path.name,
        "preset_path": preset_path,
        "shader_root": preset_path.parent,
    }


INCLUDE_RE = re.compile(r'^\s*#\s*include\s+[<"]([^">]+)[">]')
PARAM_RE = re.compile(
    r'^\s*#pragma\s+parameter\s+'
    r'(?P<name>\S+)\s+'
    r'"(?P<label>[^"]*)"\s+'
    r'(?P<default>\S+)\s+'
    r'(?P<min>\S+)\s+'
    r'(?P<max>\S+)\s+'
    r'(?P<step>\S+)')
STAGE_RE = re.compile(r'^\s*#pragma\s+stage\s+(vertex|fragment)\s*$', re.I)
SET_RE = re.compile(r'set\s*=\s*\d+')
BINDING_RE = re.compile(r'binding\s*=\s*\d+')
PUSH_CONSTANT_LAYOUT_RE = re.compile(r'layout\s*\(\s*push_constant\s*\)')
SEMANTIC_UNIFORMS = {
    "MVP",
    "MVPMatrix",
    "SourceSize",
    "OriginalSize",
    "OutputSize",
    "InputSize",
    "TextureSize",
    "FrameCount",
    "FrameDirection",
}


def quote(value):
    return json.dumps(str(value))


def strip_inline_comment(line):
    in_quote = False
    escaped = False
    out = []

    for ch in line:
        if escaped:
            out.append(ch)
            escaped = False
            continue

        if ch == "\\":
            out.append(ch)
            escaped = True
            continue

        if ch == '"':
            in_quote = not in_quote
            out.append(ch)
            continue

        if ch == "#" and not in_quote:
            break

        out.append(ch)

    return "".join(out).strip()


def parse_preset(path):
    values = {}

    for raw_line in path.read_text(encoding="utf-8").splitlines():
        line = strip_inline_comment(raw_line)
        if not line or "=" not in line:
            continue

        key, value = line.split("=", 1)
        key = key.strip()
        value = value.strip()
        if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
            value = value[1:-1]
        values[key] = value

    num_passes = int(values.get("shaders", "1"))
    passes = []
    for idx in range(num_passes):
        shader_key = f"shader{idx}"
        if shader_key not in values:
            raise RuntimeError(f"{path}: missing {shader_key}")

        pass_info = {
            "shader": str((path.parent / values[shader_key]).resolve()),
            "filter_linear": values.get(f"filter_linear{idx}", ""),
            "wrap_mode": values.get(f"wrap_mode{idx}", ""),
            "mipmap_input": values.get(f"mipmap_input{idx}", ""),
            "scale_type": values.get(f"scale_type{idx}", ""),
            "scale_type_x": values.get(f"scale_type_x{idx}", ""),
            "scale_type_y": values.get(f"scale_type_y{idx}", ""),
            "scale": values.get(f"scale{idx}", ""),
            "scale_x": values.get(f"scale_x{idx}", ""),
            "scale_y": values.get(f"scale_y{idx}", ""),
            "alias": values.get(f"alias{idx}", ""),
            "float_framebuffer": values.get(f"float_framebuffer{idx}", ""),
            "srgb_framebuffer": values.get(f"srgb_framebuffer{idx}", ""),
        }
        passes.append(pass_info)

    textures = parse_preset_textures(values, path.parent)

    return values, passes, textures


def parse_preset_textures(values, preset_dir):
    raw = values.get("textures", "")
    textures = []

    for name in raw.split(";"):
        name = name.strip()
        if not name:
            continue
        if name not in values:
            raise RuntimeError(f"textures entry {name} has no path")

        textures.append({
            "name": name,
            "path": str((preset_dir / values[name]).resolve()),
            "linear": values.get(f"{name}_linear", ""),
            "wrap_mode": values.get(f"{name}_wrap_mode", ""),
            "mipmap": values.get(f"{name}_mipmap", ""),
        })

    return textures


def find_include(include_name, current_file, shader_root):
    candidates = [
        current_file.parent / include_name,
        shader_root / include_name,
        shader_root / "include" / include_name,
    ]

    for candidate in candidates:
        if candidate.exists():
            return candidate.resolve()

    raise RuntimeError(f"{current_file}: include not found: {include_name}")


def resolve_includes(path, shader_root, seen, deps):
    path = path.resolve()
    if path in seen:
        stack = " -> ".join(str(item) for item in seen + [path])
        raise RuntimeError(f"include cycle detected: {stack}")

    seen.append(path)
    deps.append(path)
    output = []

    # Some upstream slang shaders carry latin-1 bytes in author/comment lines.
    # Decode permissively so a stray byte in a comment does not abort the build.
    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
        match = INCLUDE_RE.match(line)
        if match:
            include_path = find_include(match.group(1), path, shader_root)
            output.append(resolve_includes(include_path, shader_root, seen, deps))
        else:
            output.append(line)

    seen.pop()
    return "\n".join(output) + "\n"


def split_stages(source, source_name):
    common = []
    stages = {"vertex": [], "fragment": []}
    current = None

    for line in source.splitlines():
        match = STAGE_RE.match(line)
        if match:
            current = match.group(1).lower()
            continue

        if current:
            stages[current].append(line)
        else:
            common.append(line)

    missing = [name for name, lines in stages.items() if not lines]
    if missing:
        raise RuntimeError(f"{source_name}: missing stage(s): {', '.join(missing)}")

    return {
        name: "\n".join(common + [""] + lines) + "\n"
        for name, lines in stages.items()
    }


def rewrite_resource_sets_for_sdl_gpu(source, stage):
    texture_set = "0" if stage == "vertex" else "2"
    uniform_set = "1" if stage == "vertex" else "3"
    texture_binding = 0
    output = []

    for line in source.splitlines():
        if PUSH_CONSTANT_LAYOUT_RE.search(line):
            line = PUSH_CONSTANT_LAYOUT_RE.sub(
                    f"layout(std140, set = {uniform_set}, binding = 1)",
                    line, count=1)

        if "layout" in line and "set" in line and "binding" in line:
            lower = line.lower()
            if "sampler" in lower or "image" in lower or " buffer " in lower:
                line = SET_RE.sub(f"set = {texture_set}", line, count=1)
                line = BINDING_RE.sub(f"binding = {texture_binding}", line,
                        count=1)
                texture_binding += 1
            elif "uniform" in lower:
                line = SET_RE.sub(f"set = {uniform_set}", line, count=1)

        output.append(line)

    return "\n".join(output) + "\n"


def extract_parameters(source):
    params = []
    for line in source.splitlines():
        match = PARAM_RE.match(line)
        if not match:
            continue

        data = match.groupdict()
        params.append({
            "name": data["name"],
            "label": data["label"],
            "default": float(data["default"]),
            "min": float(data["min"]),
            "max": float(data["max"]),
            "step": float(data["step"]),
        })
    return params


def parameter_is_display_only(param):
    label = param.get("label", "").strip()
    if param.get("name", "").startswith("bogus"):
        return True
    if label.startswith("["):
        return True
    return param.get("min") == param.get("max")


def clean_header(label):
    return label.strip().strip("[]: ").strip()


def is_section_header(param):
    label = param.get("label", "").strip()
    return (param.get("name", "").startswith("bogus") or
            label.startswith("["))


def is_separator(param):
    label = param.get("label", "").strip()
    if param.get("name", "").startswith("ntsc-row"):
        return True
    return bool(label) and set(label) <= {"-", " "}


def run(cmd):
    print(" ".join(str(item) for item in cmd))
    subprocess.run(cmd, check=True)


def compile_spirv(tool, stage, source_path, output_path):
    output_path.parent.mkdir(parents=True, exist_ok=True)
    tool_name = Path(tool).name

    # -Os strips unused samplers/uniform members.  Sparse sampler slots that
    # result are handled at runtime (num_samplers = max_slot + 1 with dummy
    # bindings in the gaps), and uniform-block members are kept live by the
    # shaders that need them, so optimizing for size is safe and keeps the
    # reflected uniform offsets stable.
    if tool_name == "glslc":
        cmd = [tool, "-Os", f"-fshader-stage={stage}", str(source_path),
               "-o", str(output_path)]
    else:
        glslang_stage = "vert" if stage == "vertex" else "frag"
        cmd = [tool, "-V", "-Os", "-S", glslang_stage, "-o",
               str(output_path), str(source_path)]

    run(cmd)


def shadercross_json(shadercross, stage, spv_path, output_path):
    output_path.parent.mkdir(parents=True, exist_ok=True)
    run([
        shadercross,
        "-s", "SPIRV",
        "-d", "JSON",
        "-t", stage,
        "-o", str(output_path),
        str(spv_path),
    ])
    return json.loads(output_path.read_text(encoding="utf-8"))


def shadercross_msl(shadercross, stage, spv_path, output_path):
    output_path.parent.mkdir(parents=True, exist_ok=True)
    run([
        shadercross,
        "-s", "SPIRV",
        "-d", "MSL",
        "-t", stage,
        "-o", str(output_path),
        str(spv_path),
    ])


def shadercross_dxil(shadercross, stage, spv_path, output_path):
    output_path.parent.mkdir(parents=True, exist_ok=True)
    run([
        shadercross,
        "-s", "SPIRV",
        "-d", "DXIL",
        "-t", stage,
        "-o", str(output_path),
        str(spv_path),
    ])


def spirv_cross_reflect(spirv_cross, spv_path):
    result = subprocess.run(
        [spirv_cross, str(spv_path), "--reflect"],
        check=True,
        text=True,
        stdout=subprocess.PIPE,
    )
    return json.loads(result.stdout)


def walk_members(types, type_name, target, stage, resource_name, result):
    type_info = types.get(type_name, {})
    members = type_info.get("members", [])

    for member in members:
        name = member.get("name")
        if name and "offset" in member:
            result[name] = {
                "stage": stage,
                "target": target,
                "resource": resource_name,
                "offset": int(member["offset"]),
                "type": member.get("type", ""),
            }

        nested_type = member.get("type")
        if nested_type in types:
            walk_members(types, nested_type, target, stage, resource_name, result)


def reflected_offsets(reflect_json, stage):
    offsets = {}
    types = reflect_json.get("types", {})
    resource_groups = [
        ("push_constant", "push_constants"),
        ("push_constant", "push_constant_buffers"),
        ("ubo", "ubos"),
        ("ubo", "uniform_buffers"),
    ]

    for target, group_name in resource_groups:
        for resource in reflect_json.get(group_name, []):
            type_name = resource.get("type") or resource.get("base_type_id")
            if not type_name:
                continue

            walk_members(types, type_name, target, stage,
                    resource.get("name", ""), offsets)

    return offsets


def reflected_uniform_slots(reflect_json):
    slots = {
        "common_uniform_slot": -1,
        "param_uniform_slot": -1,
        "common_uniform_size": 0,
        "param_uniform_size": 0,
    }

    for group_name in ("ubos", "uniform_buffers"):
        for resource in reflect_json.get(group_name, []):
            name = resource.get("name", "")
            binding = int(resource.get("binding", -1))
            block_size = int(resource.get("block_size", 0))

            if name == "UBO" and slots["common_uniform_slot"] < 0:
                slots["common_uniform_slot"] = binding
                slots["common_uniform_size"] = block_size
            elif name == "Push" and slots["param_uniform_slot"] < 0:
                slots["param_uniform_slot"] = binding
                slots["param_uniform_size"] = block_size

    return slots


def reflected_semantic_uniforms(reflect_json, stage, num_passes, alias_names,
        lut_names, param_names, bundle_id, pass_index):
    uniforms = []

    for name, layout in reflected_offsets(reflect_json, stage).items():
        if name in SEMANTIC_UNIFORMS:
            uniform = dict(layout)
            uniform["name"] = name
            uniforms.append(uniform)
        elif name.endswith("Size") and name not in param_names:
            # <Texture>Size uniforms cover LUTs, aliases, pass outputs,
            # history, and feedback.  A base that maps to no known texture
            # would silently read garbage at runtime, so fail the build.
            base = name[:-4]
            if sampler_name_resolves(base, num_passes, alias_names, lut_names):
                uniform = dict(layout)
                uniform["name"] = name
                uniforms.append(uniform)
            else:
                raise RuntimeError(
                    f"{bundle_id}: pass {pass_index}: {stage} stage references "
                    f"unresolved size uniform {name}")

    return sorted(uniforms, key=lambda item: (
        item.get("resource", ""), int(item.get("offset", 0)), item["name"]))


SAMPLER_BUILTIN_RE = re.compile(r'^(PassOutput|PassPrev|OriginalHistory'
        r'|PassFeedback)(\d+)$')


def sampler_name_resolves(name, num_passes, alias_names, lut_names):
    if name in ("Source", "Original"):
        return True
    if name in alias_names or name in lut_names:
        return True
    if SAMPLER_BUILTIN_RE.match(name):
        return True
    # <Alias>Feedback samples the aliased pass's previous-frame output.
    if name.endswith("Feedback"):
        base = name[:-len("Feedback")]
        if base in alias_names:
            return True
    return False


def reflected_samplers(reflect_json, bundle_id, pass_index, stage):
    samplers = []
    for resource in reflect_json.get("textures", []):
        name = resource.get("name", "")
        binding = int(resource.get("binding", -1))
        if not name or binding < 0:
            raise RuntimeError(
                f"{bundle_id}: pass {pass_index}: {stage} stage has an "
                f"unnamed or unbound sampler in reflection")
        samplers.append({"name": name, "slot": binding})

    samplers.sort(key=lambda item: item["slot"])

    # Slots may be sparse: shared headers commonly declare more samplers (e.g.
    # every LUT) than a given pass uses, and the compiler drops the unused ones
    # while keeping the surviving bindings' original numbers.  The runtime spans
    # num_samplers = max_slot + 1 and binds a dummy 1x1 texture in the gaps, so
    # gaps are allowed here; only a duplicate slot is a real error.
    seen = set()
    for sampler in samplers:
        if sampler["slot"] in seen:
            raise RuntimeError(
                f"{bundle_id}: pass {pass_index}: {stage} stage has duplicate "
                f"sampler slot {sampler['slot']}")
        seen.add(sampler["slot"])

    return samplers


def sampler_slot_count(sampler_list):
    """num_samplers SDL must allocate = highest reflected slot + 1."""
    if not sampler_list:
        return 0
    return max(sampler["slot"] for sampler in sampler_list) + 1


def merge_param_layout(existing, layout, bundle_id, pass_index, param_name):
    if not existing:
        return dict(layout)

    for key in ("target", "resource", "offset", "type"):
        if existing.get(key) != layout.get(key):
            raise RuntimeError(
                f"{bundle_id}: pass {pass_index}: parameter {param_name} "
                f"has mismatched {key} across stages")

    if existing.get("stage") != layout.get("stage"):
        existing["stage"] = "both"

    return existing


def hash_sources(paths):
    digest = hashlib.sha256()

    for path in sorted(set(paths)):
        digest.update(str(path).encode("utf-8"))
        digest.update(b"\0")
        digest.update(path.read_bytes())
        digest.update(b"\0")

    return digest.hexdigest()


def tool_version(tool):
    for flag in ("--version", "-v"):
        try:
            result = subprocess.run([tool, flag], text=True,
                    stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                    timeout=10)
        except (OSError, subprocess.SubprocessError):
            continue

        output = result.stdout.strip().splitlines()
        if output and not output[0].startswith("ERROR"):
            return output[0]

    return Path(tool).name


def prepare_shadercross_environment():
    dxcompiler_dir = Path("/opt/directx-shader-compiler/lib")

    if not dxcompiler_dir.exists():
        return

    current = os.environ.get("LD_LIBRARY_PATH", "")
    parts = [part for part in current.split(":") if part]
    dxcompiler_dir_str = str(dxcompiler_dir)
    if dxcompiler_dir_str not in parts:
        os.environ["LD_LIBRARY_PATH"] = ":".join([dxcompiler_dir_str] + parts)


def write_license_notes(bundle_dir, bundle_id, shader_root, sources):
    lines = [
        f"# License Notes for {bundle_id}",
        "",
        "Shader source files in this bundle are copied from the configured "
        "slang-shaders checkout. Keep the copied source files with the bundle "
        "so license comments and attribution remain available.",
        "",
        "Detected license/attribution lines:",
        "",
    ]

    found = False
    for source in sorted(set(sources)):
        try:
            rel = source.relative_to(shader_root)
        except ValueError:
            rel = Path(source.name)

        matches = []
        for line in source.read_text(encoding="utf-8", errors="replace").splitlines():
            lowered = line.lower()
            if "license" in lowered or "copyright" in lowered:
                matches.append(line.strip())

        if matches:
            found = True
            lines.append(f"## {rel.as_posix()}")
            lines.append("")
            for match in matches[:8]:
                lines.append(f"- {match}")
            lines.append("")

    if not found:
        lines.append("- No license or copyright lines were detected in the "
                "copied source files. Check upstream slang-shaders licensing "
                "before redistributing this bundle.")
        lines.append("")

    (bundle_dir / "LICENSES.md").write_text("\n".join(lines), encoding="utf-8")


def rel_to_bundle(path, bundle_dir):
    return path.relative_to(bundle_dir).as_posix()


def write_bundle_toml(bundle_dir, metadata):
    lines = [
        f'id = {quote(metadata["id"])}',
        f'source_hash = {quote(metadata["source_hash"])}',
        f'glsl_to_spirv = {quote(metadata["glsl_to_spirv"])}',
        f'shadercross = {quote(metadata["shadercross"])}',
        f'spirv_cross = {quote(metadata["spirv_cross"])}',
        '',
    ]

    if metadata.get("preset"):
        lines.append(f'source_preset = {quote(metadata["preset"])}')
        lines.append('')

    for texture in metadata.get("textures", []):
        lines.extend([
            '[[texture]]',
            f'name = {quote(texture["name"])}',
            f'file = {quote(texture["file"])}',
        ])
        for key in ("linear", "wrap_mode", "mipmap"):
            if texture.get(key):
                lines.append(f'{key} = {quote(texture[key])}')
        lines.append('')

    for pass_info in metadata["passes"]:
        lines.extend([
            '[[pass]]',
            f'index = {pass_info["index"]}',
            f'source = {quote(pass_info["source"])}',
        ])

        for key in ("filter_linear", "wrap_mode", "mipmap_input",
                    "scale_type", "scale_type_x", "scale_type_y",
                    "scale", "scale_x", "scale_y", "alias",
                    "float_framebuffer", "srgb_framebuffer"):
            if pass_info.get(key):
                lines.append(f'{key} = {quote(pass_info[key])}')

        lines.append('')

        for stage_info in pass_info["stages"]:
            lines.extend([
                '  [[pass.stage]]',
                f'  stage = {quote(stage_info["stage"])}',
                f'  entrypoint = {quote("main")}',
                f'  spirv = {quote(stage_info["spirv"])}',
                f'  msl = {quote(stage_info["msl"])}',
                f'  dxil = {quote(stage_info["dxil"])}',
                f'  samplers = {stage_info["samplers"]}',
                f'  storage_textures = {stage_info["storage_textures"]}',
                f'  storage_buffers = {stage_info["storage_buffers"]}',
                f'  uniform_buffers = {stage_info["uniform_buffers"]}',
                f'  common_uniform_slot = {stage_info["common_uniform_slot"]}',
                f'  common_uniform_size = {stage_info["common_uniform_size"]}',
                f'  param_uniform_slot = {stage_info["param_uniform_slot"]}',
                f'  param_uniform_size = {stage_info["param_uniform_size"]}',
                '',
            ])

            for sampler in stage_info.get("sampler_list", []):
                lines.extend([
                    '  [[pass.stage.sampler]]',
                    f'  name = {quote(sampler["name"])}',
                    f'  slot = {sampler["slot"]}',
                    '',
                ])

            for uniform in stage_info.get("uniforms", []):
                lines.extend([
                    '  [[pass.stage.uniform]]',
                    f'  name = {quote(uniform["name"])}',
                    f'  target = {quote(uniform["target"])}',
                    f'  resource = {quote(uniform["resource"])}',
                    f'  offset = {uniform["offset"]}',
                    f'  type = {quote(uniform["type"])}',
                    '',
                ])

        for param in pass_info["params"]:
            lines.extend([
                '  [[pass.param]]',
                f'  name = {quote(param["name"])}',
                f'  label = {quote(param["label"])}',
                f'  default = {param["default"]}',
                f'  min = {param["min"]}',
                f'  max = {param["max"]}',
                f'  step = {param["step"]}',
                f'  stage = {quote(param["layout"]["stage"])}',
                f'  target = {quote(param["layout"]["target"])}',
                f'  resource = {quote(param["layout"]["resource"])}',
                f'  offset = {param["layout"]["offset"]}',
                f'  type = {quote(param["layout"]["type"])}',
            ])
            if param.get("group"):
                lines.append(f'  group = {quote(param["group"])}')
            if param.get("divider"):
                lines.append('  divider = 1')
            lines.append('')

    (bundle_dir / "bundle.toml").write_text("\n".join(lines), encoding="utf-8")


def copy_textures(bundle_dir, textures, source_paths):
    if not textures:
        return []

    texture_dir = bundle_dir / "textures"
    texture_dir.mkdir(parents=True, exist_ok=True)
    result = []

    for texture in textures:
        src = Path(texture["path"])
        if not src.exists():
            raise RuntimeError(f"LUT texture not found: {src}")

        # LUT semantic names are unique within a preset, but their source
        # basenames are not (different directories can share a filename), so
        # key the copied file on the LUT name to avoid silent overwrites.
        dest_name = f"{texture['name']}{src.suffix}"
        destination = texture_dir / dest_name
        shutil.copy2(src, destination)
        source_paths.append(src)

        result.append({
            "name": texture["name"],
            "file": f"textures/{dest_name}",
            "linear": texture["linear"],
            "wrap_mode": texture["wrap_mode"],
            "mipmap": texture["mipmap"],
        })

    return result


def copy_sources(bundle_dir, shader_root, sources):
    source_dir = bundle_dir / "source"
    if source_dir.exists():
        shutil.rmtree(source_dir)
    source_dir.mkdir(parents=True)

    for source in sorted(set(sources)):
        try:
            rel = source.relative_to(shader_root)
        except ValueError:
            rel = Path(source.name)

        destination = source_dir / rel
        destination.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, destination)


def build_bundle(args, bundle):
    preset_path = None
    if bundle.get("override_dir_arg") and getattr(args, bundle["override_dir_arg"]):
        shader_root = Path(getattr(args, bundle["override_dir_arg"])).resolve()
        override_preset = bundle.get("override_preset", bundle["preset"])
        preset_path = (shader_root / override_preset).resolve()
    elif bundle.get("preset_path"):
        preset_path = Path(bundle["preset_path"]).resolve()
        shader_root = Path(bundle["shader_root"]).resolve()
    elif bundle.get("local_preset"):
        preset_path = (args.repo_root / bundle["local_preset"]).resolve()
        shader_root = preset_path.parent
    elif bundle.get("fixture"):
        shader_root = args.fixtures_dir.resolve()
    else:
        shader_root = args.slang_shaders_dir.resolve()
    bundle_output_dir = (args.fixture_output_dir if
            bundle.get("fixture") or bundle.get("test_only") else
            args.output_dir)
    bundle_dir = bundle_output_dir / bundle["id"]
    work_dir = args.work_dir / bundle["id"]
    if bundle_dir.exists():
        shutil.rmtree(bundle_dir)
    bundle_dir.mkdir(parents=True, exist_ok=True)
    work_dir.mkdir(parents=True, exist_ok=True)

    source_paths = []
    textures = []
    source_preset = None
    if bundle["preset"]:
        preset_path = preset_path or Path(shader_root / bundle["preset"]).resolve()
        preset_values, passes, textures = parse_preset(preset_path)
        source_paths.append(preset_path)
        try:
            source_preset = preset_path.relative_to(shader_root).as_posix()
        except ValueError:
            source_preset = preset_path.name
        del preset_values
    else:
        passes = [
            {
                "shader": str((shader_root / pass_info["shader"]).resolve()),
                "filter_linear": "",
                "wrap_mode": "",
                "mipmap_input": "",
                "scale_type": "",
                "scale_type_x": "",
                "scale_type_y": "",
                "scale": "",
                "scale_x": "",
                "scale_y": "",
                "alias": "",
                "float_framebuffer": "",
                "srgb_framebuffer": "",
            }
            for pass_info in bundle["passes"]
        ]

    textures = copy_textures(bundle_dir, textures, source_paths)

    lut_names = {texture["name"] for texture in textures}
    alias_index = {pass_info["alias"]: i for i, pass_info in enumerate(passes)
            if pass_info.get("alias")}
    alias_names = set(alias_index)
    final_pass = len(passes) - 1

    metadata = {
        "id": bundle["id"],
        "preset": source_preset,
        "glsl_to_spirv": tool_version(args.glsl_to_spirv),
        "shadercross": tool_version(args.shadercross),
        "spirv_cross": tool_version(args.spirv_cross),
        "textures": textures,
        "passes": [],
    }

    for idx, pass_info in enumerate(passes):
        shader_path = Path(pass_info["shader"]).resolve()
        deps = []
        resolved = resolve_includes(shader_path, shader_root, [], deps)
        source_paths.extend(deps)

        stages = split_stages(resolved, shader_path)
        params = extract_parameters(resolved)
        param_names = {param["name"] for param in params}
        pass_metadata = {
            "index": idx,
            "source": str(shader_path.relative_to(shader_root)),
            "params": [],
            "stages": [],
        }
        pass_metadata.update({
            key: pass_info.get(key, "")
            for key in ("filter_linear", "wrap_mode", "mipmap_input",
                        "scale_type", "scale_type_x", "scale_type_y",
                        "scale", "scale_x", "scale_y", "alias",
                    "float_framebuffer", "srgb_framebuffer")
        })

        layout_by_param = {}
        for stage, stage_source in stages.items():
            stage_source = rewrite_resource_sets_for_sdl_gpu(stage_source, stage)
            stage_source_path = work_dir / f"pass{idx}.{stage}.slang"
            stage_source_path.write_text(stage_source, encoding="utf-8")

            spv_path = bundle_dir / "vulkan" / f"pass{idx}.{stage}.spv"
            msl_path = bundle_dir / "metal" / f"pass{idx}.{stage}.msl"
            dxil_path = bundle_dir / "direct3d12" / f"pass{idx}.{stage}.dxil"
            json_path = work_dir / f"pass{idx}.{stage}.shadercross.json"

            compile_spirv(args.glsl_to_spirv, stage, stage_source_path, spv_path)
            resource_json = shadercross_json(args.shadercross, stage, spv_path,
                    json_path)
            shadercross_msl(args.shadercross, stage, spv_path, msl_path)
            shadercross_dxil(args.shadercross, stage, spv_path, dxil_path)

            reflect_json = spirv_cross_reflect(args.spirv_cross, spv_path)
            offsets = reflected_offsets(reflect_json, stage)
            for name, layout in offsets.items():
                layout_by_param[name] = merge_param_layout(
                        layout_by_param.get(name), layout, bundle["id"], idx,
                        name)

            uniform_slots = reflected_uniform_slots(reflect_json)
            # The slang->SDL rewrite pins the UBO block to binding 0 and the
            # Push block to binding 1.  When a stage uses only Push (no UBO),
            # binding 0 is an unused gap, yet SDL_CreateGPUShader still needs
            # num_uniform_buffers to span the highest occupied slot, so derive
            # the count from the reflected slots rather than the raw tool count.
            highest_slot = max(uniform_slots["common_uniform_slot"],
                    uniform_slots["param_uniform_slot"])
            uniform_buffers = highest_slot + 1 if highest_slot >= 0 else 0
            sampler_list = reflected_samplers(reflect_json, bundle["id"], idx,
                    stage)

            # Resolve every reflected sampler name against the built-in
            # semantics, aliases, and LUTs now so an unresolved sampler fails
            # the build rather than only faulting at runtime.
            for sampler in sampler_list:
                if not sampler_name_resolves(sampler["name"], len(passes),
                        alias_names, lut_names):
                    raise RuntimeError(
                        f'{bundle["id"]}: pass {idx}: {stage} stage samples '
                        f'unresolved texture {sampler["name"]}')
            # num_samplers spans the highest reflected slot (gaps are filled by
            # dummy bindings at runtime).  shadercross may report fewer because
            # it dead-code-eliminates declared-but-unused samplers; that is fine.
            sampler_count = sampler_slot_count(sampler_list)
            shadercross_samplers = int(resource_json.get("samplers", 0))
            if shadercross_samplers != sampler_count:
                print(f'  note: {bundle["id"]} pass {idx} {stage}: '
                      f'shadercross sees {shadercross_samplers} sampler(s), '
                      f'reflection spans {sampler_count} slot(s)')

            pass_metadata["stages"].append({
                "stage": stage,
                "spirv": rel_to_bundle(spv_path, bundle_dir),
                "msl": rel_to_bundle(msl_path, bundle_dir),
                "dxil": rel_to_bundle(dxil_path, bundle_dir),
                "samplers": sampler_count,
                "sampler_list": sampler_list,
                "storage_textures": int(resource_json.get("storage_textures", 0)),
                "storage_buffers": int(resource_json.get("storage_buffers", 0)),
                "uniform_buffers": uniform_buffers,
                "common_uniform_slot": uniform_slots["common_uniform_slot"],
                "common_uniform_size": uniform_slots["common_uniform_size"],
                "param_uniform_slot": uniform_slots["param_uniform_slot"],
                "param_uniform_size": uniform_slots["param_uniform_size"],
                "uniforms": reflected_semantic_uniforms(reflect_json, stage,
                        len(passes), alias_names, lut_names, param_names,
                        bundle["id"], idx),
            })

        current_group = ""
        pending_divider = False
        for param in params:
            if is_section_header(param):
                current_group = clean_header(param.get("label", ""))
                pending_divider = False
                continue
            if is_separator(param):
                pending_divider = True
                continue

            if param["name"] not in layout_by_param:
                if (args.allow_unused_params or
                        bundle.get("allow_unused_params")):
                    print(f'  note: {bundle["id"]} pass {idx}: skipping '
                          f'unreflected parameter {param["name"]}')
                    continue
                raise RuntimeError(
                    f'{bundle["id"]}: pass {idx}: parameter '
                    f'{param["name"]} has no reflected uniform offset')
            param["layout"] = layout_by_param[param["name"]]
            if current_group:
                param["group"] = current_group
            if pending_divider:
                param["divider"] = True
            pending_divider = False
            pass_metadata["params"].append(param)

        metadata["passes"].append(pass_metadata)

    metadata["source_hash"] = hash_sources(source_paths)
    write_bundle_toml(bundle_dir, metadata)
    copy_sources(bundle_dir, shader_root, source_paths)
    write_license_notes(bundle_dir, bundle["id"], shader_root, source_paths)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--slang-shaders-dir", type=Path, required=True)
    parser.add_argument("--fixtures-dir", type=Path,
            default=Path(__file__).resolve().parent / "fixtures")
    parser.add_argument("--output-dir", type=Path, required=True)
    parser.add_argument("--fixture-output-dir", type=Path)
    parser.add_argument("--work-dir", type=Path, required=True)
    parser.add_argument("--repo-root", type=Path,
            default=Path(__file__).resolve().parents[2])
    parser.add_argument("--glsl-to-spirv", required=True)
    parser.add_argument("--shadercross", required=True)
    parser.add_argument("--spirv-cross", required=True)
    parser.add_argument("--extra-bundle", action="append",
            type=parse_extra_bundle, default=[],
            help="Add an external preset bundle as id=/path/to/preset.slangp")
    parser.add_argument("--only", action="append", default=[],
            help="Build only the named bundle id; may be repeated")
    parser.add_argument("--crt-guest-advanced-dir", type=Path,
            help="Optional path to a pinned CRT-Guest-Advanced release tree")
    parser.add_argument("--allow-unused-params", action="store_true",
            help="Skip #pragma parameters that are not present in reflection")
    args = parser.parse_args()

    if args.fixture_output_dir is None:
        args.fixture_output_dir = args.output_dir

    args.repo_root = args.repo_root.resolve()
    args.output_dir.mkdir(parents=True, exist_ok=True)
    args.fixture_output_dir.mkdir(parents=True, exist_ok=True)
    args.work_dir.mkdir(parents=True, exist_ok=True)
    prepare_shadercross_environment()

    bundles = BUNDLES + args.extra_bundle
    only = set(args.only)
    for bundle in bundles:
        if only and bundle["id"] not in only:
            continue
        print(f"Rebuilding shader bundle: {bundle['id']}")
        build_bundle(args, bundle)


if __name__ == "__main__":
    main()
