"""Core logic for packaging G-code into Bambu Lab .gcode.3mf files.

Produces archives accepted by Bambu Connect. The format requirements are
documented in docs/gcode-3mf-format.md in the estampo repo.
"""

from __future__ import annotations

import hashlib
import io
import json
import logging
import re
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO
from xml.sax.saxutils import escape as _xml_escape_base

log = logging.getLogger(__name__)

# BambuStudio version we present in archive metadata and HTTP headers.
# Must match a real release — the cloud API validates this for request signing.
# Source: https://github.com/bambulab/BambuStudio/blob/master/src/libslic3r/ProjectTask.hpp
BAMBU_STUDIO_VERSION = "02.05.00.66"


def xml_escape(value: str) -> str:
    """Escape a string for use inside XML double-quoted attribute values."""
    return _xml_escape_base(value, {'"': "&quot;"})


# Minimum AMS slot count for a P1S (4 AMS + 1 external spool).
# All per-filament arrays in project_settings must be padded to this length.
MIN_SLOTS = 5


def pad_to_slots(items: list, min_slots: int = MIN_SLOTS) -> list:
    """Pad *items* by repeating its last element until it reaches *min_slots*.

    Returns a new list (never mutates the input).
    """
    if not items or len(items) >= min_slots:
        return list(items)
    result = list(items)
    while len(result) < min_slots:
        result.append(result[-1])
    return result


# Keys absent from OrcaSlicer CLI --min-save output but required by Bambu Connect.
_BC_REQUIRED_KEYS: dict[str, object] = {
    "bbl_use_printhost": "1",
    "default_bed_type": "",
    "filament_retract_lift_above": ["0"],
    "filament_retract_lift_below": ["0"],
    "filament_retract_lift_enforce": [""],
    "host_type": "octoprint",
    "pellet_flow_coefficient": "0",
    "pellet_modded_printer": "0",
    "printhost_authorization_type": "key",
    "printhost_ssl_ignore_revoke": "0",
    "thumbnails_format": "BTT_TFT",
}

# ---------------------------------------------------------------------------
# Static boilerplate (identical for every .gcode.3mf)
# ---------------------------------------------------------------------------

CONTENT_TYPES_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
 <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
 <Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
 <Default Extension="png" ContentType="image/png"/>
 <Default Extension="gcode" ContentType="text/x.gcode"/>
</Types>"""

RELS_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
 <Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
 <Relationship Target="/Metadata/plate_1.png" Id="rel-2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/>
 <Relationship Target="/Metadata/plate_1.png" Id="rel-4" Type="http://schemas.bambulab.com/package/2021/cover-thumbnail-middle"/>
 <Relationship Target="/Metadata/plate_1_small.png" Id="rel-5" Type="http://schemas.bambulab.com/package/2021/cover-thumbnail-small"/>
</Relationships>"""

MODEL_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:BambuStudio="http://schemas.bambulab.com/package/2021" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p">
 <metadata name="Application">{application}</metadata>
 <metadata name="BambuStudio:3mfVersion">1</metadata>
 <metadata name="Copyright"></metadata>
 <metadata name="CreationDate"></metadata>
 <metadata name="Description"></metadata>
 <metadata name="Designer"></metadata>
 <metadata name="DesignerCover"></metadata>
 <metadata name="DesignerUserId"></metadata>
 <metadata name="License"></metadata>
 <metadata name="ModificationDate"></metadata>
 <metadata name="Origin"></metadata>
 <metadata name="ProfileCover"></metadata>
 <metadata name="ProfileDescription"></metadata>
 <metadata name="ProfileTitle"></metadata>
 <metadata name="Title"></metadata>
 <resources>
 </resources>
 <build/>
