"""Unit tests for the filament usage tracker.

Tests 3MF-primary tracking (Path 1) and AMS remain% delta fallback
(Path 2) for spools not covered by 3MF data.
"""

from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from backend.app.services.usage_tracker import (
    PrintSession,
    _active_sessions,
    _track_from_3mf,
    on_print_complete,
    on_print_start,
)


def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
    """Create a mock Spool object."""
    spool = MagicMock()
    spool.id = id
    spool.label_weight = label_weight
    spool.weight_used = weight_used
    spool.tag_uid = tag_uid
    spool.tray_uuid = tray_uuid
    spool.last_used = None
    spool.cost_per_kg = None
    spool.material = "PLA"
    return spool


def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0, created_at=None):
    """Create a mock SpoolAssignment object."""
    assignment = MagicMock()
    assignment.spool_id = spool_id
    assignment.printer_id = printer_id
    assignment.ams_id = ams_id
    assignment.tray_id = tray_id
    assignment.created_at = created_at or datetime.now(timezone.utc)
    return assignment


def _make_printer_state(ams_data, progress=0, layer_num=0, tray_now=255):
    """Create a mock printer state with AMS data."""
    state = MagicMock()
    state.raw_data = {"ams": ams_data}
    state.progress = progress
    state.layer_num = layer_num
    state.tray_now = tray_now
    return state


def _make_printer_manager(state=None):
    """Create a mock printer manager."""
    pm = MagicMock()
    pm.get_status.return_value = state
    return pm


class TestOnPrintStart:
    """Tests for on_print_start — capturing AMS remain%."""

    @pytest.fixture(autouse=True)
    def _clear_sessions(self):
        _active_sessions.clear()
        yield
        _active_sessions.clear()

    @pytest.mark.asyncio
    async def test_creates_session_with_valid_remain(self):
        """Session created with remain% data for trays reporting 0-100."""
        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
        pm = _make_printer_manager(_make_printer_state(ams_data))

        await on_print_start(1, {"subtask_name": "test_print"}, pm)

        assert 1 in _active_sessions
        session = _active_sessions[1]
        assert session.print_name == "test_print"
        assert session.tray_remain_start == {(0, 0): 80}

    @pytest.mark.asyncio
    async def test_creates_session_even_without_valid_remain(self):
        """Session still created when remain=-1 (for 3MF fallback path)."""
        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]
        pm = _make_printer_manager(_make_printer_state(ams_data))

        await on_print_start(1, {"subtask_name": "test_print"}, pm)

        assert 1 in _active_sessions
        session = _active_sessions[1]
        assert session.tray_remain_start == {}  # Empty, no valid remain

    @pytest.mark.asyncio
    async def test_skips_without_ams_data(self):
        """No session created when no AMS data available."""
        state = MagicMock()
        state.raw_data = {"ams": []}
        pm = _make_printer_manager(state)

        await on_print_start(1, {"subtask_name": "test"}, pm)

        assert 1 not in _active_sessions


