"""Tests for kiln.printers.bambu_3mf — Bambu Lab 3MF packaging.

Covers:
- BambuPrintSettings dataclass defaults and custom values
- Bambu3MFResult dataclass and to_dict
- Template loading (lazy singletons with mocked file reads)
- _resolve_start_gcode() — parametric temp substitution vs fixed temps
- _resolve_end_gcode() — safe Z-height regex substitution
- _count_layers() — LAYER_CHANGE marker counting
- _find_max_z() — Z-height extraction from PrusaSlicer comments
- _postprocess_prusa_body() — init stripping + layer tracking injection
- _build_gcode_header() — header block generation
- _build_slice_info() — XML metadata generation
- _build_plate_json() — JSON plate metadata
- build_bambu_3mf() — end-to-end 3MF packaging
- Edge cases: empty body, no layers, missing templates, source 3MF extraction
- _reset_cache() — singleton cache clearing
"""

from __future__ import annotations

import hashlib
import json
import os
import zipfile
from pathlib import Path
from unittest.mock import patch

import pytest

from kiln.printers.bambu_3mf import (
    Bambu3MFResult,
    BambuPrintSettings,
    _build_gcode_header,
    _build_plate_json,
    _build_slice_info,
    _count_layers,
    _find_max_z,
    _postprocess_prusa_body,
    _reset_cache,
    _resolve_end_gcode,
    _resolve_start_gcode,
    build_bambu_3mf,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture(autouse=True)
def _clear_singleton_cache():
    """Reset lazy-loaded template singletons between tests."""
    _reset_cache()
    yield
    _reset_cache()


# Minimal PrusaSlicer-like gcode body with two layers.
MINIMAL_GCODE_BODY = """\
; generated by PrusaSlicer
M83
G28
M104 S200
M140 S60

;BEFORE_LAYER_CHANGE
;Z:0.2
;LAYER_CHANGE
G1 Z0.2 F600
G1 X10 Y10 E0.5

;BEFORE_LAYER_CHANGE
;Z:0.4
;LAYER_CHANGE
G1 Z0.4 F600
G1 X20 Y20 E0.5
"""

FAKE_START_GCODE = """\
; BambuStudio A1 start gcode
M104 S220
M140 S65
M109 S220
M190 S65
M104 S140
M104 S170
M104 S250
G1 Z25 F600
; set_filament_type:PLA
; set_filament_type:UNKNOWN
"""

FAKE_END_GCODE = """\
; End gcode
G1 Z65.0 F900
M104 S0
M140 S0
"""


# ---------------------------------------------------------------------------
# BambuPrintSettings tests
# ---------------------------------------------------------------------------

class TestBambuPrintSettingsDefaults:
    """BambuPrintSettings dataclass default values and to_dict."""

    def test_default_values(self):
        s = BambuPrintSettings()
        assert s.hotend_temp == 220
        assert s.bed_temp == 65
        assert s.filament_type == "PLA"
        assert s.filament_color == "#FFFFFF"
        assert s.nozzle_diameter == 0.4
        assert s.layer_height == 0.2
        assert s.bed_type == "textured_plate"
        assert s.model_name == "model"

    def test_custom_values(self):
        s = BambuPrintSettings(
            hotend_temp=250,
            bed_temp=90,
            filament_type="ABS",
            filament_color="#FF0000",
            nozzle_diameter=0.6,
            layer_height=0.3,
            bed_type="cool_plate",
            model_name="my_model",
        )
        assert s.hotend_temp == 250
        assert s.bed_temp == 90
        assert s.filament_type == "ABS"
        assert s.filament_color == "#FF0000"
        assert s.nozzle_diameter == 0.6
        assert s.layer_height == 0.3
        assert s.bed_type == "cool_plate"
        assert s.model_name == "my_model"

    def test_to_dict_keys(self):
        d = BambuPrintSettings().to_dict()
        expected_keys = {
            "hotend_temp", "bed_temp", "filament_type", "filament_color",
            "nozzle_diameter", "layer_height", "bed_type", "model_name",
        }
        assert set(d.keys()) == expected_keys

    def test_to_dict_values_match_fields(self):
        s = BambuPrintSettings(hotend_temp=240, filament_type="PETG")
        d = s.to_dict()
        assert d["hotend_temp"] == 240
        assert d["filament_type"] == "PETG"
        assert d["bed_temp"] == 65  # default


# ---------------------------------------------------------------------------
# Bambu3MFResult tests
# ---------------------------------------------------------------------------

class TestBambu3MFResult:
    """Bambu3MFResult dataclass and to_dict."""

    def test_to_dict_all_fields(self):
        r = Bambu3MFResult(
            output_path="/tmp/test.3mf",
            total_layers=10,
            max_z=2.0,
            file_size=1234,
            md5="abc123",
            est_print_time_sec=300,
        )
        d = r.to_dict()
        assert d["output_path"] == "/tmp/test.3mf"
        assert d["total_layers"] == 10
        assert d["max_z"] == 2.0
        assert d["file_size"] == 1234
        assert d["md5"] == "abc123"
        assert d["est_print_time_sec"] == 300


# ---------------------------------------------------------------------------
# Template loading tests
# ---------------------------------------------------------------------------

class TestTemplateLoading:
    """_load_a1_start_gcode / _load_a1_end_gcode with mocked filesystem."""

    def test_load_start_gcode_reads_file(self):
        from kiln.printers.bambu_3mf import _load_a1_start_gcode
        with patch("kiln.printers.bambu_3mf._A1_START_GCODE_PATH") as mock_path:
            mock_path.is_file.return_value = True
            mock_path.read_text.return_value = "G28\nM104 S220\n"
            result = _load_a1_start_gcode()
            assert result == "G28\nM104 S220\n"
            mock_path.read_text.assert_called_once_with(encoding="utf-8")

    def test_load_start_gcode_caches(self):
        from kiln.printers.bambu_3mf import _load_a1_start_gcode
        with patch("kiln.printers.bambu_3mf._A1_START_GCODE_PATH") as mock_path:
            mock_path.is_file.return_value = True
            mock_path.read_text.return_value = "cached content"
            result1 = _load_a1_start_gcode()
            result2 = _load_a1_start_gcode()
            assert result1 == result2
            # Should only read once due to singleton caching.
            mock_path.read_text.assert_called_once()

    def test_load_start_gcode_missing_file_raises(self):
        from kiln.printers.bambu_3mf import _load_a1_start_gcode
        with patch("kiln.printers.bambu_3mf._A1_START_GCODE_PATH") as mock_path:
            mock_path.is_file.return_value = False
            mock_path.__str__ = lambda self: "/fake/path/start.gcode"
            with pytest.raises(FileNotFoundError, match="start gcode not found"):
                _load_a1_start_gcode()

    def test_load_end_gcode_reads_file(self):
        from kiln.printers.bambu_3mf import _load_a1_end_gcode
        with patch("kiln.printers.bambu_3mf._A1_END_GCODE_PATH") as mock_path:
            mock_path.is_file.return_value = True
            mock_path.read_text.return_value = "M104 S0\n"
            result = _load_a1_end_gcode()
            assert result == "M104 S0\n"

    def test_load_end_gcode_missing_file_raises(self):
        from kiln.printers.bambu_3mf import _load_a1_end_gcode
        with patch("kiln.printers.bambu_3mf._A1_END_GCODE_PATH") as mock_path:
            mock_path.is_file.return_value = False
            mock_path.__str__ = lambda self: "/fake/path/end.gcode"
            with pytest.raises(FileNotFoundError, match="end gcode not found"):
                _load_a1_end_gcode()


# ---------------------------------------------------------------------------
# _resolve_start_gcode tests
# ---------------------------------------------------------------------------

class TestResolveStartGcode:
    """Temperature substitution in start gcode templates.

    220°C and 65°C are parametric (PLA defaults).
    140°C, 170°C, 250°C, 25°C are fixed init temps that must NOT change.
    """

    def test_default_temps_unchanged(self):
        result = _resolve_start_gcode(
            FAKE_START_GCODE, hotend_temp=220, bed_temp=65
        )
        assert "M104 S220" in result
        assert "M140 S65" in result

    def test_custom_hotend_temp_replaces_220(self):
        result = _resolve_start_gcode(
            FAKE_START_GCODE, hotend_temp=250, bed_temp=65
        )
        assert "M104 S250" in result
        assert "M109 S250" in result

    def test_custom_bed_temp_replaces_65(self):
        result = _resolve_start_gcode(
            FAKE_START_GCODE, hotend_temp=220, bed_temp=90
        )
        assert "M140 S90" in result
        assert "M190 S90" in result

    def test_fixed_temp_140_preserved(self):
        result = _resolve_start_gcode(
            FAKE_START_GCODE, hotend_temp=250, bed_temp=90
        )
        assert "M104 S140" in result

    def test_fixed_temp_170_preserved(self):
        result = _resolve_start_gcode(
            FAKE_START_GCODE, hotend_temp=250, bed_temp=90
        )
        assert "M104 S170" in result

    def test_fixed_temp_250_preserved_when_hotend_not_250(self):
        # When hotend IS 250, the M104 S250 line should be left alone
        # (it's a fixed init line, not a parametric one).  But since
        # M104 S250 only gets replaced when hotend_temp != 220 and the
        # line starts with M104 S220, the fixed 250 line is never matched.
        result = _resolve_start_gcode(
            FAKE_START_GCODE, hotend_temp=230, bed_temp=65
        )
        assert "M104 S250" in result

    def test_filament_type_replacement(self):
        result = _resolve_start_gcode(
            FAKE_START_GCODE, filament_type="PETG"
        )
        assert "set_filament_type:PETG" in result
        assert "set_filament_type:UNKNOWN" in result  # UNKNOWN preserved

    def test_filament_type_pla_unchanged(self):
        result = _resolve_start_gcode(
            FAKE_START_GCODE, filament_type="PLA"
        )
        assert "set_filament_type:PLA" in result

    def test_empty_template(self):
        result = _resolve_start_gcode("", hotend_temp=250, bed_temp=90)
        assert result == ""

    def test_both_temps_changed(self):
        result = _resolve_start_gcode(
            FAKE_START_GCODE, hotend_temp=240, bed_temp=80
        )
        assert "M104 S240" in result
        assert "M109 S240" in result
        assert "M140 S80" in result
        assert "M190 S80" in result
        # Fixed temps still present.
        assert "M104 S140" in result
        assert "M104 S170" in result
        assert "M104 S250" in result


# ---------------------------------------------------------------------------
# _resolve_end_gcode tests
# ---------------------------------------------------------------------------

class TestResolveEndGcode:
    """Z-height substitution in end gcode template."""

    def test_default_z_adjustment(self):
        result = _resolve_end_gcode(FAKE_END_GCODE, max_z=65.0)
        # safe_z = 65.0 + 5.0 = 70.0
        assert "G1 Z70.0 F900" in result

    def test_custom_z_height(self):
        result = _resolve_end_gcode(FAKE_END_GCODE, max_z=20.0)
        # safe_z = 20.0 + 5.0 = 25.0
        assert "G1 Z25.0 F900" in result

    def test_only_first_z_f900_replaced(self):
        template = "G1 Z65.0 F900\nG1 Z10.0 F900\n"
        result = _resolve_end_gcode(template, max_z=30.0)
        # Only the first match replaced.
        assert "G1 Z35.0 F900" in result
        assert "G1 Z10.0 F900" in result

    def test_non_matching_lines_preserved(self):
        result = _resolve_end_gcode(FAKE_END_GCODE, max_z=10.0)
        assert "M104 S0" in result
        assert "M140 S0" in result

    def test_z_with_integer_in_template(self):
        template = "G1 Z50 F900\n"
        result = _resolve_end_gcode(template, max_z=40.0)
        assert "G1 Z45.0 F900" in result


# ---------------------------------------------------------------------------
# _count_layers / _find_max_z tests
# ---------------------------------------------------------------------------

class TestCountLayers:
    """Layer counting from ;LAYER_CHANGE markers."""

    def test_two_layers(self):
        assert _count_layers(MINIMAL_GCODE_BODY) == 2

    def test_no_layers(self):
        assert _count_layers("G28\nG1 X10\n") == 0

    def test_empty_body(self):
        assert _count_layers("") == 0

    def test_inline_layer_change_not_counted(self):
        # ;LAYER_CHANGE must be at start of line
        body = "some text ;LAYER_CHANGE\n"
        assert _count_layers(body) == 0

    def test_many_layers(self):
        body = "\n".join(f";LAYER_CHANGE\nG1 Z{i * 0.2}" for i in range(100))
        assert _count_layers(body) == 100


class TestFindMaxZ:
    """Z-height extraction from ;Z: comments."""

    def test_finds_max_z(self):
        assert _find_max_z(MINIMAL_GCODE_BODY) == 0.4

    def test_no_z_comments_returns_default(self):
        assert _find_max_z("G28\nG1 X10\n") == 10.0

    def test_empty_body_returns_default(self):
        assert _find_max_z("") == 10.0

    def test_multiple_z_values(self):
        body = ";Z:0.2\n;Z:5.0\n;Z:3.0\n;Z:10.5\n"
        assert _find_max_z(body) == 10.5

    def test_integer_z_value(self):
        body = ";Z:10\n"
        assert _find_max_z(body) == 10.0


# ---------------------------------------------------------------------------
# _postprocess_prusa_body tests
# ---------------------------------------------------------------------------

class TestPostprocessPrusaBody:
    """PrusaSlicer gcode post-processing: init stripping + layer tracking."""

    def test_strips_prusa_init_commands(self):
        result = _postprocess_prusa_body(
            MINIMAL_GCODE_BODY, total_layers=2, est_time_sec=60
        )
        lines = result.split("\n")
        # M83, G28, M104, M140 from the header should be stripped.
        stripped_commands = [line.strip() for line in lines]
        assert "M83" not in stripped_commands
        assert "G28" not in stripped_commands

    def test_preserves_comments_in_header(self):
        result = _postprocess_prusa_body(
            MINIMAL_GCODE_BODY, total_layers=2, est_time_sec=60
        )
        assert "; generated by PrusaSlicer" in result

    def test_injects_m73_layer_tracking(self):
        result = _postprocess_prusa_body(
            MINIMAL_GCODE_BODY, total_layers=2, est_time_sec=60
        )
        assert "M73 L1" in result
        assert "M73 L2" in result

    def test_injects_m991_layer_notification(self):
        result = _postprocess_prusa_body(
            MINIMAL_GCODE_BODY, total_layers=2, est_time_sec=60
        )
        assert "M991 S0 P0" in result

    def test_injects_progress_percentage(self):
        result = _postprocess_prusa_body(
            MINIMAL_GCODE_BODY, total_layers=2, est_time_sec=120
        )
        # Layer 1 of 2 = 50%, layer 2 of 2 = 99% (capped)
        assert "M73 P50" in result
        assert "M73 P99" in result

    def test_progress_never_exceeds_99(self):
        # Single layer — 1/1*100 = 100, but capped at 99
        body = ";LAYER_CHANGE\nG1 Z0.2\n"
        result = _postprocess_prusa_body(
            body, total_layers=1, est_time_sec=60
        )
        assert "M73 P99" in result
        assert "M73 P100" not in result

    def test_remaining_time_in_minutes(self):
        result = _postprocess_prusa_body(
            MINIMAL_GCODE_BODY, total_layers=2, est_time_sec=600
        )
        # At layer 1: remaining = (2-1)*600/2 = 300 sec = 5 min
        assert "R5" in result

    def test_remaining_time_minimum_one_minute(self):
        result = _postprocess_prusa_body(
            MINIMAL_GCODE_BODY, total_layers=2, est_time_sec=60
        )
        # At layer 2: remaining sec = max(60, (2-2)*60/2) = max(60,0) = 60 → 1 min
        lines = result.split("\n")
        # Last progress line for layer 2 should have R1
        r_values = [line for line in lines if line.startswith("M73 P") and "R" in line]
        assert any("R1" in v for v in r_values)

    def test_preserves_gcode_after_header(self):
        result = _postprocess_prusa_body(
            MINIMAL_GCODE_BODY, total_layers=2, est_time_sec=60
        )
        assert "G1 X10 Y10 E0.5" in result
        assert "G1 X20 Y20 E0.5" in result

    def test_layer_comment_format(self):
        result = _postprocess_prusa_body(
            MINIMAL_GCODE_BODY, total_layers=2, est_time_sec=60
        )
        assert "; layer num/total_layer_count: 1/2" in result
        assert "; layer num/total_layer_count: 2/2" in result
        assert "; update layer progress" in result

    def test_no_layer_change_markers(self):
        body = "; just comments\nG1 X10\n"
        result = _postprocess_prusa_body(
            body, total_layers=0, est_time_sec=0
        )
        # No injection, body passes through.
        assert "M73 L" not in result
        assert "G1 X10" in result

    def test_empty_body(self):
        result = _postprocess_prusa_body(
            "", total_layers=0, est_time_sec=0
        )
        assert result == ""

    def test_non_init_gcode_before_first_layer_preserved(self):
        """Non-init commands before the first LAYER_CHANGE should be kept."""
        body = "G92 E0\nG1 F1800\n;LAYER_CHANGE\nG1 Z0.2\n"
        result = _postprocess_prusa_body(
            body, total_layers=1, est_time_sec=30
        )
        # G92 is in _skip_prefixes so it IS stripped.
        # G1 F1800 is NOT in _skip_prefixes, so it should be kept.
        assert "G1 F1800" in result


# ---------------------------------------------------------------------------
# _build_gcode_header tests
# ---------------------------------------------------------------------------

class TestBuildGcodeHeader:
    """Gcode header block generation."""

    def test_contains_header_markers(self):
        h = _build_gcode_header(
            total_layers=10, max_z=2.0, est_print_time_sec=300
        )
        assert "; HEADER_BLOCK_START" in h
        assert "; HEADER_BLOCK_END" in h
        assert "; CONFIG_BLOCK_START" in h
        assert "; CONFIG_BLOCK_END" in h

    def test_contains_layer_count(self):
        h = _build_gcode_header(
            total_layers=42, max_z=8.4, est_print_time_sec=600
        )
        assert "; total layer number: 42" in h

    def test_contains_max_z(self):
        h = _build_gcode_header(
            total_layers=10, max_z=15.50, est_print_time_sec=300
        )
        assert "; max_z_height: 15.50" in h

    def test_contains_filament_type(self):
        h = _build_gcode_header(
            total_layers=10, max_z=2.0, est_print_time_sec=300,
            filament_type="PETG",
        )
        assert "; filament_type = PETG" in h

    def test_contains_temperatures(self):
        h = _build_gcode_header(
            total_layers=10, max_z=2.0, est_print_time_sec=300,
            hotend_temp=245, bed_temp=85,
        )
        assert "; temperature = 245" in h
        assert "; bed_temperature = 85" in h

    def test_time_formatting(self):
        # 3723 seconds = 1h 2m 3s
        h = _build_gcode_header(
            total_layers=10, max_z=2.0, est_print_time_sec=3723
        )
        assert "1h 2m 3s" in h

    def test_nozzle_diameter(self):
        h = _build_gcode_header(
            total_layers=10, max_z=2.0, est_print_time_sec=300,
            nozzle_diameter=0.6,
        )
        assert "; nozzle_diameter = 0.6" in h

    def test_bambu_studio_version(self):
        h = _build_gcode_header(
            total_layers=10, max_z=2.0, est_print_time_sec=300
        )
        assert "BambuStudio 02.05.00.66" in h


# ---------------------------------------------------------------------------
# _build_slice_info tests
# ---------------------------------------------------------------------------

class TestBuildSliceInfo:
    """XML slice_info.config generation."""

    def test_valid_xml_structure(self):
        xml = _build_slice_info(
            total_layers=10, est_print_time_sec=300
        )
        assert xml.startswith('<?xml version="1.0"')
        assert "<config>" in xml
        assert "</config>" in xml
        assert "<plate>" in xml

    def test_contains_prediction(self):
        xml = _build_slice_info(
            total_layers=10, est_print_time_sec=600
        )
        assert 'key="prediction" value="600"' in xml

    def test_contains_filament_type(self):
        xml = _build_slice_info(
            total_layers=10, est_print_time_sec=300,
            filament_type="ABS",
        )
        assert 'type="ABS"' in xml

    def test_contains_filament_color(self):
        xml = _build_slice_info(
            total_layers=10, est_print_time_sec=300,
            filament_color="#FF0000",
        )
        assert 'color="#FF0000"' in xml

    def test_contains_model_name(self):
        xml = _build_slice_info(
            total_layers=10, est_print_time_sec=300,
            model_name="benchy",
        )
        assert 'name="benchy"' in xml

    def test_layer_range_uses_total_layers(self):
        xml = _build_slice_info(
            total_layers=50, est_print_time_sec=1500
        )
        assert 'layer_ranges="0 49"' in xml

    def test_nozzle_diameter_in_filament(self):
        xml = _build_slice_info(
            total_layers=10, est_print_time_sec=300,
            nozzle_diameter=0.6,
        )
        assert 'nozzle_diameter="0.60"' in xml

    def test_printer_model_id(self):
        xml = _build_slice_info(
            total_layers=10, est_print_time_sec=300
        )
        assert 'printer_model_id" value="N2S"' in xml


# ---------------------------------------------------------------------------
# _build_plate_json tests
# ---------------------------------------------------------------------------

class TestBuildPlateJson:
    """JSON plate metadata generation."""

    def test_valid_json(self):
        raw = _build_plate_json()
        data = json.loads(raw)
        assert isinstance(data, dict)

    def test_default_values(self):
        data = json.loads(_build_plate_json())
        assert data["bed_type"] == "textured_plate"
        assert data["nozzle_diameter"] == 0.4
        assert data["filament_colors"] == ["#FFFFFF"]
        assert data["version"] == 2
        assert data["is_seq_print"] is False

    def test_custom_values(self):
        data = json.loads(_build_plate_json(
            filament_color="#00FF00",
            nozzle_diameter=0.6,
            bed_type="cool_plate",
            first_layer_time=120.0,
        ))
        assert data["filament_colors"] == ["#00FF00"]
        assert data["nozzle_diameter"] == 0.6
        assert data["bed_type"] == "cool_plate"
        assert data["first_layer_time"] == 120.0

    def test_bbox_all_present(self):
        data = json.loads(_build_plate_json())
        assert "bbox_all" in data
        assert len(data["bbox_all"]) == 4

    def test_filament_ids(self):
        data = json.loads(_build_plate_json())
        assert data["filament_ids"] == [0]
        assert data["first_extruder"] == 0


# ---------------------------------------------------------------------------
# build_bambu_3mf — end-to-end tests
# ---------------------------------------------------------------------------

class TestBuildBambu3mf:
    """End-to-end 3MF building with mocked template loading."""

    def _mock_templates(self):
        """Return patches for start and end gcode loading."""
        return (
            patch(
                "kiln.printers.bambu_3mf._load_a1_start_gcode",
                return_value=FAKE_START_GCODE,
            ),
            patch(
                "kiln.printers.bambu_3mf._load_a1_end_gcode",
                return_value=FAKE_END_GCODE,
            ),
        )

    def test_produces_valid_zip(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(MINIMAL_GCODE_BODY, out)
        assert zipfile.is_zipfile(out)

    def test_contains_required_files(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(MINIMAL_GCODE_BODY, out)
        with zipfile.ZipFile(out) as zf:
            names = zf.namelist()
            assert "[Content_Types].xml" in names
            assert "_rels/.rels" in names
            assert "3D/3dmodel.model" in names
            assert "Metadata/plate_1.gcode" in names
            assert "Metadata/plate_1.gcode.md5" in names
            assert "Metadata/slice_info.config" in names
            assert "Metadata/plate_1.json" in names
            assert "Metadata/model_settings.config" in names
            assert "Metadata/cut_information.xml" in names
            assert "Metadata/filament_sequence.json" in names
            assert "Metadata/project_settings.config" in names

    def test_gcode_md5_matches_content(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(MINIMAL_GCODE_BODY, out)
        with zipfile.ZipFile(out) as zf:
            gcode = zf.read("Metadata/plate_1.gcode")
            stored_md5 = zf.read("Metadata/plate_1.gcode.md5").decode("utf-8")
            assert hashlib.md5(gcode).hexdigest() == stored_md5

    def test_result_metadata(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            result = build_bambu_3mf(MINIMAL_GCODE_BODY, out)
        assert result.output_path == out
        assert result.total_layers == 2
        assert result.max_z == 0.4
        assert result.file_size > 0
        assert len(result.md5) == 32  # MD5 hex digest
        assert result.est_print_time_sec == 12  # 2 layers * 6s (fallback when no slicer estimate)

    def test_result_file_md5_matches(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            result = build_bambu_3mf(MINIMAL_GCODE_BODY, out)
        actual_md5 = hashlib.md5(Path(out).read_bytes()).hexdigest()
        assert result.md5 == actual_md5

    def test_custom_settings_applied(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        settings = BambuPrintSettings(
            hotend_temp=250,
            bed_temp=90,
            filament_type="ABS",
            filament_color="#FF0000",
            model_name="benchy",
        )
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(MINIMAL_GCODE_BODY, out, settings=settings)
        with zipfile.ZipFile(out) as zf:
            gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
            assert "; filament_type = ABS" in gcode
            assert "; temperature = 250" in gcode
            assert "; bed_temperature = 90" in gcode

            slice_info = zf.read("Metadata/slice_info.config").decode("utf-8")
            assert 'type="ABS"' in slice_info
            assert 'color="#FF0000"' in slice_info
            assert 'name="benchy"' in slice_info

    def test_no_layer_change_raises_valueerror(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        body = "; just comments\nG28\n"
        p_start, p_end = self._mock_templates()
        with p_start, p_end, pytest.raises(ValueError, match="no ;LAYER_CHANGE markers"):
            build_bambu_3mf(body, out)

    def test_empty_body_raises_valueerror(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end, pytest.raises(ValueError, match="no ;LAYER_CHANGE markers"):
            build_bambu_3mf("", out)

    def test_creates_output_directory(self, tmp_path):
        out = str(tmp_path / "subdir" / "deep" / "test.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(MINIMAL_GCODE_BODY, out)
        assert os.path.isfile(out)

    def test_default_settings_used_when_none(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(MINIMAL_GCODE_BODY, out, settings=None)
        with zipfile.ZipFile(out) as zf:
            gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
            assert "; filament_type = PLA" in gcode
            assert "; temperature = 220" in gcode

    def test_gcode_contains_start_and_end(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(MINIMAL_GCODE_BODY, out)
        with zipfile.ZipFile(out) as zf:
            gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
            # Start gcode content should be present.
            assert "BambuStudio A1 start gcode" in gcode
            # End gcode content should be present.
            assert "; End gcode" in gcode

    def test_gcode_contains_layer_tracking(self, tmp_path):
        out = str(tmp_path / "test.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(MINIMAL_GCODE_BODY, out)
        with zipfile.ZipFile(out) as zf:
            gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
            assert "M73 L1" in gcode
            assert "M73 L2" in gcode
            assert "M991 S0 P0" in gcode


# ---------------------------------------------------------------------------
# Source 3MF extraction tests
# ---------------------------------------------------------------------------

class TestBuildBambu3mfSourceExtraction:
    """Thumbnail and geometry extraction from a source 3MF."""

    def _mock_templates(self):
        return (
            patch(
                "kiln.printers.bambu_3mf._load_a1_start_gcode",
                return_value=FAKE_START_GCODE,
            ),
            patch(
                "kiln.printers.bambu_3mf._load_a1_end_gcode",
                return_value=FAKE_END_GCODE,
            ),
        )

    def test_extracts_thumbnails_from_source(self, tmp_path):
        # Create a fake source 3MF with a thumbnail.
        source = str(tmp_path / "source.3mf")
        with zipfile.ZipFile(source, "w") as zf:
            zf.writestr("Metadata/thumbnail.png", b"fake_png_data")
            zf.writestr("3D/3dmodel.model", "<model>custom geometry</model>")

        out = str(tmp_path / "output.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(
                MINIMAL_GCODE_BODY, out, source_3mf_path=source
            )
        with zipfile.ZipFile(out) as zf:
            assert "Metadata/thumbnail.png" in zf.namelist()
            assert zf.read("Metadata/thumbnail.png") == b"fake_png_data"

    def test_extracts_3d_model_from_source(self, tmp_path):
        source = str(tmp_path / "source.3mf")
        with zipfile.ZipFile(source, "w") as zf:
            zf.writestr("3D/3dmodel.model", "<model>custom geometry</model>")

        out = str(tmp_path / "output.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(
                MINIMAL_GCODE_BODY, out, source_3mf_path=source
            )
        with zipfile.ZipFile(out) as zf:
            model = zf.read("3D/3dmodel.model").decode("utf-8")
            assert "custom geometry" in model

    def test_bad_source_3mf_uses_minimal_model(self, tmp_path):
        # Create a corrupt source file.
        source = str(tmp_path / "corrupt.3mf")
        Path(source).write_text("not a zip")

        out = str(tmp_path / "output.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(
                MINIMAL_GCODE_BODY, out, source_3mf_path=source
            )
        # Should still produce a valid 3MF with the minimal model.
        assert zipfile.is_zipfile(out)
        with zipfile.ZipFile(out) as zf:
            model = zf.read("3D/3dmodel.model").decode("utf-8")
            assert "vertex" in model  # minimal cube model

    def test_nonexistent_source_3mf_uses_minimal_model(self, tmp_path):
        out = str(tmp_path / "output.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(
                MINIMAL_GCODE_BODY, out,
                source_3mf_path="/nonexistent/source.3mf",
            )
        assert zipfile.is_zipfile(out)

    def test_no_source_3mf_uses_minimal_model(self, tmp_path):
        out = str(tmp_path / "output.3mf")
        p_start, p_end = self._mock_templates()
        with p_start, p_end:
            build_bambu_3mf(MINIMAL_GCODE_BODY, out, source_3mf_path=None)
        with zipfile.ZipFile(out) as zf:
            model = zf.read("3D/3dmodel.model").decode("utf-8")
            assert "vertex" in model


# ---------------------------------------------------------------------------
# _reset_cache tests
# ---------------------------------------------------------------------------

class TestResetCache:
    """Singleton cache clearing."""

    def test_reset_allows_reload(self):
        import kiln.printers.bambu_3mf as mod
        mod._a1_start_gcode = "cached"
        mod._a1_end_gcode = "cached"
        _reset_cache()
        assert mod._a1_start_gcode is None
        assert mod._a1_end_gcode is None
