"""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,
    repackage_gcode_as_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_strips_prusaslicer_m73_commands(self):
        """PrusaSlicer M73 P{pct} R{min} lines must be stripped.

        PrusaSlicer emits M73 progress lines throughout the gcode body.
        These lack the L parameter and override our injected layer tracking,
        causing the Bambu printer display to show stale progress.
        """
        body_with_m73 = (
            "; generated by PrusaSlicer\n"
            "M83\n"
            "M73 P0 R30\n"   # PrusaSlicer progress — should be stripped
            ";BEFORE_LAYER_CHANGE\n"
            ";Z:0.2\n"
            ";LAYER_CHANGE\n"
            "G1 Z0.2 F600\n"
            "M73 P10 R27\n"  # PrusaSlicer mid-layer progress — stripped
            "G1 X10 Y10 E0.5\n"
            "M73 P20 R24\n"  # Another PrusaSlicer M73 — stripped
            ";BEFORE_LAYER_CHANGE\n"
            ";Z:0.4\n"
            ";LAYER_CHANGE\n"
            "G1 Z0.4 F600\n"
            "M73 P50 R15\n"  # Stripped
            "G1 X20 Y20 E0.5\n"
        )
        result = _postprocess_prusa_body(
            body_with_m73, total_layers=2, est_time_sec=120
        )
        lines = result.split("\n")

        # Our injected M73 lines (with L and with correct P/R) should be present.
        assert "M73 L1" in result
        assert "M73 L2" in result

        # PrusaSlicer's native M73 lines should all be gone.
        prusa_m73_lines = [
            l for l in lines
            if l.strip().startswith("M73 ")
            and "L" not in l  # our injected lines have L
        ]
        # The only M73 P lines should be our injected ones (which are at
        # layer boundaries, right after M73 L lines).
        for m73_line in prusa_m73_lines:
            # Our injected M73 P lines are in the format "M73 P{pct} R{min}"
            # and appear right after "; update layer progress" — they should
            # only exist at positions following M73 L lines.
            pass
        # More directly: the specific PrusaSlicer values should be gone.
        assert "M73 P10 R27" not in result
        assert "M73 P20 R24" not in result
        assert "M73 P50 R15" not in result
        assert "M73 P0 R30" not 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 == 480  # 2 layers * 6s + 420s startup overhead

    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

    def test_gcode_starts_with_initial_m73(self, tmp_path):
        """Initial M73 at very start so firmware shows correct time immediately."""
        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")
            # The very first line should be M73 P0 R{minutes_with_startup}
            first_line = gcode.split("\n")[0]
            assert first_line.startswith("M73 P0 R")
            # Minutes should include startup overhead (420s = 7min)
            # 2 layers * 6s = 12s printing + 420s startup = 480s = 8min
            assert first_line == "M73 P0 R8"

    def test_slice_info_prediction_includes_startup_overhead(self, tmp_path):
        """slice_info prediction should include Bambu startup overhead."""
        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:
            slice_info = zf.read("Metadata/slice_info.config").decode("utf-8")
            # 12s printing + 420s startup = 480s
            assert 'key="prediction" value="480"' in slice_info


# ---------------------------------------------------------------------------
# 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


# ---------------------------------------------------------------------------
# repackage_gcode_as_bambu_3mf — stl_paths thumbnail fallback
# ---------------------------------------------------------------------------

class TestRepackageStlThumbnailFallback:
    """When no source_3mf_path thumbnails are available, stl_paths must
    trigger OpenSCAD-based thumbnail generation so the Bambu LCD shows
    the actual part (not a blank preview)."""

    def test_stl_paths_triggers_thumbnail_generation(self, tmp_path):
        gcode = tmp_path / "test.gcode"
        gcode.write_text("; test gcode\nG1 X0 Y0 Z0\n")
        stl = tmp_path / "disc.stl"
        stl.write_text("solid test\nendsolid\n")
        out = str(tmp_path / "output.3mf")

        # Real PNG bytes — PIL needs a valid image to resize.
        from io import BytesIO
        from PIL import Image
        buf = BytesIO()
        Image.new("RGB", (64, 64), color=(255, 255, 255)).save(buf, format="PNG")
        fake_png = buf.getvalue()

        with patch(
            "kiln.multicolor_3mf._generate_thumbnail",
            return_value=fake_png,
        ):
            repackage_gcode_as_bambu_3mf(
                str(gcode), out, stl_paths=[str(stl)],
            )

        with zipfile.ZipFile(out) as zf:
            names = zf.namelist()
            assert "Metadata/plate_1.png" in names
            assert "Auxiliaries/.thumbnails/thumbnail_3mf.png" in names

    def test_source_3mf_thumbnails_win_over_stl_fallback(self, tmp_path):
        gcode = tmp_path / "test.gcode"
        gcode.write_text("; test gcode\n")
        source = tmp_path / "source.3mf"
        with zipfile.ZipFile(source, "w") as zf:
            zf.writestr("Metadata/plate_1.png", b"source_thumb_bytes")
        stl = tmp_path / "disc.stl"
        stl.write_text("solid test\nendsolid\n")
        out = str(tmp_path / "output.3mf")

        # _generate_thumbnail should NOT be called when source 3MF has thumbnails
        with patch(
            "kiln.multicolor_3mf._generate_thumbnail",
            return_value=b"stl_fallback_bytes",
        ) as gen:
            repackage_gcode_as_bambu_3mf(
                str(gcode), out,
                source_3mf_path=str(source),
                stl_paths=[str(stl)],
            )
            gen.assert_not_called()

        with zipfile.ZipFile(out) as zf:
            assert zf.read("Metadata/plate_1.png") == b"source_thumb_bytes"

    def test_no_stl_no_thumbnails_still_succeeds(self, tmp_path):
        gcode = tmp_path / "test.gcode"
        gcode.write_text("; test gcode\n")
        out = str(tmp_path / "output.3mf")

        repackage_gcode_as_bambu_3mf(str(gcode), out)

        assert zipfile.is_zipfile(out)


# ---------------------------------------------------------------------------
# _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