class TestOnPrintCompleteAMSDelta:
    """Tests for Path 1: AMS remain% delta tracking."""

    @pytest.fixture(autouse=True)
    def _clear_sessions(self):
        _active_sessions.clear()
        yield
        _active_sessions.clear()

    @pytest.fixture(autouse=True)
    def _mock_get_setting(self):
        with patch(
            "backend.app.api.routes.settings.get_setting",
            new_callable=AsyncMock,
            return_value=None,
        ):
            yield

    @pytest.mark.asyncio
    async def test_computes_delta_and_updates_spool(self):
        """Spool weight_used updated by remain% delta * label_weight."""
        # Set up session with start remain = 80%
        _active_sessions[1] = PrintSession(
            printer_id=1,
            print_name="test",
            started_at=datetime.now(timezone.utc),
            tray_remain_start={(0, 0): 80},
        )

        # Current remain = 70% → 10% consumed → 100g on 1000g spool
        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
        pm = _make_printer_manager(_make_printer_state(ams_data))

        spool = _make_spool(label_weight=1000, weight_used=50)
        assignment = _make_assignment()

        db = AsyncMock()
        # First 2 executes → _find_3mf_by_filename (library + archive search, uses scalars().all()),
        # then assignment, then spool for the AMS fallback path
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(),  # _find_3mf_by_filename: library search
                MagicMock(),  # _find_3mf_by_filename: archive search
                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
            ]
        )

        results = await on_print_complete(1, {"status": "completed"}, pm, db)

        assert len(results) == 1
        assert results[0]["weight_used"] == 100.0
        assert results[0]["percent_used"] == 10
        # weight_used should be old (50) + delta (100)
        assert spool.weight_used == 150.0
        db.commit.assert_called_once()

    @pytest.mark.asyncio
    async def test_skips_negative_delta(self):
        """No tracking when remain increased (spool refilled)."""
        _active_sessions[1] = PrintSession(
            printer_id=1,
            print_name="test",
            started_at=datetime.now(timezone.utc),
            tray_remain_start={(0, 0): 50},
        )

        # Remain went UP: 50 → 80 (refilled)
        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
        pm = _make_printer_manager(_make_printer_state(ams_data))
        db = AsyncMock()

        results = await on_print_complete(1, {"status": "completed"}, pm, db)

        assert results == []
        db.commit.assert_not_called()

    @pytest.mark.asyncio
    async def test_no_session_falls_through_to_3mf(self):
        """When no session exists, AMS delta path skipped (3MF may still run)."""
        pm = _make_printer_manager()
        db = AsyncMock()

        results = await on_print_complete(1, {"status": "completed"}, pm, db)

        assert results == []

    @pytest.mark.asyncio
    async def test_skips_fallback_for_trays_outside_print_mapping(self):
        """#1269: swapping a spool in an UNUSED slot mid-print must NOT charge the old spool.

        Reproduces maugsburger's report: single-color print on AMS0-T3
        (ams_mapping=[3]). User swaps spools in T1 and T2 during the print —
        those slots report remain=0 at completion (new spool with no tag).
        The fallback must skip T1 and T2 because they were never in the
        print's tray mapping or runtime tray_change_log.
        """
        _active_sessions[1] = PrintSession(
            printer_id=1,
            print_name="splitter",
            started_at=datetime.now(timezone.utc),
            tray_remain_start={(0, 1): 100, (0, 2): 17, (0, 3): 100},
            tray_now_at_start=3,
            ams_mapping=[3],
        )

        # User swapped T1 and T2 mid-print → both report remain=0 now.
        # T3 was actually used but it's also at 0 now. Without the fix the
        # fallback would charge the originally-assigned spools at T1 and T2.
        ams_data = [
            {
                "id": 0,
                "tray": [
                    {"id": 1, "remain": 0},
                    {"id": 2, "remain": 0},
                    {"id": 3, "remain": 0},
                ],
            }
        ]
        state = _make_printer_state(ams_data, tray_now=3)
        state.tray_change_log = [(3, 0)]  # only T3 was loaded during the print
        pm = _make_printer_manager(state)

        # Only T3 should reach the spool lookup; T1 and T2 must be filtered
        # out before any DB query is issued for them.
        t3_spool = _make_spool(id=8, label_weight=1000, weight_used=0)
        t3_assignment = _make_assignment(spool_id=8, ams_id=0, tray_id=3)
        db = AsyncMock()
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(),  # _find_3mf_by_filename: library search
                MagicMock(),  # _find_3mf_by_filename: archive search
                MagicMock(scalar_one_or_none=MagicMock(return_value=t3_assignment)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=t3_spool)),
            ]
        )

        results = await on_print_complete(1, {"status": "completed"}, pm, db)

        # Only T3 should be charged. T1 (spool 27 in the report) and T2
        # (spool 24) must NOT appear in the results.
        assert len(results) == 1
        assert results[0]["ams_id"] == 0
        assert results[0]["tray_id"] == 3