</model>
"""


def _model_settings_xml(filament_maps: str, filament_volume_maps: str = "") -> str:
    """Generate Metadata/model_settings.config."""
    lines = [
        '<?xml version="1.0" encoding="UTF-8"?>',
        "<config>",
        "  <plate>",
        '    <metadata key="plater_id" value="1"/>',
        '    <metadata key="plater_name" value=""/>',
        '    <metadata key="locked" value="false"/>',
        '    <metadata key="filament_map_mode" value="Auto For Flush"/>',
        f'    <metadata key="filament_maps" value="{filament_maps}"/>',
    ]
    if filament_volume_maps:
        lines.append(f'    <metadata key="filament_volume_maps" value="{filament_volume_maps}"/>')
    lines.extend(
        [
            '    <metadata key="gcode_file" value="Metadata/plate_1.gcode"/>',
            '    <metadata key="thumbnail_file" value="Metadata/plate_1.png"/>',
            '    <metadata key="thumbnail_no_light_file" value="Metadata/plate_no_light_1.png"/>',
            '    <metadata key="top_file" value="Metadata/top_1.png"/>',
            '    <metadata key="pick_file" value="Metadata/pick_1.png"/>',
            '    <metadata key="pattern_bbox_file" value="Metadata/plate_1.json"/>',
            "  </plate>",
            "</config>",
            "",
        ]
    )
    return "\n".join(lines)


MODEL_SETTINGS_RELS_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
 <Relationship Target="/Metadata/plate_1.gcode" Id="rel-1" Type="http://schemas.bambulab.com/package/2021/gcode"/>
</Relationships>"""

