"""Integration tests for Slicer Pipeline runs (#1425 PR B).

Slicing itself is a network call to the slicer sidecar — these tests
stub ``slice_and_persist`` so the orchestration logic is exercised without
needing a live sidecar in CI.
"""

from __future__ import annotations

from unittest.mock import AsyncMock, patch

import pytest
from httpx import AsyncClient


def _pipeline_payload(**overrides) -> dict:
    payload = {
        "name": "Production Batch",
        "description": None,
        "printer_preset": {"source": "local", "id": "1"},
        "process_preset": {"source": "local", "id": "2"},
        "filament_presets": [{"source": "local", "id": "3"}],
        "bed_type": None,
    }
    payload.update(overrides)
    return payload


@pytest.fixture
async def pipeline_factory(async_client: AsyncClient):
    """Create pipelines via the API + optionally set a target printer."""

    async def _make(target_printer_id: int | None = None, **overrides) -> dict:
        resp = await async_client.post("/api/v1/slicer-pipelines/", json=_pipeline_payload(**overrides))
        assert resp.status_code == 201, resp.text
        pipeline = resp.json()
        if target_printer_id is not None:
            put_resp = await async_client.put(
                f"/api/v1/slicer-pipelines/{pipeline['id']}",
                json={"target_kind": "specific_printer", "target_printer_id": target_printer_id},
            )
            assert put_resp.status_code == 200, put_resp.text
            pipeline = put_resp.json()
        return pipeline

    return _make


@pytest.fixture
async def printer_factory(db_session):
    """Insert a Printer row for tests that need a target_printer_id."""
    from backend.app.models.printer import Printer

    counter = [0]

    async def _make(**overrides) -> Printer:
        counter[0] += 1
        defaults = {
            "name": f"X1C #{counter[0]}",
            "serial_number": f"SERIAL{counter[0]:04d}",
            "ip_address": "192.0.2.1",
            "access_code": "ABCD1234",
            "model": "Bambu Lab X1 Carbon",
            "is_active": True,
        }
        defaults.update(overrides)
        printer = Printer(**defaults)
        db_session.add(printer)
        await db_session.commit()
        await db_session.refresh(printer)
        return printer

    return _make


@pytest.fixture
async def library_file_factory(db_session):
    """Insert a LibraryFile row for tests that need a source_library_file_id."""
    from pathlib import Path

    from backend.app.core.config import settings as app_settings
    from backend.app.models.library import LibraryFile

    counter = [0]

    async def _make(**overrides) -> LibraryFile:
        counter[0] += 1
        # Materialise an empty file on disk so the orchestration's path-exists
        # guard passes when tests reach it.
        rel = f"test_pipeline_run_{counter[0]}.3mf"
        abs_path = Path(app_settings.base_dir) / rel
        abs_path.parent.mkdir(parents=True, exist_ok=True)
        abs_path.write_bytes(b"")
        defaults = {
            "filename": f"cube_{counter[0]}.3mf",
            "file_path": rel,
            "file_type": "3mf",
            "file_size": 0,
            "file_hash": f"hash_{counter[0]}",
            "source_type": "uploaded",
        }
        defaults.update(overrides)
        row = LibraryFile(**defaults)
        db_session.add(row)
        await db_session.commit()
        await db_session.refresh(row)
        return row

    return _make


class TestSlicerPipelineTarget:
    """PUT /slicer-pipelines/{id} accepts the new target fields."""

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_update_writes_target(self, async_client: AsyncClient, pipeline_factory, printer_factory):
        printer = await printer_factory()
        pipeline = await pipeline_factory()
        resp = await async_client.put(
            f"/api/v1/slicer-pipelines/{pipeline['id']}",
            json={"target_kind": "specific_printer", "target_printer_id": printer.id},
        )
        assert resp.status_code == 200, resp.text
        updated = resp.json()
        assert updated["target_kind"] == "specific_printer"
        assert updated["target_printer_id"] == printer.id

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_update_target_printer_id_zero_clears(
        self, async_client: AsyncClient, pipeline_factory, printer_factory
    ):
        """Empty-select dropdown sends target_printer_id=0 → backend treats
        as 'clear' rather than referencing printer #0 (which doesn't exist)."""
        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        resp = await async_client.put(
            f"/api/v1/slicer-pipelines/{pipeline['id']}",
            json={"target_printer_id": 0},
        )
        assert resp.status_code == 200, resp.text
        assert resp.json()["target_printer_id"] is None