class TestTrackFrom3MF:
    """Tests for Path 2: 3MF per-filament fallback tracking."""

    @pytest.mark.asyncio
    async def test_updates_non_bl_spool_from_3mf(self):
        """Non-BL spool gets weight_used from 3MF used_g for completed print."""
        spool = _make_spool(id=5, label_weight=1000, weight_used=100)
        assignment = _make_assignment(spool_id=5)
        archive = MagicMock()
        archive.file_path = "archives/test.3mf"

        db = AsyncMock()
        # archive, queue_item(None), assignment, spool
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
            ]
        )

        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
        filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PLA", "color": "#FF0000"}]

        with (
            patch("backend.app.core.config.settings") as mock_settings,
            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
        ):
            mock_path = MagicMock()
            mock_path.exists.return_value = True
            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)

            results = await _track_from_3mf(
                printer_id=1,
                archive_id=10,
                status="completed",
                print_name="test_print",
                handled_trays=set(),
                printer_manager=pm,
                db=db,
            )

        assert len(results) == 1
        assert results[0]["spool_id"] == 5
        assert results[0]["weight_used"] == 25.5
        # weight_used = old (100) + 3MF (25.5)
        assert spool.weight_used == 125.5

    @pytest.mark.asyncio
    async def test_scales_by_progress_for_failed_print(self):
        """Failed print scales 3MF estimate by progress percentage."""
        spool = _make_spool(id=1, label_weight=1000, weight_used=0)
        assignment = _make_assignment()
        archive = MagicMock()
        archive.file_path = "archives/test.3mf"

        db = AsyncMock()
        # archive, queue_item(None), assignment, spool
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
            ]
        )

        # Print failed at 50% progress → 50g consumed from 100g estimate
        pm = _make_printer_manager(_make_printer_state([], progress=50, tray_now=0))
        filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]

        with (
            patch("backend.app.core.config.settings") as mock_settings,
            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
        ):
            mock_path = MagicMock()
            mock_path.exists.return_value = True
            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)

            results = await _track_from_3mf(
                printer_id=1,
                archive_id=10,
                status="failed",
                print_name="test",
                handled_trays=set(),
                printer_manager=pm,
                db=db,
            )

        assert len(results) == 1
        assert results[0]["weight_used"] == 50.0
        assert spool.weight_used == 50.0

    @pytest.mark.asyncio
    async def test_tracks_bl_spools_via_3mf(self):
        """BL spools (with tag_uid) ARE now tracked via 3MF (unified tracking)."""
        spool = _make_spool(tag_uid="ABCD1234", tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4")
        assignment = _make_assignment()
        archive = MagicMock()
        archive.file_path = "archives/test.3mf"

        db = AsyncMock()
        # archive, queue_item(None), assignment, spool
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
            ]
        )

        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
        filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]

        with (
            patch("backend.app.core.config.settings") as mock_settings,
            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
        ):
            mock_path = MagicMock()
            mock_path.exists.return_value = True
            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)

            results = await _track_from_3mf(
                printer_id=1,
                archive_id=10,
                status="completed",
                print_name="test",
                handled_trays=set(),
                printer_manager=pm,
                db=db,
            )

        assert len(results) == 1
        assert results[0]["spool_id"] == 1
        assert results[0]["weight_used"] == 50.0

    @pytest.mark.asyncio
    async def test_skips_already_handled_trays(self):
        """Trays handled by AMS remain% delta are not double-tracked via 3MF."""
        archive = MagicMock()
        archive.file_path = "archives/test.3mf"

        db = AsyncMock()
        # archive, queue_item(None)
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
            ]
        )

        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
        filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]

        with (
            patch("backend.app.core.config.settings") as mock_settings,
            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
        ):
            mock_path = MagicMock()
            mock_path.exists.return_value = True
            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)

            results = await _track_from_3mf(
                printer_id=1,
                archive_id=10,
                status="completed",
                print_name="test",
                handled_trays={(0, 0)},  # slot_id=1 → ams_id=0, tray_id=0
                printer_manager=pm,
                db=db,
            )

        assert results == []

    @pytest.mark.asyncio
    async def test_slot_to_tray_mapping(self):
        """3MF slot_id maps correctly to (ams_id, tray_id) via tray_now."""
        # tray_now=4 → ams_id=1, tray_id=0 (single filament uses tray_now)
        spool = _make_spool(id=9)
        assignment = _make_assignment(spool_id=9, ams_id=1, tray_id=0)
        archive = MagicMock()
        archive.file_path = "archives/test.3mf"

        db = AsyncMock()
        # archive, queue_item(None), assignment, spool
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
            ]
        )

        pm = _make_printer_manager(_make_printer_state([], tray_now=4))
        filament_usage = [{"slot_id": 5, "used_g": 30.0, "type": "PETG", "color": ""}]

        with (
            patch("backend.app.core.config.settings") as mock_settings,
            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
        ):
            mock_path = MagicMock()
            mock_path.exists.return_value = True
            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)

            results = await _track_from_3mf(
                printer_id=1,
                archive_id=10,
                status="completed",
                print_name="test",
                handled_trays=set(),
                printer_manager=pm,
                db=db,
            )

        assert len(results) == 1
        assert results[0]["ams_id"] == 1
        assert results[0]["tray_id"] == 0