# 1x1 transparent PNG (67 bytes) — minimal valid placeholder thumbnail.
_PLACEHOLDER_PNG = (
    b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
    b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
    b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
    b"\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class FilamentInfo:
    """Metadata for a single filament used in the print."""

    slot: int = 1  # 1-indexed filament slot
    tray_info_idx: str = "GFL99"  # Bambu filament ID (e.g. GFL99 = generic PLA)
    filament_type: str = "PLA"
    color: str = "#F2754E"
    used_m: float = 0.0  # metres of filament used
    used_g: float = 0.0  # grams of filament used
    # BBS 02.05+ attributes
    used_for_object: bool = True
    used_for_support: bool = False
    group_id: int = 0
    nozzle_diameter: float = 0.4
    volume_type: str = "Standard"


@dataclass
class SliceInfo:
    """Metadata about the sliced print."""

    printer_model_id: str = ""  # e.g. "BL-P001" for X1C, "C12" for P1S
    nozzle_diameter: float = 0.4
    prediction: int = 0  # estimated print time in seconds
    weight: float = 0.0  # total filament weight in grams
    outside: bool = False  # objects extend beyond bed
    support_used: bool = False
    label_object_enabled: bool = True
    timelapse_type: int = 0  # 0=traditional, 1=smooth, -1=disabled
    filaments: list[FilamentInfo] = field(default_factory=list)
    objects: list[ObjectInfo] = field(default_factory=list)
    warnings: list[WarningInfo] = field(default_factory=list)
    bed_type: str = "textured_plate"  # plate_1.json bed type
    plate_data: dict[str, object] | None = None  # raw plate_1.json passthrough
    application: str = f"BambuStudio-{BAMBU_STUDIO_VERSION}"
    model_xml: str = ""  # raw 3D/3dmodel.model passthrough (overrides application)
    # BBS 02.05+ fields
    client_version: str = BAMBU_STUDIO_VERSION
    extruder_type: int = 0
    nozzle_volume_type: int = 0
    first_layer_time: float | None = None
    filament_maps: str = ""  # override filament_maps in slice_info (e.g. "1 1 1 1 1")
    limit_filament_maps: str = "0 0 0 0 0"
    layer_filament_lists: list[dict[str, str]] | None = None  # [{filament_list, layer_ranges}]
    filament_volume_maps: str = "0 0 0 0 0"  # BBS 02.05+


def _filament_maps_str(min_slots: int = MIN_SLOTS) -> str:
    """Build padded filament_maps string for model_settings (e.g. '1 1 1 1 1').

    This is the plate mapping index (always 1 for single-plate prints), NOT
    the filament slot number.
    """
    return " ".join(["1"] * min_slots)


@dataclass
class ObjectInfo:
    """An object entry for slice_info.config."""

    identify_id: int
    name: str = ""
    skipped: bool = False


@dataclass
class WarningInfo:
    """A warning entry for slice_info.config."""

    msg: str
    level: int = 1
    error_code: str = ""


def _slice_info_xml(info: SliceInfo) -> str:
    """Generate Metadata/slice_info.config XML."""
    filament_maps = info.filament_maps or _filament_maps_str()

    lines = [
        '<?xml version="1.0" encoding="UTF-8"?>',
        "<config>",
        "  <header>",
        '    <header_item key="X-BBL-Client-Type" value="slicer"/>',
        f'    <header_item key="X-BBL-Client-Version" value="{xml_escape(info.client_version)}"/>',
        "  </header>",
        "  <plate>",
        '    <metadata key="index" value="1"/>',
    ]

    lines.append(f'    <metadata key="extruder_type" value="{info.extruder_type}"/>')
    lines.append(f'    <metadata key="nozzle_volume_type" value="{info.nozzle_volume_type}"/>')

    lines.extend(
        [
            f'    <metadata key="printer_model_id" value="{xml_escape(info.printer_model_id)}"/>',
            f'    <metadata key="nozzle_diameters" value="{info.nozzle_diameter}"/>',
            f'    <metadata key="timelapse_type" value="{info.timelapse_type}"/>',
            f'    <metadata key="prediction" value="{info.prediction}"/>',
            f'    <metadata key="weight" value="{info.weight:.2f}"/>',
        ]
    )

    if info.first_layer_time is not None:
        lines.append(f'    <metadata key="first_layer_time" value="{info.first_layer_time}"/>')

    lines.extend(
        [
            f'    <metadata key="outside" value="{str(info.outside).lower()}"/>',
            f'    <metadata key="support_used" value="{str(info.support_used).lower()}"/>',
            f'    <metadata key="label_object_enabled" value="{str(info.label_object_enabled).lower()}"/>',
            f'    <metadata key="filament_maps" value="{xml_escape(filament_maps)}"/>',
        ]
    )

    if info.limit_filament_maps:
        lines.append(
            f'    <metadata key="limit_filament_maps" value="{xml_escape(info.limit_filament_maps)}"/>'
        )

    for obj in info.objects:
        lines.append(
            f'    <object identify_id="{obj.identify_id}"'
            f' name="{xml_escape(obj.name)}" skipped="{str(obj.skipped).lower()}" />'
        )

    for f in info.filaments:
        attrs = (
            f'id="{f.slot}" tray_info_idx="{xml_escape(f.tray_info_idx)}"'
            f' type="{xml_escape(f.filament_type)}" color="{xml_escape(f.color)}"'
            f' used_m="{f.used_m:.2f}" used_g="{f.used_g:.2f}"'
            f' used_for_object="{str(f.used_for_object).lower()}"'
            f' used_for_support="{str(f.used_for_support).lower()}"'
            f' group_id="{f.group_id}"'
            f' nozzle_diameter="{f.nozzle_diameter:.2f}"'
            f' volume_type="{xml_escape(f.volume_type)}"'
        )
        lines.append(f"    <filament {attrs}/>")

    for w in info.warnings:
        lines.append(
            f'    <warning msg="{xml_escape(w.msg)}" level="{w.level}" error_code ="{xml_escape(w.error_code)}"  />'
        )

    if info.layer_filament_lists:
        lines.append("    <layer_filament_lists>")
        for lfl in info.layer_filament_lists:
            lines.append(
                f'      <layer_filament_list filament_list="{xml_escape(lfl["filament_list"])}"'
                f' layer_ranges="{xml_escape(lfl["layer_ranges"])}" />'
            )
        lines.append("    </layer_filament_lists>")

    lines.extend(
        [
            "  </plate>",
            "</config>",
            "",  # trailing newline
        ]
    )
    return "\n".join(lines)


def _plate_json(info: SliceInfo, filaments: list[FilamentInfo]) -> str:
    """Generate Metadata/plate_1.json.

    If ``info.plate_data`` is provided, it is used as the base and only missing
    keys are filled in from SliceInfo/FilamentInfo.
    """
    data: dict[str, object] = {}
    if info.plate_data:
        data.update(info.plate_data)

    # Fill in defaults for keys not already present
    data.setdefault("bed_type", info.bed_type)
    data.setdefault("filament_colors", [f.color for f in filaments] if filaments else ["#F2754E"])
    data.setdefault("filament_ids", [f.slot - 1 for f in filaments] if filaments else [0])
    data.setdefault("first_extruder", filaments[0].slot - 1 if filaments else 0)
    if info.first_layer_time is not None:
        data.setdefault("first_layer_time", info.first_layer_time)
    data.setdefault("is_seq_print", False)
    data.setdefault("nozzle_diameter", info.nozzle_diameter)
    data.setdefault("version", 2)

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


def fixup_project_settings(
    settings: dict[str, object], min_slots: int = MIN_SLOTS
) -> dict[str, object]:
    """Make project_settings Bambu Connect-ready.

    1. Add required keys that OrcaSlicer CLI ``--min-save`` omits.
    2. Pad short per-filament arrays to *min_slots* (P1S = 5).

    Called automatically by :func:`pack_gcode_3mf`. Also useful standalone
    for patching existing 3MF archives.
    """
    result = {k: (list(v) if isinstance(v, list) else v) for k, v in settings.items()}
    for key, default in _BC_REQUIRED_KEYS.items():
        if key not in result:
            result[key] = list(default) if isinstance(default, list) else default
    for key, val in result.items():
        if isinstance(val, list) and 0 < len(val) < min_slots:
            result[key] = pad_to_slots(val, min_slots)
    return result


def fixup_model_settings(xml: str, min_slots: int = MIN_SLOTS) -> str:
    """Fix model_settings.config for Bambu Connect.

    1. Pad ``filament_maps`` value to *min_slots* (e.g. ``"1"`` → ``"1 1 1 1 1"``).
    2. Add missing thumbnail/bbox metadata keys that Bambu Connect requires.
    """

    def _pad_maps(m: re.Match[str]) -> str:
        parts = m.group(1).split()
        while len(parts) < min_slots:
            parts.append(parts[-1] if parts else "1")
        return f'key="filament_maps" value="{" ".join(parts)}"'

    result = re.sub(r'key="filament_maps" value="([^"]*)"', _pad_maps, xml)

    extra_keys = {
        "thumbnail_file": "Metadata/plate_1.png",
        "thumbnail_no_light_file": "Metadata/plate_no_light_1.png",
        "top_file": "Metadata/top_1.png",
        "pick_file": "Metadata/pick_1.png",
    }
    for key, val in extra_keys.items():
        if f'key="{key}"' not in result:
            result = result.replace(
                "  </plate>",
                f'    <metadata key="{key}" value="{val}"/>\n  </plate>',
            )
    return result


def _patch_slice_info_printer_model(xml_str: str, printer_model_id: str) -> str:
    """Replace the printer_model_id value in slice_info.config XML."""
    return re.sub(
        r'(<metadata key="printer_model_id" value=")[^"]*(")',
        rf"\g<1>{printer_model_id}\g<2>",
        xml_str,
    )


def repack_3mf(
    path: Path,
    *,
    machine: str | None = None,
    filaments: list[str] | None = None,
    filament_colors: list[str] | None = None,
    min_slots: int = MIN_SLOTS,
) -> None:
    """Fix up an existing OrcaSlicer .gcode.3mf for Bambu Connect.

    Applies all BBL firmware fixups in-place:

    1. **project_settings.config** — add missing keys, pad arrays. If *machine*
       and *filaments* are given, regenerate from profiles instead of patching.
    2. **model_settings.config** — pad filament_maps, add thumbnail references.
    3. **Thumbnails** — regenerate from G-code toolpath if missing or broken
       (headless OrcaSlicer without Xvfb produces empty PNGs).

    Args:
        path: Path to the .gcode.3mf file (modified in-place).
        machine: Machine profile name for settings regeneration. If ``None``,
            existing settings are patched rather than regenerated.
        filaments: Filament type names for settings regeneration.
        filament_colors: Hex colors per filament slot.
        min_slots: Minimum slot count for per-filament arrays (default 5).
    """
    _THUMB_MIN_SIZE = 1024

    with zipfile.ZipFile(path, "r") as zin:
        # --- Fix project_settings.config ---
        try:
            ps_raw = zin.read("Metadata/project_settings.config")
        except KeyError:
            ps_raw = None

        if machine and filaments:
            from bambox.settings import build_project_settings

            ps = build_project_settings(
                filaments,
                machine=machine,
                filament_colors=filament_colors,
                min_slots=min_slots,
            )
        elif ps_raw is not None:
            ps = fixup_project_settings(json.loads(ps_raw), min_slots=min_slots)
        else:
            ps = None

        # --- Fix model_settings.config ---
        try:
            ms_raw = zin.read("Metadata/model_settings.config").decode()
            ms_patched = fixup_model_settings(ms_raw, min_slots=min_slots)
        except KeyError:
            ms_patched = None

        # --- Fix plate_1.json (bounding-box / plate metadata) ---
        _PLATE_JSON_PATH = "Metadata/plate_1.json"
        plate_json_override: str | None = None
        try:
            zin.read(_PLATE_JSON_PATH)
        except KeyError:
            # Generate a minimal plate_1.json so model_settings refs are valid
            colors = filament_colors or ["#F2754E"]
            plate_data: dict[str, object] = {
                "filament_colors": colors,
                "filament_ids": list(range(len(colors))),
                "first_extruder": 0,
                "is_seq_print": False,
                "nozzle_diameter": 0.4,
                "version": 2,
            }
            plate_json_override = json.dumps(plate_data, separators=(",", ":"))

        # Add pattern_bbox_file ref to model_settings now that we know the file
        # will exist (either already present or generated above)
        if ms_patched and 'key="pattern_bbox_file"' not in ms_patched:
            ms_patched = ms_patched.replace(
                "  </plate>",
                f'    <metadata key="pattern_bbox_file" value="{_PLATE_JSON_PATH}"/>\n  </plate>',
            )

        # --- Fix slice_info.config (printer_model_id) ---
        try:
            si_raw = zin.read("Metadata/slice_info.config").decode()
        except KeyError:
            si_raw = None
        si_patched: str | None = None
        if si_raw is not None and machine:
            from bambox.cura import PRINTER_MODEL_IDS

            model_id = PRINTER_MODEL_IDS.get(machine.lower(), "")
            if model_id:
                si_patched = _patch_slice_info_printer_model(si_raw, model_id)

        # --- Fix thumbnails ---
        thumb_files = [
            "Metadata/plate_1.png",
            "Metadata/plate_no_light_1.png",
            "Metadata/plate_1_small.png",
            "Metadata/top_1.png",
            "Metadata/pick_1.png",
        ]
        thumbnail_overrides: dict[str, bytes] = {}

        # Try to generate thumbnails from the packed G-code
        gcode_str: str | None = None
        for fname in thumb_files:
            try:
                existing = zin.read(fname)
                if len(existing) >= _THUMB_MIN_SIZE:
                    continue  # valid thumbnail
            except KeyError:
                pass
            # Need to generate — load G-code lazily
            if gcode_str is None:
                try:
                    gcode_bytes = zin.read("Metadata/plate_1.gcode")
                    gcode_str = gcode_bytes.decode(errors="replace")
                except KeyError:
                    gcode_str = ""
            if gcode_str:
                try:
                    from bambox.thumbnail import gcode_thumbnail

                    size = 128 if "small" in fname else 512
                    thumbnail_overrides[fname] = gcode_thumbnail(gcode_str, size, size)
                except Exception:
                    log.debug("Thumbnail generation failed for %s", fname, exc_info=True)
                    thumbnail_overrides[fname] = _PLACEHOLDER_PNG
            else:
                thumbnail_overrides[fname] = _PLACEHOLDER_PNG

        # --- Rewrite the archive ---
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zout:
            for item in zin.infolist():
                if item.filename in thumbnail_overrides:
                    continue  # replaced below
                elif item.filename == "Metadata/project_settings.config" and ps is not None:
                    zout.writestr(item, json.dumps(ps, indent=4) + "\n")
                elif item.filename == "Metadata/model_settings.config" and ms_patched:
                    zout.writestr(item, ms_patched)
                elif item.filename == "Metadata/slice_info.config" and si_patched:
                    zout.writestr(item, si_patched)
                else:
                    zout.writestr(item, zin.read(item.filename))

            # Write generated thumbnails
            for fname, data in thumbnail_overrides.items():
                zout.writestr(fname, data)

            # Add project_settings if it didn't exist in the original
            if ps is not None and ps_raw is None:
                zout.writestr(
                    "Metadata/project_settings.config",
                    json.dumps(ps, indent=4) + "\n",
                )

            # Add plate_1.json if it didn't exist in the original
            if plate_json_override is not None:
                zout.writestr(_PLATE_JSON_PATH, plate_json_override)

    path.write_bytes(buf.getvalue())


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------


def pack_gcode_3mf(
    gcode: bytes,
    output: Path | IO[bytes],
    *,
    slice_info: SliceInfo | None = None,
    project_settings: dict[str, object] | None = None,
    thumbnails: dict[str, bytes] | None = None,
    extra_files: dict[str, bytes] | None = None,
) -> None:
    """Package G-code bytes into a Bambu Lab .gcode.3mf archive.

    Args:
        gcode: Raw G-code bytes.
        output: Output file path or BytesIO buffer.
        slice_info: Print metadata (time, weight, filaments). Defaults are
            provided if omitted.
        project_settings: Full slicer settings dict for
            project_settings.config. Automatically fixed up for Bambu
            Connect compatibility (missing keys added, arrays padded).
            Use :func:`build_project_settings` from ``bambox.settings``
            to generate from templates.
        thumbnails: Optional dict mapping archive paths to PNG bytes, e.g.
            ``{"Metadata/plate_1.png": png_bytes}``. If not provided,
            1x1 placeholder PNGs are used.
        extra_files: Optional dict of additional archive entries, e.g.
            ``{"Metadata/top_1.png": png_bytes}``.
    """
    if slice_info is None:
        slice_info = SliceInfo()

    # Translate non-BBL G-code (CuraEngine, PrusaSlicer, etc.) to
    # Bambu-firmware-compatible format with HEADER_BLOCK, M73 L, M991.
    from bambox.gcode_compat import is_bbl_gcode, translate_to_bbl

    if not is_bbl_gcode(gcode):
        gcode = translate_to_bbl(gcode)

    # MD5 of final gcode (uppercase hex, as firmware expects)
    md5 = hashlib.md5(gcode).hexdigest().upper()

    filament_maps = _filament_maps_str()

    # Build the ZIP archive
    fh: IO[bytes]
    if isinstance(output, (str, Path)):
        fh = open(output, "wb")
        should_close = True
    else:
        fh = output
        should_close = False

    try:
        with zipfile.ZipFile(fh, "w", zipfile.ZIP_DEFLATED) as z:
            z.writestr("[Content_Types].xml", CONTENT_TYPES_XML)
            z.writestr(
                "Metadata/plate_1.json",
                _plate_json(slice_info, slice_info.filaments),
            )
            z.writestr(
                "3D/3dmodel.model",
                slice_info.model_xml or MODEL_XML.format(application=slice_info.application),
            )

            if project_settings is not None:
                fixed_ps = fixup_project_settings(project_settings)
                z.writestr(
                    "Metadata/project_settings.config",
                    json.dumps(fixed_ps, indent=4) + "\n",
                )

            z.writestr("Metadata/plate_1.gcode.md5", md5)
            z.writestr("Metadata/plate_1.gcode", gcode)
            z.writestr(
                "Metadata/_rels/model_settings.config.rels",
                MODEL_SETTINGS_RELS_XML,
            )
            z.writestr(
                "Metadata/model_settings.config",
                _model_settings_xml(filament_maps, slice_info.filament_volume_maps),
            )
            z.writestr("Metadata/slice_info.config", _slice_info_xml(slice_info))
            z.writestr("_rels/.rels", RELS_XML)

            # Thumbnails — generate from G-code toolpath if not provided
            thumb_map = thumbnails or {}
            if not thumb_map:
                try:
                    from bambox.thumbnail import gcode_thumbnail

                    gcode_str = gcode if isinstance(gcode, str) else gcode.decode(errors="replace")
                    main_png = gcode_thumbnail(gcode_str, 512, 512)
                    small_png = gcode_thumbnail(gcode_str, 128, 128)
                    thumb_map = {
                        "Metadata/plate_1.png": main_png,
                        "Metadata/plate_no_light_1.png": main_png,
                        "Metadata/plate_1_small.png": small_png,
                        "Metadata/top_1.png": main_png,
                        "Metadata/pick_1.png": main_png,
                    }
                except Exception:
                    log.debug("Thumbnail generation failed, using placeholders", exc_info=True)
            extra = extra_files or {}
            for path in [
                "Metadata/plate_1.png",
                "Metadata/plate_no_light_1.png",
                "Metadata/plate_1_small.png",
                "Metadata/top_1.png",
                "Metadata/pick_1.png",
            ]:
                if path not in extra:
                    z.writestr(path, thumb_map.get(path, _PLACEHOLDER_PNG))

            # Extra files (e.g. custom thumbnails, cut_information.xml)
            for path, data in extra.items():
                z.writestr(path, data)
    finally:
        if should_close:
            fh.close()
