"""Integration tests for the /gcode-viewer static-file routes.

Covers two behaviours added by the GCode viewer PR:

1. Route ordering — /gcode-viewer/* is served by explicit @app.get routes
   that are registered before the /{full_path:path} SPA catch-all, so the
   GCode viewer is never accidentally served the React app HTML.

2. Path-traversal guard — requests for paths that escape gcode_viewer/
   (e.g. /gcode-viewer/../main.py) must return 403, not the file contents.

Plus tests for the archive G-code endpoint behaviour the viewer depends on:
``?plate=N`` resolution including zero-padded filenames, and the ``has_gcode``
flag on the plates endpoint that gates the frontend plate picker.
"""

import zipfile
from pathlib import Path

import pytest
from httpx import AsyncClient


class TestGCodeViewerRouteOrdering:
    """Verify the /gcode-viewer routes are reachable and distinct from the SPA."""

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_gcode_viewer_index_does_not_fall_through_to_spa(self, async_client: AsyncClient):
        """GET /gcode-viewer/ must not return the React SPA index.html.

        If route ordering is broken the SPA catch-all returns 200 with
        Content-Type: text/html and a <div id="root"> body.  The correct
        response is either 200 (gcode_viewer/index.html present) or 404
        (directory absent in CI) — never the SPA shell.
        """
        response = await async_client.get("/gcode-viewer/")
        # 200 or 404 are both acceptable depending on whether gcode_viewer/
        # exists in the test environment; the SPA catch-all always returns 200.
        assert response.status_code in (200, 404)
        # If a body came back it must NOT be the React SPA shell.
        assert b'<div id="root">' not in response.content

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_gcode_viewer_index_served_when_assets_present(self, async_client: AsyncClient):
        """GET /gcode-viewer/ must return the PrettyGCode index when the
        vendored assets are on disk.

        Regression guard for #1218: the production Dockerfile has to copy
        ``gcode_viewer/`` into the image alongside ``static/``. The previous
        ``test_gcode_viewer_index_does_not_fall_through_to_spa`` accepted
        404 unconditionally so a missing COPY never failed CI. This test
        only runs when the directory is actually present (so it stays a
        no-op in unit-test environments where the assets are intentionally
        absent), but when it does run it asserts 200 + a non-empty HTML
        body so a future packaging regression fails loudly.
        """
        from backend.app.main import _gcode_viewer_dir

        index = _gcode_viewer_dir / "index.html"
        if not index.is_file():
            pytest.skip(f"gcode_viewer/index.html not present at {index} — skipping packaging assertion")
        response = await async_client.get("/gcode-viewer/")
        assert response.status_code == 200, (
            f"gcode_viewer/index.html exists at {index} but /gcode-viewer/ returned "
            f"{response.status_code} — route or response wiring is broken."
        )
        assert b"PrettyGCode" in response.content or b"<!doctype html>" in response.content.lower()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_gcode_viewer_no_trailing_slash_falls_through_to_spa(self, async_client: AsyncClient):
        """GET /gcode-viewer (no trailing slash) must fall through to the SPA.

        Only /gcode-viewer/ (trailing slash) should serve the raw viewer — that
        form is what the iframe in GCodeViewerPage requests. The bare path is
        the SPA route the user navigates to; reloading it must re-enter the
        React layout rather than serve the iframe contents standalone.
        """
        response = await async_client.get("/gcode-viewer", follow_redirects=False)
        # SPA catch-all serves 200 with the React index.html (which contains
        # <div id="root">). If the build output isn't present the catch-all
        # may 404 — both outcomes are acceptable here; the key invariant is
        # that we do NOT serve the standalone PrettyGCode index.html (which
        # starts with <!doctype html> and contains "PrettyGCode").
        assert response.status_code in (200, 404)
        if response.status_code == 200:
            assert b"PrettyGCode" not in response.content