class TestSpoolAssignmentSnapshot:
    """Tests for spool assignment snapshotting at print start (#459).

    When a spool runs empty mid-print, on_ams_change deletes the SpoolAssignment.
    The snapshot captured at print start ensures usage is still attributed correctly.
    """

    @pytest.fixture(autouse=True)
    def _clear_sessions(self):
        _active_sessions.clear()
        yield
        _active_sessions.clear()

    @pytest.fixture(autouse=True)
    def _mock_get_setting(self):
        with patch(
            "backend.app.api.routes.settings.get_setting",
            new_callable=AsyncMock,
            return_value=None,
        ):
            yield

    @pytest.mark.asyncio
    async def test_on_print_start_snapshots_assignments_with_db(self):
        """on_print_start captures spool assignments when db is provided."""
        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 60}]}]
        pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))

        assignment_0 = _make_assignment(spool_id=10, printer_id=1, ams_id=0, tray_id=0)
        assignment_1 = _make_assignment(spool_id=20, printer_id=1, ams_id=0, tray_id=1)

        db = AsyncMock()
        scalars_mock = MagicMock()
        scalars_mock.all.return_value = [assignment_0, assignment_1]
        result_mock = MagicMock()
        result_mock.scalars.return_value = scalars_mock
        db.execute = AsyncMock(return_value=result_mock)

        await on_print_start(1, {"subtask_name": "Benchy"}, pm, db=db)

        session = _active_sessions[1]
        assert session.spool_assignments == {(0, 0): 10, (0, 1): 20}

    @pytest.mark.asyncio
    async def test_on_print_start_empty_snapshot_without_db(self):
        """on_print_start creates empty snapshot when no db provided."""
        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
        pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))

        await on_print_start(1, {"subtask_name": "Benchy"}, pm)

        session = _active_sessions[1]
        assert session.spool_assignments == {}

    @pytest.mark.asyncio
    async def test_3mf_uses_snapshot_instead_of_live_query(self):
        """_track_from_3mf uses snapshot spool_id without querying SpoolAssignment."""
        spool = _make_spool(id=42, label_weight=1000)
        archive = MagicMock()
        archive.file_path = "archives/test.3mf"

        # db: archive, queue_item(None), spool — NO assignment query needed
        db = AsyncMock()
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
            ]
        )

        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
        filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]

        with (
            patch("backend.app.core.config.settings") as mock_settings,
            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
        ):
            mock_path = MagicMock()
            mock_path.exists.return_value = True
            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)

            results = await _track_from_3mf(
                printer_id=1,
                archive_id=10,
                status="completed",
                print_name="Test",
                handled_trays=set(),
                printer_manager=pm,
                db=db,
                spool_assignments={(0, 0): 42},
            )

        assert len(results) == 1
        assert results[0]["spool_id"] == 42
        assert results[0]["weight_used"] == 15.0

    @pytest.mark.asyncio
    async def test_3mf_falls_back_to_live_query_without_snapshot(self):
        """_track_from_3mf queries SpoolAssignment when no snapshot exists."""
        spool = _make_spool(id=5, label_weight=1000)
        assignment = _make_assignment(spool_id=5)
        archive = MagicMock()
        archive.file_path = "archives/test.3mf"

        # db: archive, queue_item(None), assignment, spool
        db = AsyncMock()
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
            ]
        )

        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]

        with (
            patch("backend.app.core.config.settings") as mock_settings,
            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
        ):
            mock_path = MagicMock()
            mock_path.exists.return_value = True
            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)

            results = await _track_from_3mf(
                printer_id=1,
                archive_id=10,
                status="completed",
                print_name="Test",
                handled_trays=set(),
                printer_manager=pm,
                db=db,
                spool_assignments=None,
            )

        assert len(results) == 1
        assert results[0]["spool_id"] == 5

    @pytest.mark.asyncio
    async def test_ams_delta_uses_snapshot_over_live_query(self):
        """AMS remain% fallback uses snapshot spool_id instead of live query."""
        spool = _make_spool(id=77, label_weight=1000)

        _active_sessions[1] = PrintSession(
            printer_id=1,
            print_name="Benchy",
            started_at=datetime.now(timezone.utc),
            tray_remain_start={(0, 0): 80},
            spool_assignments={(0, 0): 77},
        )

        # Current remain = 70% → 10% delta → 100g
        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
        pm = _make_printer_manager(_make_printer_state(ams_data))

        # First 2 executes → _find_3mf_by_filename (library + archive search),
        # then live assignment check (returns None), then spool lookup by snapshot spool_id
        db = AsyncMock()
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(),  # _find_3mf_by_filename: library search
                MagicMock(),  # _find_3mf_by_filename: archive search
                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),  # live assignment
                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
            ]
        )

        results = await on_print_complete(
            printer_id=1,
            data={"status": "completed"},
            printer_manager=pm,
            db=db,
            archive_id=None,
        )

        assert len(results) == 1
        assert results[0]["spool_id"] == 77
        assert results[0]["weight_used"] == 100.0

    @pytest.mark.asyncio
    async def test_ams_delta_falls_back_to_live_query_without_snapshot(self):
        """AMS remain% fallback queries SpoolAssignment when snapshot is empty."""
        spool = _make_spool(id=33, label_weight=1000)
        assignment = _make_assignment(spool_id=33)

        _active_sessions[1] = PrintSession(
            printer_id=1,
            print_name="Benchy",
            started_at=datetime.now(timezone.utc),
            tray_remain_start={(0, 0): 80},
            spool_assignments={},  # Empty snapshot (pre-upgrade session)
        )

        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
        pm = _make_printer_manager(_make_printer_state(ams_data))

        # First 2 executes → _find_3mf_by_filename (library + archive search),
        # then assignment and spool for the AMS fallback path
        db = AsyncMock()
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(),  # _find_3mf_by_filename: library search
                MagicMock(),  # _find_3mf_by_filename: archive search
                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
            ]
        )

        results = await on_print_complete(
            printer_id=1,
            data={"status": "completed"},
            printer_manager=pm,
            db=db,
            archive_id=None,
        )

        assert len(results) == 1
        assert results[0]["spool_id"] == 33

    @pytest.mark.asyncio
    async def test_snapshot_survives_mid_print_unlink(self):
        """Core bug scenario: snapshot provides spool_id after mid-print unlink.

        Simulates the #459 scenario: spool runs empty mid-print, on_ams_change
        deletes the SpoolAssignment, but the snapshot from print start still
        has the spool_id so usage is correctly attributed at print completion.
        """
        spool = _make_spool(id=8, label_weight=1000, weight_used=50)
        archive = MagicMock()
        archive.file_path = "archives/big_print.3mf"
        # Explicit numeric so the #1344 top-up branch doesn't trip a
        # MagicMock-vs-float comparison.
        archive.filament_used_grams = 14.2

        # Session was created at print start WITH snapshot
        _active_sessions[1] = PrintSession(
            printer_id=1,
            print_name="Big Print",
            started_at=datetime.now(timezone.utc),
            tray_remain_start={(0, 0): 90},
            spool_assignments={(0, 0): 8},  # Snapshot from print start
        )

        pm = _make_printer_manager(
            _make_printer_state(
                [{"id": 0, "tray": [{"id": 0, "remain": 75}]}],
                tray_now=0,
            )
        )

        filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]

        # db: archive, queue_item(None), live assignment(None), spool,
        # then cost aggregation queries
        # NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
        db = AsyncMock()
        db.execute = AsyncMock(
            side_effect=[
                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
                # Cost-update block re-selects the archive to mutate cost.
                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
            ]
        )

        with (
            patch("backend.app.core.config.settings") as mock_settings,
            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
        ):
            mock_path = MagicMock()
            mock_path.exists.return_value = True
            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)

            results = await on_print_complete(
                printer_id=1,
                data={"status": "completed"},
                printer_manager=pm,
                db=db,
                archive_id=100,
            )

        # Usage should be tracked despite assignment being deleted mid-print
        assert len(results) >= 1
        assert results[0]["spool_id"] == 8
        assert results[0]["weight_used"] == 14.2
        # Spool weight should be updated: 50 + 14.2 = 64.2
        assert spool.weight_used == 64.2