class TestCheckEligibility:
    """POST /slicer-pipelines/{id}/check-eligibility surfaces structured issues."""

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_no_target_set(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        library_file_factory,
    ):
        pipeline = await pipeline_factory()  # no target set
        src = await library_file_factory()
        resp = await async_client.post(
            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
            json={"source_library_file_id": src.id},
        )
        assert resp.status_code == 200
        body = resp.json()
        assert body["ok"] is False
        kinds = [i["kind"] for i in body["issues"]]
        # PR A defaults target_kind to 'printer_class' so a freshly-saved
        # pipeline with no target_model_class surfaces ``class_not_set``; the
        # PR B UI path that hadn't pinned a target_printer_id would surface
        # ``printer_not_set``. Both signal the same thing to the operator;
        # accept either.
        assert kinds == ["class_not_set"] or kinds == ["printer_not_set"]

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_printer_disabled(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
    ):
        printer = await printer_factory(is_active=False)
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        with patch("backend.app.api.routes.pipeline_runs._load_printer_status", new=AsyncMock(return_value=None)):
            resp = await async_client.post(
                f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
                json={"source_library_file_id": src.id},
            )
        assert resp.status_code == 200
        body = resp.json()
        kinds = [i["kind"] for i in body["issues"]]
        assert "printer_disabled" in kinds
        # printer_offline also fires because get_status returns None — both
        # issues are expected and both block.
        assert "printer_offline" in kinds
        assert body["ok"] is False

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_online_match_clears_issues(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        """Patch printer_manager so AMS slot 0 carries the same canonical
        type the pipeline's local-tier filament preset declares."""
        from backend.app.models.local_preset import LocalPreset

        preset = LocalPreset(
            name="My PLA",
            preset_type="filament",
            source="manual",
            setting="{}",
            filament_type="PLA",
            default_filament_colour="#FFFFFF",
        )
        db_session.add(preset)
        await db_session.commit()
        await db_session.refresh(preset)

        printer = await printer_factory()
        pipeline = await pipeline_factory(
            target_printer_id=printer.id,
            filament_presets=[{"source": "local", "id": str(preset.id)}],
        )
        src = await library_file_factory()

        # The printer reports the generic material in tray_type and the product
        # name in tray_sub_brands, so this is the shape a real AMS sends.
        live_status = {
            "connected": True,
            "raw_data": {"ams": [{"tray": [{"tray_type": "PLA", "tray_color": "FFFFFFFF"}]}]},
        }
        with patch(
            "backend.app.api.routes.pipeline_runs._load_printer_status",
            new=AsyncMock(return_value=live_status),
        ):
            resp = await async_client.post(
                f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
                json={"source_library_file_id": src.id},
            )
        assert resp.status_code == 200
        body = resp.json()
        assert body["ok"] is True
        assert body["issues"] == []
        assert body["target_printer_name"] == printer.name

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_a_product_name_in_tray_type_is_reported_as_a_mismatch(
        self,
        async_client: AsyncClient,
        db_session,
        printer_factory,
        pipeline_factory,
        library_file_factory,
    ):
        """Eligibility answers with the dispatch matcher's type rules, not its own.

        It used to alias "PLA Basic" to "PLA" and pass this; the matcher never
        did, so the run cleared the pre-flight and then failed to map the slot.
        Flagging it here is the honest answer even though it is the stricter one.
        """
        from backend.app.models.local_preset import LocalPreset

        preset = LocalPreset(
            name="My PLA",
            preset_type="filament",
            source="manual",
            setting="{}",
            filament_type="PLA",
            default_filament_colour="#FFFFFF",
        )
        db_session.add(preset)
        await db_session.commit()
        await db_session.refresh(preset)

        printer = await printer_factory()
        pipeline = await pipeline_factory(
            target_printer_id=printer.id,
            filament_presets=[{"source": "local", "id": str(preset.id)}],
        )
        src = await library_file_factory()

        live_status = {
            "connected": True,
            "raw_data": {"ams": [{"tray": [{"tray_type": "PLA Basic", "tray_color": "FFFFFFFF"}]}]},
        }
        with patch(
            "backend.app.api.routes.pipeline_runs._load_printer_status",
            new=AsyncMock(return_value=live_status),
        ):
            resp = await async_client.post(
                f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
                json={"source_library_file_id": src.id},
            )
        assert resp.status_code == 200
        body = resp.json()
        assert [i["kind"] for i in body["issues"]] == ["filament_type_mismatch"]


class TestRunPipeline:
    """POST /slicer-pipelines/{id}/run orchestrates slice + enqueue."""

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_run_with_issues_and_no_force_returns_409(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        library_file_factory,
    ):
        pipeline = await pipeline_factory()  # no target set
        src = await library_file_factory()
        resp = await async_client.post(
            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
            json={"source_library_file_id": src.id},
        )
        assert resp.status_code == 409
        # Eligibility report rides in detail.
        detail = resp.json()["detail"]
        assert detail["ok"] is False
        # printer_not_set or class_not_set — depends on the PR A default
        # target_kind. Both mean "no target chosen yet".
        kinds = [i["kind"] for i in detail["issues"]]
        assert "printer_not_set" in kinds or "class_not_set" in kinds

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_run_force_with_no_target_still_400(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        library_file_factory,
    ):
        """``force=True`` bypasses the 409 but the run endpoint still needs a
        target to enqueue against — the second guard returns 400."""
        pipeline = await pipeline_factory()
        src = await library_file_factory()
        resp = await async_client.post(
            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
            json={"source_library_file_id": src.id, "force": True},
        )
        assert resp.status_code == 400

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_run_creates_run_and_job(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
    ):
        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()

        live_status = {"connected": True, "raw_data": {"ams": []}}
        # AMS empty → eligibility surfaces filament_unverified (non-blocking)
        # for the standard-tier filament refs the default factory uses; report
        # is ok=True so no force needed.
        from dataclasses import dataclass

        @dataclass
        class _FakeSliceJob:
            id: int = 9001

        with (
            patch(
                "backend.app.api.routes.pipeline_runs._load_printer_status",
                new=AsyncMock(return_value=live_status),
            ),
            patch(
                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
                new=AsyncMock(return_value=_FakeSliceJob()),
            ),
        ):
            resp = await async_client.post(
                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
                json={"source_library_file_id": src.id},
            )
        assert resp.status_code == 202, resp.text
        body = resp.json()
        assert body["pipeline_id"] == pipeline["id"]
        assert body["source_library_file_id"] == src.id
        assert body["copies"] == 1
        assert body["status"] == "queued"
        assert len(body["jobs"]) == 1
        assert body["jobs"][0]["copy_index"] == 0
        assert body["eligibility_overridden"] is False
        # slice_job_id rides on the response so the frontend can call
        # trackJob and render the progress toast.
        assert body["slice_job_id"] == 9001


class TestRunListAndGet:
    """Run history surfaces."""

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_list_runs_empty(
        self,
        async_client: AsyncClient,
        pipeline_factory,
    ):
        pipeline = await pipeline_factory()
        resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
        assert resp.status_code == 200
        assert resp.json() == {"runs": [], "total": 0}

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_get_run_404(
        self,
        async_client: AsyncClient,
    ):
        resp = await async_client.get("/api/v1/pipeline-runs/99999")
        assert resp.status_code == 404


class TestCancelRun:
    """Cancellation marks the run + linked queue entry."""

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_cancel_unknown_run_404(self, async_client: AsyncClient):
        resp = await async_client.post("/api/v1/pipeline-runs/99999/cancel")
        assert resp.status_code == 404

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_cancel_marks_queued_run(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        live_status = {"connected": True, "raw_data": {"ams": []}}
        from dataclasses import dataclass

        @dataclass
        class _FakeSliceJob:
            id: int = 9001

        with (
            patch(
                "backend.app.api.routes.pipeline_runs._load_printer_status",
                new=AsyncMock(return_value=live_status),
            ),
            patch(
                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
                new=AsyncMock(return_value=_FakeSliceJob()),
            ),
        ):
            run_resp = await async_client.post(
                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
                json={"source_library_file_id": src.id},
            )
        run_id = run_resp.json()["id"]
        cancel_resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
        assert cancel_resp.status_code == 200
        assert cancel_resp.json()["status"] == "cancelled"

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_run_accepts_archive_source(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        db_session,
    ):
        """``source_archive_id`` is accepted in place of source_library_file_id."""
        from pathlib import Path

        from backend.app.core.config import settings as app_settings
        from backend.app.models.archive import PrintArchive

        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)

        rel = "test_pipeline_archive_source.3mf"
        (Path(app_settings.base_dir) / rel).write_bytes(b"")
        archive = PrintArchive(
            printer_id=printer.id,
            filename="Archive Source.3mf",
            file_path=rel,
            file_size=0,
            source_3mf_path=rel,
        )
        db_session.add(archive)
        await db_session.commit()
        await db_session.refresh(archive)

        from dataclasses import dataclass

        @dataclass
        class _FakeSliceJob:
            id: int = 7777

        live_status = {"connected": True, "raw_data": {"ams": []}}
        with (
            patch(
                "backend.app.api.routes.pipeline_runs._load_printer_status",
                new=AsyncMock(return_value=live_status),
            ),
            patch(
                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
                new=AsyncMock(return_value=_FakeSliceJob()),
            ),
        ):
            resp = await async_client.post(
                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
                json={"source_archive_id": archive.id},
            )
        assert resp.status_code == 202, resp.text
        body = resp.json()
        assert body["source_library_file_id"] is None
        assert body["source_archive_id"] == archive.id
        assert body["slice_job_id"] == 7777

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_run_rejects_no_source(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
    ):
        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        resp = await async_client.post(f"/api/v1/slicer-pipelines/{pipeline['id']}/run", json={})
        assert resp.status_code == 422

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_run_rejects_both_sources(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
    ):
        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        resp = await async_client.post(
            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
            json={"source_library_file_id": src.id, "source_archive_id": 99},
        )
        assert resp.status_code == 422


class TestPipelineC:
    """PR C — multi-copy, class targeting, fanout strategies, retry-failed,
    dashboard list, max-copies cap."""

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_copies_cap_enforced(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
    ):
        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        # Default cap is 50; over-request returns 422 even with valid eligibility.
        resp = await async_client.post(
            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
            json={"source_library_file_id": src.id, "copies": 9999},
        )
        assert resp.status_code == 422  # schema gate (le=1000)

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_run_copies_3_creates_3_jobs(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
    ):
        from dataclasses import dataclass

        @dataclass
        class _FakeSliceJob:
            id: int = 5555

        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()

        live_status = {"connected": True, "raw_data": {"ams": []}}
        with (
            patch(
                "backend.app.api.routes.pipeline_runs._load_printer_status",
                new=AsyncMock(return_value=live_status),
            ),
            patch(
                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
                new=AsyncMock(return_value=_FakeSliceJob()),
            ),
        ):
            resp = await async_client.post(
                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
                json={"source_library_file_id": src.id, "copies": 3},
            )
        assert resp.status_code == 202, resp.text
        body = resp.json()
        assert body["copies"] == 3
        assert len(body["jobs"]) == 3
        assert [j["copy_index"] for j in body["jobs"]] == [0, 1, 2]

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_class_eligibility_per_printer_breakdown(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
    ):
        """target_kind='printer_class' surfaces per-printer reports."""
        await printer_factory(model="X1C")
        await printer_factory(model="X1C")
        await printer_factory(model="P1S")  # noise — different model
        pipeline = await pipeline_factory()
        # Wire class targeting via PUT.
        put_resp = await async_client.put(
            f"/api/v1/slicer-pipelines/{pipeline['id']}",
            json={
                "target_kind": "printer_class",
                "target_printer_id": 0,
                "target_model_class": "X1C",
                "fanout_strategy": "max_parallel",
            },
        )
        assert put_resp.status_code == 200, put_resp.text
        src = await library_file_factory()
        resp = await async_client.post(
            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
            json={"source_library_file_id": src.id},
        )
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert body["target_kind"] == "printer_class"
        assert body["target_model_class"] == "X1C"
        # Two X1Cs were created — both should appear in the per-printer breakdown.
        assert len(body["printer_reports"]) == 2
        assert all(r["printer_name"].startswith("X1C") for r in body["printer_reports"])
        # AMS empty + no live state → both are offline, so ok=False.
        assert body["ok"] is False

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_class_eligibility_no_matching_printers(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
    ):
        await printer_factory(model="P1S")  # only a P1S in the install
        pipeline = await pipeline_factory()
        await async_client.put(
            f"/api/v1/slicer-pipelines/{pipeline['id']}",
            json={
                "target_kind": "printer_class",
                "target_printer_id": 0,
                "target_model_class": "X1C",
            },
        )
        src = await library_file_factory()
        resp = await async_client.post(
            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
            json={"source_library_file_id": src.id},
        )
        body = resp.json()
        assert body["ok"] is False
        assert any(i["kind"] == "no_class_matches" for i in body["issues"])

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_list_all_runs_dashboard_endpoint(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        from backend.app.models.pipeline_run import PipelineRun

        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        for i in range(3):
            run = PipelineRun(
                pipeline_id=pipeline["id"],
                source_library_file_id=src.id,
                copies=1,
                status="completed" if i % 2 == 0 else "failed",
            )
            db_session.add(run)
        await db_session.commit()

        resp = await async_client.get("/api/v1/pipeline-runs?limit=10")
        assert resp.status_code == 200
        body = resp.json()
        assert body["total"] == 3
        assert len(body["runs"]) == 3
        # Newest first.
        assert body["runs"][0]["id"] > body["runs"][-1]["id"]

        # Filter by status.
        resp = await async_client.get("/api/v1/pipeline-runs?status=failed")
        body = resp.json()
        assert all(r["status"] == "failed" for r in body["runs"])

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_retry_failed_creates_child_run(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        from dataclasses import dataclass

        from backend.app.models.pipeline_run import PipelineJob, PipelineRun

        @dataclass
        class _FakeSliceJob:
            id: int = 6666

        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        # Build a parent run with 3 jobs: 1 completed, 2 failed → retry
        # should request copies=2.
        parent = PipelineRun(
            pipeline_id=pipeline["id"],
            source_library_file_id=src.id,
            copies=3,
            status="partial_failure",
        )
        db_session.add(parent)
        await db_session.flush()
        for idx, status in enumerate(["completed", "failed", "failed"]):
            db_session.add(PipelineJob(pipeline_run_id=parent.id, copy_index=idx, status=status))
        await db_session.commit()
        await db_session.refresh(parent)

        live_status = {"connected": True, "raw_data": {"ams": []}}
        with (
            patch(
                "backend.app.api.routes.pipeline_runs._load_printer_status",
                new=AsyncMock(return_value=live_status),
            ),
            patch(
                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
                new=AsyncMock(return_value=_FakeSliceJob()),
            ),
        ):
            resp = await async_client.post(f"/api/v1/pipeline-runs/{parent.id}/retry-failed")
        assert resp.status_code == 202, resp.text
        body = resp.json()
        assert body["copies"] == 2  # only the 2 failed copies
        assert body["parent_run_id"] == parent.id


class TestPolishFollowUp:
    """Polish-pass fixes: dashboard target filters, clear endpoint, and the
    deleted-queue-entry → cancelled rollup behaviour."""

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_dashboard_filters_by_target_printer(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        from backend.app.models.pipeline_run import PipelineRun

        printer_a = await printer_factory()
        printer_b = await printer_factory()
        pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
        pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
        src = await library_file_factory()
        for pipe in (pipe_a, pipe_a, pipe_b):
            db_session.add(
                PipelineRun(
                    pipeline_id=pipe["id"],
                    source_library_file_id=src.id,
                    copies=1,
                    status="completed",
                )
            )
        await db_session.commit()

        resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
        assert resp.status_code == 200
        body = resp.json()
        assert body["total"] == 2
        assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_dashboard_filters_by_target_model_class(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        from backend.app.models.pipeline_run import PipelineRun

        await printer_factory(model="X1C")
        await printer_factory(model="P1S")
        # Two pipelines, one class-targeting X1C, one P1S.
        pipe_x = await pipeline_factory()
        await async_client.put(
            f"/api/v1/slicer-pipelines/{pipe_x['id']}",
            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
        )
        pipe_p = await pipeline_factory()
        await async_client.put(
            f"/api/v1/slicer-pipelines/{pipe_p['id']}",
            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
        )
        src = await library_file_factory()
        for pipe in (pipe_x, pipe_p, pipe_p):
            db_session.add(
                PipelineRun(
                    pipeline_id=pipe["id"],
                    source_library_file_id=src.id,
                    copies=1,
                    status="completed",
                )
            )
        await db_session.commit()

        resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
        assert resp.status_code == 200
        body = resp.json()
        assert body["total"] == 2
        assert all(r["target_model_class"] == "P1S" for r in body["runs"])

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_clear_endpoint_deletes_terminal_runs_only(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        from backend.app.models.pipeline_run import PipelineRun

        printer = await printer_factory()
        pipe = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
            db_session.add(
                PipelineRun(
                    pipeline_id=pipe["id"],
                    source_library_file_id=src.id,
                    copies=1,
                    status=status,
                )
            )
        await db_session.commit()

        resp = await async_client.post("/api/v1/pipeline-runs/clear")
        assert resp.status_code == 200, resp.text
        assert resp.json()["deleted"] == 4  # 4 terminal statuses cleared

        # The in-flight rows survive.
        survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
        assert survivors["total"] == 2
        assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_deleted_queue_entry_rolls_up_as_cancelled(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        """When the queue entry that a PipelineJob is linked to gets deleted
        from the print-queue page, the job's live status should roll up to
        ``cancelled`` so the run doesn't sit forever showing ``queued`` /
        ``dispatching``."""
        from backend.app.models.pipeline_run import PipelineJob, PipelineRun

        printer = await printer_factory()
        pipe = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        # Simulate the state PR C leaves a successful dispatch in: run is
        # 'dispatching' and the job has a queue_entry_id pointing at a
        # PrintQueueItem that no longer exists.
        run = PipelineRun(
            pipeline_id=pipe["id"],
            source_library_file_id=src.id,
            copies=1,
            status="dispatching",
        )
        db_session.add(run)
        await db_session.flush()
        db_session.add(
            PipelineJob(
                pipeline_run_id=run.id,
                copy_index=0,
                queue_entry_id=999999,  # Doesn't exist — simulates manual delete from queue.
                assigned_printer_id=printer.id,
                status="queued",
            )
        )
        await db_session.commit()
        await db_session.refresh(run)

        resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
        assert resp.status_code == 200, resp.text
        body = resp.json()
        # Job rolled up to cancelled because the queue entry is gone.
        assert body["jobs"][0]["status"] == "cancelled"
        # Run also rolls up — all jobs cancelled → run reads as cancelled.
        assert body["status"] == "cancelled"


class TestCancelTerminal:
    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_cancel_terminal_run_is_idempotent(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        from backend.app.models.pipeline_run import PipelineRun

        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        run = PipelineRun(
            pipeline_id=pipeline["id"],
            source_library_file_id=src.id,
            copies=1,
            status="completed",
        )
        db_session.add(run)
        await db_session.commit()
        await db_session.refresh(run)
        resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
        assert resp.status_code == 200
        assert resp.json()["status"] == "completed"  # unchanged


class TestRunViaApiKey:
    """An API key may run a pipeline (#1425 follow-up).

    Every pipeline endpoint used to answer 403 for API keys — the three
    permissions were parked as administrative in PR A, before the run dispatch
    existed to decide about. ``pipelines:run`` now needs the key's Manage Queue
    *and* Manage Library scopes together, because a run slices into the library
    and then queues prints.
    """

    async def _admin_and_key(self, async_client: AsyncClient, db_session, **flags):
        """Enable auth, then mint a key owned by the admin. The owner matters:
        a key never out-ranks its owner, and only an owned key can stand in for
        a user when a cloud preset has to be resolved."""
        from sqlalchemy import select

        from backend.app.core.auth import generate_api_key
        from backend.app.models.api_key import APIKey
        from backend.app.models.user import User

        await async_client.post(
            "/api/v1/auth/setup",
            json={"auth_enabled": True, "admin_username": "pipeadmin", "admin_password": "AdminPass1!"},
        )
        admin = (await db_session.execute(select(User).where(User.username == "pipeadmin"))).scalar_one()

        full_key, key_hash, key_prefix = generate_api_key()
        db_session.add(
            APIKey(
                name="pipeline-runner",
                key_hash=key_hash,
                key_prefix=key_prefix,
                user_id=admin.id,
                enabled=True,
                **{"can_read_status": False, "can_queue": False, "can_manage_library": False, **flags},
            )
        )
        await db_session.commit()
        return admin, full_key

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_a_scoped_key_runs_the_pipeline(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        from dataclasses import dataclass

        @dataclass
        class _FakeSliceJob:
            id: int = 7777

        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        # Auth goes on only now: the factories above post as an anonymous
        # caller, which is how every other test in this file works.
        _, key = await self._admin_and_key(
            async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
        )

        live_status = {"connected": True, "raw_data": {"ams": []}}
        with (
            patch(
                "backend.app.api.routes.pipeline_runs._load_printer_status",
                new=AsyncMock(return_value=live_status),
            ),
            patch(
                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
                new=AsyncMock(return_value=_FakeSliceJob()),
            ),
        ):
            resp = await async_client.post(
                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
                json={"source_library_file_id": src.id, "copies": 1, "force": True},
                headers={"X-API-Key": key},
            )

        assert resp.status_code == 202, resp.text

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_a_key_without_manage_library_is_refused(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        """Queueing prints is only half of what a run does. The refusal names
        the flag that is missing rather than calling the whole thing
        administrative."""
        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        _, key = await self._admin_and_key(async_client, db_session, can_read_status=True, can_queue=True)

        resp = await async_client.post(
            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
            json={"source_library_file_id": src.id, "copies": 1, "force": True},
            headers={"X-API-Key": key},
        )

        assert resp.status_code == 403
        assert "can_manage_library" in resp.json()["detail"]

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_a_cloud_scoped_key_slices_as_its_owner(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        """A pipeline can be built on Bambu/Orca Cloud presets, and resolving
        those reads a cloud token off a user record. The permission gate hands
        an API-keyed request ``current_user=None``, so without falling back to
        the key's owner such a pipeline would have nobody to resolve against
        and would fail at slice time — the same fallback the direct slice route
        makes."""
        from dataclasses import dataclass

        @dataclass
        class _FakeSliceJob:
            id: int = 7778

        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        admin, key = await self._admin_and_key(
            async_client,
            db_session,
            can_read_status=True,
            can_queue=True,
            can_manage_library=True,
            can_access_cloud=True,
        )

        enqueue = AsyncMock(return_value=_FakeSliceJob())
        live_status = {"connected": True, "raw_data": {"ams": []}}
        with (
            patch(
                "backend.app.api.routes.pipeline_runs._load_printer_status",
                new=AsyncMock(return_value=live_status),
            ),
            patch("backend.app.services.slice_dispatch.slice_dispatch.enqueue", new=enqueue),
        ):
            resp = await async_client.post(
                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
                json={"source_library_file_id": src.id, "copies": 1, "force": True},
                headers={"X-API-Key": key},
            )

        assert resp.status_code == 202, resp.text
        assert enqueue.await_args.kwargs["owner_id"] == admin.id

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_a_key_without_cloud_scope_stays_anonymous(
        self,
        async_client: AsyncClient,
        pipeline_factory,
        printer_factory,
        library_file_factory,
        db_session,
    ):
        """The fallback is the cloud scope's own opt-in, not a general identity
        for API keys: a key without it slices unattributed, exactly as before."""
        from dataclasses import dataclass

        @dataclass
        class _FakeSliceJob:
            id: int = 7779

        printer = await printer_factory()
        pipeline = await pipeline_factory(target_printer_id=printer.id)
        src = await library_file_factory()
        # Same owner and same three scopes as the test above — only the cloud
        # opt-in differs.
        _, full_key = await self._admin_and_key(
            async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
        )

        enqueue = AsyncMock(return_value=_FakeSliceJob())
        live_status = {"connected": True, "raw_data": {"ams": []}}
        with (
            patch(
                "backend.app.api.routes.pipeline_runs._load_printer_status",
                new=AsyncMock(return_value=live_status),
            ),
            patch("backend.app.services.slice_dispatch.slice_dispatch.enqueue", new=enqueue),
        ):
            resp = await async_client.post(
                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
                json={"source_library_file_id": src.id, "copies": 1, "force": True},
                headers={"X-API-Key": full_key},
            )

        assert resp.status_code == 202, resp.text
        assert enqueue.await_args.kwargs["owner_id"] is None