class TestGCodeViewerPathTraversal:
    """Verify the path-traversal guard on /gcode-viewer/{file_path:path}.

    HTTP clients (and servers) normalise plain `..` segments before the
    request reaches a route handler, so `/gcode-viewer/../x` becomes `/x`
    and hits the SPA catch-all rather than our guard — that normalisation is
    itself a defence layer.  The actual at-risk form is URL-encoded dots
    (`%2E%2E`) which survive normalisation and land in {file_path:path} as
    the literal string `../x`.  We test that form here.
    """

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_encoded_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
        """GET /gcode-viewer/%2E%2E/main.py must return 403.

        %2E%2E URL-decodes to .. which is not normalised away by httpx/
        Starlette, so it reaches _gcode_viewer_response as '../main.py'.
        Path.is_relative_to(gcode_viewer_dir) then blocks it with 403.
        """
        response = await async_client.get("/gcode-viewer/%2E%2E/main.py")
        assert response.status_code == 403

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_encoded_nested_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
        """GET /gcode-viewer/js/%2E%2E/%2E%2E/main.py must return 403."""
        response = await async_client.get("/gcode-viewer/js/%2E%2E/%2E%2E/main.py")
        assert response.status_code == 403

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_nonexistent_safe_path_returns_404(self, async_client: AsyncClient):
        """A safe but nonexistent path returns 404, not 403."""
        response = await async_client.get("/gcode-viewer/does-not-exist.js")
        assert response.status_code == 404


def _write_3mf(
    path: Path,
    plate_gcode: dict[int, str] | None = None,
    plate_filenames: dict[int, str] | None = None,
    include_png_for: list[int] | None = None,
) -> None:
    """Write a synthetic Bambu-style 3MF zip at *path*.

    Parameters let a single test pin one specific shape:

    - ``plate_gcode`` — {plate_index: gcode_text} written at
      ``Metadata/plate_{index}.gcode``. Use for the normal (sliced) case.
    - ``plate_filenames`` — {plate_index: custom_filename} written with the
      raw filename verbatim. Use for zero-padded names (plate_01.gcode) etc.
    - ``include_png_for`` — plate indices to add PNG stubs for. Use to
      simulate source-only archives (PNG/JSON present, no .gcode).

    Leaving all three empty produces an archive that the plates endpoint
    will parse as empty (no plates).
    """
    plate_gcode = plate_gcode or {}
    plate_filenames = plate_filenames or {}
    include_png_for = include_png_for or []
    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
        for idx, text in plate_gcode.items():
            zf.writestr(f"Metadata/plate_{idx}.gcode", text)
        for idx, filename in plate_filenames.items():
            zf.writestr(f"Metadata/{filename}", f"; stub for plate {idx}\n")
        for idx in include_png_for:
            zf.writestr(f"Metadata/plate_{idx}.png", b"\x89PNG\r\n\x1a\n")
            zf.writestr(f"Metadata/plate_{idx}.json", b'{"bbox_objects": []}')


@pytest.fixture
def _patch_archive_base_dir(monkeypatch, tmp_path):
    """Point archive file_path resolution at *tmp_path* for this test."""
    from backend.app.core.config import settings

    monkeypatch.setattr(settings, "base_dir", tmp_path)
    return tmp_path


class TestArchiveGcodePlateParam:
    """The viewer passes ``?plate=N`` for multi-plate archives."""

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_plate_param_returns_that_plate(
        self,
        async_client: AsyncClient,
        archive_factory,
        printer_factory,
        _patch_archive_base_dir,
    ):
        """GET /archives/{id}/gcode?plate=2 returns Metadata/plate_2.gcode."""
        tmp = _patch_archive_base_dir
        threemf = tmp / "multi.3mf"
        _write_3mf(
            threemf,
            plate_gcode={1: "G0 ; plate 1\n", 2: "G1 X0 Y0 ; plate 2\n"},
        )
        printer = await printer_factory()
        archive = await archive_factory(printer.id, filename="multi.3mf", file_path="multi.3mf")

        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=2")

        assert response.status_code == 200
        assert "plate 2" in response.text

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_plate_param_zero_padded_filename_resolves(
        self,
        async_client: AsyncClient,
        archive_factory,
        printer_factory,
        _patch_archive_base_dir,
    ):
        """plate_01.gcode reports as plate 1 from /plates — /gcode?plate=1 must find it.

        Regression: the original exact-string match on ``Metadata/plate_1.gcode``
        missed zero-padded filenames exported by some slicers, so the picker
        showed plate 1 as selectable but the viewer 404'd on selection.
        """
        tmp = _patch_archive_base_dir
        threemf = tmp / "padded.3mf"
        with zipfile.ZipFile(threemf, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("Metadata/plate_01.gcode", "G0 ; padded plate\n")
        printer = await printer_factory()
        archive = await archive_factory(printer.id, filename="padded.3mf", file_path="padded.3mf")

        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")

        assert response.status_code == 200
        assert "padded plate" in response.text

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_missing_plate_returns_404(
        self,
        async_client: AsyncClient,
        archive_factory,
        printer_factory,
        _patch_archive_base_dir,
    ):
        """Requesting a plate index the archive doesn't contain returns 404."""
        tmp = _patch_archive_base_dir
        threemf = tmp / "only_plate_2.3mf"
        _write_3mf(threemf, plate_gcode={2: "G0\n"})
        printer = await printer_factory()
        archive = await archive_factory(printer.id, filename="only_plate_2.3mf", file_path="only_plate_2.3mf")

        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")

        assert response.status_code == 404

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_no_plate_param_returns_first_plate(
        self,
        async_client: AsyncClient,
        archive_factory,
        printer_factory,
        _patch_archive_base_dir,
    ):
        """Omitting ?plate falls back to the first gcode in the archive.

        Preserves the pre-plate-param behaviour — existing callers that don't
        know about plates still get something sensible back.
        """
        tmp = _patch_archive_base_dir
        threemf = tmp / "single.3mf"
        _write_3mf(threemf, plate_gcode={1: "G0 ; only plate\n"})
        printer = await printer_factory()
        archive = await archive_factory(printer.id, filename="single.3mf", file_path="single.3mf")

        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode")

        assert response.status_code == 200
        assert "only plate" in response.text

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_plate_param_rejects_zero_and_negative(
        self,
        async_client: AsyncClient,
        archive_factory,
        printer_factory,
        _patch_archive_base_dir,
    ):
        """``?plate=0`` or negative must 400 — not silently fall through."""
        tmp = _patch_archive_base_dir
        threemf = tmp / "any.3mf"
        _write_3mf(threemf, plate_gcode={1: "G0\n"})
        printer = await printer_factory()
        archive = await archive_factory(printer.id, filename="any.3mf", file_path="any.3mf")

        response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=0")

        assert response.status_code == 400


class TestArchivePlatesHasGcode:
    """The ``has_gcode`` flag on /plates gates the frontend plate picker."""

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_has_gcode_true_when_gcode_files_present(
        self,
        async_client: AsyncClient,
        archive_factory,
        printer_factory,
        _patch_archive_base_dir,
    ):
        """Sliced multi-plate 3MF → has_gcode=true."""
        tmp = _patch_archive_base_dir
        threemf = tmp / "sliced.3mf"
        _write_3mf(threemf, plate_gcode={1: "G0\n", 2: "G1\n"})
        printer = await printer_factory()
        archive = await archive_factory(printer.id, filename="sliced.3mf", file_path="sliced.3mf")

        response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")

        assert response.status_code == 200
        data = response.json()
        assert data["has_gcode"] is True

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_has_gcode_false_for_source_only_archive(
        self,
        async_client: AsyncClient,
        archive_factory,
        printer_factory,
        _patch_archive_base_dir,
    ):
        """Source-only 3MF (PNG/JSON only, no gcode) → has_gcode=false.

        Regression for the archive-69 bug: the PNG/JSON fallback path made the
        plates endpoint report plate indices that the gcode endpoint couldn't
        actually serve, so every viewer preview 404'd. The frontend now uses
        has_gcode to suppress the picker + show a toast instead.
        """
        tmp = _patch_archive_base_dir
        threemf = tmp / "project.3mf"
        _write_3mf(threemf, include_png_for=[1, 2, 3])  # no .gcode at all
        printer = await printer_factory()
        archive = await archive_factory(printer.id, filename="project.3mf", file_path="project.3mf")

        response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")

        assert response.status_code == 200
        data = response.json()
        assert data["has_gcode"] is False
        # The endpoint still reports plates (from JSON/PNG) — the flag is what
        # the frontend keys on, not an empty plate list.
        assert len(data["plates"]) == 3
