"""Integration tests for External Folder API endpoints."""

import os
import tempfile
from pathlib import Path

import pytest
from httpx import AsyncClient


class TestExternalFolderCreation:
    """Tests for POST /library/folders/external."""

    @pytest.fixture
    def external_dir(self, tmp_path):
        """Create a temporary directory to act as an external folder."""
        ext_dir = tmp_path / "nas_share"
        ext_dir.mkdir()
        # Add some test files
        (ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
        (ext_dir / "bracket.stl").write_bytes(b"fakestl")
        (ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
        (ext_dir / "readme.txt").write_text("not a print file")
        (ext_dir / ".hidden.3mf").write_bytes(b"hidden")
        return ext_dir

    @pytest.fixture
    def nested_external_dir(self, external_dir):
        """Create a nested subdirectory in the external folder."""
        sub = external_dir / "subfolder"
        sub.mkdir()
        (sub / "nested_part.stl").write_bytes(b"nestedstl")
        return external_dir

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_create_external_folder(self, async_client: AsyncClient, db_session, external_dir):
        """Verify external folder can be created with valid path."""
        data = {
            "name": "NAS Prints",
            "external_path": str(external_dir),
            "readonly": True,
            "show_hidden": False,
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        assert response.status_code == 200
        result = response.json()
        assert result["name"] == "NAS Prints"
        assert result["is_external"] is True
        assert result["external_readonly"] is True
        assert result["external_show_hidden"] is False
        assert result["external_path"] == str(external_dir.resolve())

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session):
        """Verify 400 for non-existent path."""
        data = {
            "name": "Bad Path",
            "external_path": "/nonexistent/path/that/does/not/exist",
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        assert response.status_code == 400
        assert "does not exist" in response.json()["detail"]

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_create_external_folder_system_dir_blocked(self, async_client: AsyncClient, db_session):
        """Verify system directories are blocked."""
        data = {
            "name": "System",
            "external_path": "/proc",
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        assert response.status_code == 400
        assert "system directory" in response.json()["detail"].lower()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_create_external_folder_file_not_dir(self, async_client: AsyncClient, db_session, tmp_path):
        """Verify 400 when path is a file, not directory."""
        file_path = tmp_path / "not_a_dir.txt"
        file_path.write_text("hello")
        data = {
            "name": "Not A Dir",
            "external_path": str(file_path),
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        assert response.status_code == 400
        assert "not a directory" in response.json()["detail"].lower()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_create_external_folder_duplicate_path(self, async_client: AsyncClient, db_session, external_dir):
        """Verify 409 when same path already linked."""
        data = {
            "name": "First",
            "external_path": str(external_dir),
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        assert response.status_code == 200

        data["name"] = "Duplicate"
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        assert response.status_code == 409
        assert "already exists" in response.json()["detail"]

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_external_folder_appears_in_tree(self, async_client: AsyncClient, db_session, external_dir):
        """Verify external folder shows up in folder tree with external fields."""
        data = {
            "name": "My NAS",
            "external_path": str(external_dir),
            "readonly": True,
        }
        await async_client.post("/api/v1/library/folders/external", json=data)

        response = await async_client.get("/api/v1/library/folders")
        assert response.status_code == 200
        folders = response.json()
        ext_folder = next((f for f in folders if f["name"] == "My NAS"), None)
        assert ext_folder is not None
        assert ext_folder["is_external"] is True
        assert ext_folder["external_readonly"] is True


def find_folder_in_tree(folders: list, name: str) -> dict | None:
    """Recursively search a folder tree for a folder by name."""
    for f in folders:
        if f["name"] == name:
            return f
        result = find_folder_in_tree(f.get("children", []), name)
        if result:
            return result
    return None


def collect_folder_names(folders: list) -> list[str]:
    """Recursively collect all folder names from a tree."""
    names = []
    for f in folders:
        names.append(f["name"])
        names.extend(collect_folder_names(f.get("children", [])))
    return names


class TestExternalFolderScan:
    """Tests for POST /library/folders/{id}/scan."""

    @pytest.fixture
    def external_dir(self, tmp_path):
        """Create a temporary directory with test files."""
        ext_dir = tmp_path / "prints"
        ext_dir.mkdir()
        (ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
        (ext_dir / "bracket.stl").write_bytes(b"fakestl")
        (ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
        (ext_dir / "readme.txt").write_text("not a print file")
        (ext_dir / ".hidden.3mf").write_bytes(b"hidden")
        sub = ext_dir / "subfolder"
        sub.mkdir()
        (sub / "nested.stl").write_bytes(b"nested")
        return ext_dir

    @pytest.fixture
    async def external_folder(self, async_client, db_session, external_dir):
        """Create an external folder via API."""
        data = {
            "name": "Scan Test",
            "external_path": str(external_dir),
            "readonly": True,
            "show_hidden": False,
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        return response.json()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_discovers_files(self, async_client: AsyncClient, db_session, external_folder):
        """Verify scan discovers supported files and creates subfolders."""
        response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
        assert response.status_code == 200
        result = response.json()
        # Should find: benchy.3mf, bracket.stl, print.gcode (root) + subfolder/nested.stl
        # Should skip: readme.txt (unsupported), .hidden.3mf (hidden)
        assert result["added"] == 4
        assert result["removed"] == 0

        # Root folder should have 3 files (nested.stl is in subfolder)
        response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
        root_files = response.json()
        assert len(root_files) == 3
        root_filenames = {f["filename"] for f in root_files}
        assert root_filenames == {"benchy.3mf", "bracket.stl", "print.gcode"}

        # Subfolder should exist in the tree and contain nested.stl
        response = await async_client.get("/api/v1/library/folders")
        folders = response.json()
        subfolder = find_folder_in_tree(folders, "subfolder")
        assert subfolder is not None
        assert subfolder["is_external"] is True
        assert subfolder["parent_id"] == external_folder["id"]

        response = await async_client.get(f"/api/v1/library/files?folder_id={subfolder['id']}")
        sub_files = response.json()
        assert len(sub_files) == 1
        assert sub_files[0]["filename"] == "nested.stl"

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_skips_hidden_files(self, async_client: AsyncClient, db_session, external_folder):
        """Verify hidden files are skipped by default."""
        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")

        # List files in root folder
        response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
        assert response.status_code == 200
        files = response.json()
        filenames = [f["filename"] for f in files]
        assert ".hidden.3mf" not in filenames

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_shows_hidden_when_enabled(self, async_client: AsyncClient, db_session, external_dir):
        """Verify hidden files found when show_hidden=True."""
        data = {
            "name": "Show Hidden Test",
            "external_path": str(external_dir),
            "show_hidden": True,
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        folder = response.json()

        response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
        result = response.json()
        # Now should also find .hidden.3mf → 5 total
        assert result["added"] == 5

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_idempotent(self, async_client: AsyncClient, db_session, external_folder):
        """Verify scanning twice doesn't duplicate files."""
        response1 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
        assert response1.json()["added"] == 4

        response2 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
        assert response2.json()["added"] == 0
        assert response2.json()["removed"] == 0

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_removes_deleted_files(
        self, async_client: AsyncClient, db_session, external_folder, external_dir
    ):
        """Verify scan removes entries for files no longer on disk."""
        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")

        # Delete a file from disk
        (external_dir / "bracket.stl").unlink()

        response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
        result = response.json()
        assert result["removed"] == 1
        assert result["added"] == 0

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):
        """Verify scan fails on regular (non-external) folder."""
        # Create a regular folder
        data = {"name": "Regular Folder"}
        response = await async_client.post("/api/v1/library/folders", json=data)
        folder = response.json()

        response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
        assert response.status_code == 400
        assert "not an external" in response.json()["detail"].lower()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_files_marked_external(self, async_client: AsyncClient, db_session, external_folder):
        """Verify scanned files have is_external=True in root and subfolders."""
        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")

        # Check root folder files
        response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
        files = response.json()
        assert len(files) > 0
        for f in files:
            assert f["is_external"] is True

        # Check subfolder files
        response = await async_client.get("/api/v1/library/folders")
        folders = response.json()
        subfolder = find_folder_in_tree(folders, "subfolder")
        assert subfolder is not None
        response = await async_client.get(f"/api/v1/library/files?folder_id={subfolder['id']}")
        sub_files = response.json()
        for f in sub_files:
            assert f["is_external"] is True

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_creates_nested_subfolders(self, async_client: AsyncClient, db_session, external_dir):
        """Verify deeply nested directories create correct folder hierarchy."""
        # Create nested structure: deep/nested/dir/model.stl
        deep = external_dir / "deep" / "nested" / "dir"
        deep.mkdir(parents=True)
        (deep / "model.stl").write_bytes(b"deepstl")

        data = {
            "name": "Nested Test",
            "external_path": str(external_dir),
            "readonly": True,
            "show_hidden": False,
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        root = response.json()

        response = await async_client.post(f"/api/v1/library/folders/{root['id']}/scan")
        assert response.status_code == 200

        # Verify folder chain: root -> deep -> nested -> dir
        response = await async_client.get("/api/v1/library/folders")
        all_folders = response.json()

        deep = find_folder_in_tree(all_folders, "deep")
        assert deep is not None
        assert deep["parent_id"] == root["id"]
        assert deep["is_external"] is True

        nested = find_folder_in_tree(all_folders, "nested")
        assert nested is not None
        assert nested["parent_id"] == deep["id"]

        dir_folder = find_folder_in_tree(all_folders, "dir")
        assert dir_folder is not None
        assert dir_folder["parent_id"] == nested["id"]

        # model.stl should be in the "dir" folder
        response = await async_client.get(f"/api/v1/library/files?folder_id={dir_folder['id']}")
        files = response.json()
        assert len(files) == 1
        assert files[0]["filename"] == "model.stl"

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_skips_hidden_directories(self, async_client: AsyncClient, db_session, external_dir):
        """Verify hidden directories are skipped when show_hidden=False."""
        hidden_dir = external_dir / ".hidden_dir"
        hidden_dir.mkdir()
        (hidden_dir / "secret.stl").write_bytes(b"secret")

        data = {
            "name": "Hidden Dir Test",
            "external_path": str(external_dir),
            "readonly": True,
            "show_hidden": False,
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        root = response.json()

        response = await async_client.post(f"/api/v1/library/folders/{root['id']}/scan")
        result = response.json()
        # Should find 4 files (root 3 + subfolder/nested.stl) but NOT .hidden_dir/secret.stl
        assert result["added"] == 4

        # No ".hidden_dir" folder should be created
        response = await async_client.get("/api/v1/library/folders")
        folder_names = collect_folder_names(response.json())
        assert ".hidden_dir" not in folder_names

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_removes_deleted_subfolder(
        self, async_client: AsyncClient, db_session, external_folder, external_dir
    ):
        """Verify scan removes empty subfolder entries when directory deleted from disk."""
        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")

        # Verify subfolder exists
        response = await async_client.get("/api/v1/library/folders")
        subfolder = find_folder_in_tree(response.json(), "subfolder")
        assert subfolder is not None

        # Delete the subfolder from disk
        import shutil

        shutil.rmtree(external_dir / "subfolder")

        # Re-scan
        response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
        result = response.json()
        assert result["removed"] == 1  # nested.stl removed

        # Subfolder should be cleaned up (empty + directory gone)
        response = await async_client.get("/api/v1/library/folders")
        subfolder = find_folder_in_tree(response.json(), "subfolder")
        assert subfolder is None

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_scan_subfolder_inherits_readonly(
        self, async_client: AsyncClient, db_session, external_folder, external_dir
    ):
        """Verify created subfolders inherit external_readonly from parent."""
        await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")

        response = await async_client.get("/api/v1/library/folders")
        subfolder = find_folder_in_tree(response.json(), "subfolder")
        assert subfolder is not None
        assert subfolder["external_readonly"] is True


class TestExternalFolderProtections:
    """Tests for read-only protections on external folders."""

    @pytest.fixture
    def external_dir(self, tmp_path):
        ext_dir = tmp_path / "readonly_share"
        ext_dir.mkdir()
        (ext_dir / "test.stl").write_bytes(b"fakestl")
        return ext_dir

    @pytest.fixture
    async def readonly_folder(self, async_client, db_session, external_dir):
        """Create a read-only external folder with files scanned."""
        data = {
            "name": "Read Only",
            "external_path": str(external_dir),
            "readonly": True,
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        folder = response.json()
        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
        return folder

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_upload_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
        """Verify uploads to read-only external folders are blocked."""
        import io

        file_content = io.BytesIO(b"test content")
        response = await async_client.post(
            f"/api/v1/library/files?folder_id={readonly_folder['id']}",
            files={"file": ("test.gcode", file_content, "application/octet-stream")},
        )
        assert response.status_code == 403
        assert "read-only" in response.json()["detail"].lower()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_move_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
        """Verify moving files to read-only external folder is blocked."""
        from backend.app.models.library import LibraryFile

        # Create a regular file
        lib_file = LibraryFile(
            filename="regular.3mf",
            file_path="/test/regular.3mf",
            file_size=1024,
            file_type="3mf",
        )
        db_session.add(lib_file)
        await db_session.commit()
        await db_session.refresh(lib_file)

        data = {"file_ids": [lib_file.id], "folder_id": readonly_folder["id"]}
        response = await async_client.post("/api/v1/library/files/move", json=data)
        assert response.status_code == 403
        assert "read-only" in response.json()["detail"].lower()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_external_files_cannot_be_moved_out(self, async_client: AsyncClient, db_session, readonly_folder):
        """Verify external files can't be moved to other folders."""
        # Get the external file ID
        response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
        files = response.json()
        assert len(files) > 0
        ext_file_id = files[0]["id"]

        # Try to move to root
        data = {"file_ids": [ext_file_id], "folder_id": None}
        response = await async_client.post("/api/v1/library/files/move", json=data)
        assert response.status_code == 200
        # File should be skipped, not moved
        result = response.json()
        assert result["moved"] == 0

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_delete_external_file_removes_db_only(
        self, async_client: AsyncClient, db_session, readonly_folder, external_dir
    ):
        """Verify deleting an external file only removes DB entry, not the file on disk."""
        response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
        files = response.json()
        ext_file_id = files[0]["id"]
        ext_filename = files[0]["filename"]

        # Delete via API
        response = await async_client.delete(f"/api/v1/library/files/{ext_file_id}")
        assert response.status_code == 200

        # File should still exist on disk
        assert (external_dir / ext_filename).exists()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_delete_external_folder_preserves_files(
        self, async_client: AsyncClient, db_session, readonly_folder, external_dir
    ):
        """Verify deleting an external folder doesn't delete files from disk."""
        response = await async_client.delete(f"/api/v1/library/folders/{readonly_folder['id']}")
        assert response.status_code == 200

        # Files should still exist on disk
        assert (external_dir / "test.stl").exists()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_zip_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
        """Verify ZIP extraction to read-only external folder is blocked."""
        import io
        import zipfile

        # Create a minimal zip
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w") as zf:
            zf.writestr("test.stl", b"fakestl")
        buf.seek(0)

        response = await async_client.post(
            f"/api/v1/library/files/extract-zip?folder_id={readonly_folder['id']}",
            files={"file": ("test.zip", buf, "application/zip")},
        )
        assert response.status_code == 403
        assert "read-only" in response.json()["detail"].lower()


class TestExternalFolderWritableUpload:
    """Tests for upload write-through to writable external folders (#1112).

    Before the fix, uploads to writable external folders silently landed in the
    internal library dir while the DB row pointed at the external folder —
    files were invisible when the mount was viewed from another machine.
    """

    @pytest.fixture
    def external_dir(self, tmp_path):
        ext_dir = tmp_path / "writable_share"
        ext_dir.mkdir()
        return ext_dir

    @pytest.fixture
    async def writable_folder(self, async_client, db_session, external_dir):
        data = {
            "name": "Writable NAS",
            "external_path": str(external_dir),
            "readonly": False,
        }
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        assert response.status_code == 200
        return response.json()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_upload_lands_on_external_mount(
        self, async_client: AsyncClient, db_session, writable_folder, external_dir
    ):
        """Bytes are written to ``<external_path>/<filename>``, not the internal library dir."""
        import io

        content = b"hello-external-world"
        response = await async_client.post(
            f"/api/v1/library/files?folder_id={writable_folder['id']}",
            files={"file": ("upload.stl", io.BytesIO(content), "application/octet-stream")},
        )
        assert response.status_code == 200, response.text

        on_disk = external_dir / "upload.stl"
        assert on_disk.exists(), "file must be written to the external mount"
        assert on_disk.read_bytes() == content

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_upload_persists_correct_db_shape(
        self, async_client: AsyncClient, db_session, writable_folder, external_dir
    ):
        """DB row must have ``is_external=True`` and ``file_path`` = absolute external path,
        so scan-dedupe and deletion behaviour match scanned files."""
        import io
        import zipfile

        from backend.app.models.library import LibraryFile

        # #1401 hardened the library upload route to reject .3mf files that
        # aren't valid ZIP containers. This test asserts external-folder
        # DB shape, not the upload validator, so feed it a minimal real zip
        # rather than placeholder bytes.
        zip_buf = io.BytesIO()
        with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("placeholder.txt", "")
        zip_buf.seek(0)

        response = await async_client.post(
            f"/api/v1/library/files?folder_id={writable_folder['id']}",
            files={"file": ("model.3mf", zip_buf, "application/octet-stream")},
        )
        assert response.status_code == 200
        file_id = response.json()["id"]

        row = await db_session.get(LibraryFile, file_id)
        await db_session.refresh(row)
        assert row.is_external is True
        assert row.file_path == str((external_dir / "model.3mf").resolve())

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_upload_filename_collision_returns_409(
        self, async_client: AsyncClient, db_session, writable_folder, external_dir
    ):
        """Re-uploading a filename that already exists on the mount must 409,
        not silently overwrite — matches scan's treatment of external files as
        externally-owned bytes."""
        import io

        (external_dir / "already.stl").write_bytes(b"prior")
        response = await async_client.post(
            f"/api/v1/library/files?folder_id={writable_folder['id']}",
            files={"file": ("already.stl", io.BytesIO(b"new"), "application/octet-stream")},
        )
        assert response.status_code == 409
        assert (external_dir / "already.stl").read_bytes() == b"prior"

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_upload_to_missing_external_path_returns_400(
        self, async_client: AsyncClient, db_session, writable_folder, external_dir
    ):
        """If the external mount has gone away between folder-create and
        upload, fail loud rather than silently misroute to internal storage."""
        import io
        import shutil

        shutil.rmtree(external_dir)

        response = await async_client.post(
            f"/api/v1/library/files?folder_id={writable_folder['id']}",
            files={"file": ("x.stl", io.BytesIO(b"x"), "application/octet-stream")},
        )
        assert response.status_code == 400
        assert "not accessible" in response.json()["detail"].lower()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_upload_rejects_path_traversal_filename(
        self, async_client: AsyncClient, db_session, writable_folder, external_dir
    ):
        """A malicious filename like ``../escape.stl`` must not write outside
        the external folder. Defence-in-depth — FastAPI already strips these
        on parse, but the resolve-and-relative_to guard is the final gate."""
        import io

        response = await async_client.post(
            f"/api/v1/library/files?folder_id={writable_folder['id']}",
            files={"file": ("../escape.stl", io.BytesIO(b"x"), "application/octet-stream")},
        )
        # Either a 400 from our traversal guard or a 200 with basename-stripped
        # filename inside the external dir — both prove nothing escaped.
        if response.status_code == 200:
            assert not (external_dir.parent / "escape.stl").exists()
            assert (external_dir / "escape.stl").exists() or (external_dir / "..escape.stl").exists()
        else:
            assert response.status_code in (400, 422)
            assert not (external_dir.parent / "escape.stl").exists()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_zip_to_writable_external_folder_rejected(
        self, async_client: AsyncClient, db_session, writable_folder
    ):
        """Extract-zip into writable external folders isn't supported (nested
        subfolder creation on the mount is a separate design). Users are
        pointed at the Scan flow instead."""
        import io
        import zipfile

        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w") as zf:
            zf.writestr("a/b/c.stl", b"x")
        buf.seek(0)

        response = await async_client.post(
            f"/api/v1/library/files/extract-zip?folder_id={writable_folder['id']}",
            files={"file": ("test.zip", buf, "application/zip")},
        )
        assert response.status_code == 400
        assert "scan" in response.json()["detail"].lower()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_non_external_upload_unchanged(self, async_client: AsyncClient, db_session):
        """Uploads with no folder_id (root) keep the existing internal-storage behaviour."""
        import io

        from backend.app.models.library import LibraryFile

        response = await async_client.post(
            "/api/v1/library/files",
            files={"file": ("root.stl", io.BytesIO(b"x"), "application/octet-stream")},
        )
        assert response.status_code == 200
        file_id = response.json()["id"]
        row = await db_session.get(LibraryFile, file_id)
        await db_session.refresh(row)
        assert row.is_external is False
        # Internal storage: file_path is UUID-scoped, stored as a relative path.
        assert not row.file_path.startswith("/")


class TestCrossBoundaryMove:
    """#1112 follow-up: moving files between managed and external folders
    must physically relocate the bytes, not just shuffle the DB ``folder_id``.

    Pre-fix symptom (reported by @Carter3DP after testing 0.2.4b1): a file
    moved from a managed folder to a NAS-backed external folder showed up
    in Bambuddy's UI under the external folder but was never written to
    the NAS — so the SMB mount and Bambuddy disagreed about what was
    actually there.
    """

    @pytest.fixture
    def external_dir(self, tmp_path):
        ext_dir = tmp_path / "writable_share"
        ext_dir.mkdir()
        return ext_dir

    @pytest.fixture
    async def writable_folder(self, async_client, db_session, external_dir):
        data = {"name": "Writable NAS", "external_path": str(external_dir), "readonly": False}
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        assert response.status_code == 200
        return response.json()

    @pytest.fixture
    async def readonly_folder(self, async_client, db_session, tmp_path):
        ro_dir = tmp_path / "ro_share"
        ro_dir.mkdir()
        (ro_dir / "stranded.gcode").write_text("G28")
        data = {"name": "Read-only NAS", "external_path": str(ro_dir), "readonly": True}
        response = await async_client.post("/api/v1/library/folders/external", json=data)
        assert response.status_code == 200
        # Populate via scan so the file gets a DB row with is_external=True.
        scan = await async_client.post(f"/api/v1/library/folders/{response.json()['id']}/scan")
        assert scan.status_code == 200
        return response.json()

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_managed_to_external_relocates_bytes(
        self, async_client: AsyncClient, db_session, writable_folder, external_dir
    ):
        """The actual #1112 fix: managed → external must write the bytes
        to the NAS mount AND drop them from internal storage. Pre-fix the
        DB row flipped to the new folder but the bytes stayed put."""
        import io

        from backend.app.api.routes.library import to_absolute_path
        from backend.app.models.library import LibraryFile

        upload = await async_client.post(
            "/api/v1/library/files",
            files={"file": ("ship_me.stl", io.BytesIO(b"original-bytes"), "application/octet-stream")},
        )
        assert upload.status_code == 200
        file_id = upload.json()["id"]

        # Snapshot the pre-move on-disk path so we can verify it's gone after.
        pre = await db_session.get(LibraryFile, file_id)
        await db_session.refresh(pre)
        managed_disk_path = to_absolute_path(pre.file_path)
        assert managed_disk_path is not None and managed_disk_path.exists()

        response = await async_client.post(
            "/api/v1/library/files/move",
            json={"file_ids": [file_id], "folder_id": writable_folder["id"]},
        )
        assert response.status_code == 200, response.text
        body = response.json()
        assert body["moved"] == 1
        assert body["skipped"] == 0

        # Bytes are on the NAS mount.
        on_nas = external_dir / "ship_me.stl"
        assert on_nas.exists()
        assert on_nas.read_bytes() == b"original-bytes"

        # Internal copy is gone.
        assert not managed_disk_path.exists(), "managed source must be removed after the move"

        # DB row matches reality.
        await db_session.refresh(pre)
        assert pre.is_external is True
        assert pre.folder_id == writable_folder["id"]
        assert pre.file_path == str(on_nas.resolve())

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_external_to_managed_relocates_bytes(
        self, async_client: AsyncClient, db_session, writable_folder, external_dir
    ):
        """Symmetric direction: external → managed copies the bytes into
        internal storage with a UUID name, deletes the source on the
        mount, and recomputes the file hash (since scan stores
        ``file_hash=None`` for external rows)."""
        import io

        from backend.app.models.library import LibraryFile

        # Plant a file on the writable mount and let upload give it a row.
        upload = await async_client.post(
            f"/api/v1/library/files?folder_id={writable_folder['id']}",
            files={"file": ("relocate_me.stl", io.BytesIO(b"nas-bytes"), "application/octet-stream")},
        )
        assert upload.status_code == 200
        file_id = upload.json()["id"]
        ext_disk = external_dir / "relocate_me.stl"
        assert ext_disk.exists()

        response = await async_client.post(
            "/api/v1/library/files/move",
            json={"file_ids": [file_id], "folder_id": None},
        )
        assert response.status_code == 200
        assert response.json()["moved"] == 1

        db_session.expire_all()
        row = await db_session.get(LibraryFile, file_id)
        assert row.is_external is False
        assert row.folder_id is None
        assert not row.file_path.startswith("/"), "managed file_path must be relative"
        assert not ext_disk.exists(), "external source must be removed after the move"
        # Hash filled in for the now-managed row so future dedup works.
        assert row.file_hash is not None and len(row.file_hash) == 64

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_managed_to_external_collision_skips_with_reason(
        self, async_client: AsyncClient, db_session, writable_folder, external_dir
    ):
        """A name collision on the target external mount must skip the
        move with a structured reason — not silently overwrite a file
        that's already on the NAS."""
        import io

        # Pre-existing file on the mount with the same name as the upload.
        (external_dir / "duplicate.stl").write_bytes(b"pre-existing")

        upload = await async_client.post(
            "/api/v1/library/files",
            files={"file": ("duplicate.stl", io.BytesIO(b"new-bytes"), "application/octet-stream")},
        )
        assert upload.status_code == 200
        file_id = upload.json()["id"]

        response = await async_client.post(
            "/api/v1/library/files/move",
            json={"file_ids": [file_id], "folder_id": writable_folder["id"]},
        )
        assert response.status_code == 200
        body = response.json()
        assert body["moved"] == 0
        assert body["skipped"] == 1
        reasons = body["skipped_reasons"]
        assert len(reasons) == 1
        assert reasons[0]["file_id"] == file_id
        assert reasons[0]["code"] == "name_collision"
        # Pre-existing target file is intact.
        assert (external_dir / "duplicate.stl").read_bytes() == b"pre-existing"

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_external_readonly_source_skips(self, async_client: AsyncClient, db_session, readonly_folder):
        """A read-only mount allows reading but not deletes, and a move
        is semantically a delete on the source. Skip with
        ``source_readonly`` so the file isn't duplicated by half-moving."""
        listing = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
        assert listing.status_code == 200
        ext_file_id = listing.json()[0]["id"]

        response = await async_client.post(
            "/api/v1/library/files/move",
            json={"file_ids": [ext_file_id], "folder_id": None},
        )
        assert response.status_code == 200
        body = response.json()
        assert body["moved"] == 0
        assert body["skipped"] == 1
        assert body["skipped_reasons"][0]["code"] == "source_readonly"

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_managed_to_managed_remains_db_only(self, async_client: AsyncClient, db_session):
        """Same-boundary moves (managed → managed) keep the existing
        DB-only fast path — no shutil.copy, no UUID rename. The original
        file_path stays the same, only ``folder_id`` changes."""
        import io

        from backend.app.models.library import LibraryFile

        sub = await async_client.post(
            "/api/v1/library/folders",
            json={"name": "subfolder", "parent_id": None},
        )
        assert sub.status_code == 200
        target_id = sub.json()["id"]

        upload = await async_client.post(
            "/api/v1/library/files",
            files={"file": ("part.stl", io.BytesIO(b"x"), "application/octet-stream")},
        )
        assert upload.status_code == 200
        file_id = upload.json()["id"]
        pre = await db_session.get(LibraryFile, file_id)
        await db_session.refresh(pre)
        original_path = pre.file_path

        response = await async_client.post(
            "/api/v1/library/files/move",
            json={"file_ids": [file_id], "folder_id": target_id},
        )
        assert response.status_code == 200
        assert response.json()["moved"] == 1

        db_session.expire_all()
        post = await db_session.get(LibraryFile, file_id)
        assert post.folder_id == target_id
        assert post.is_external is False
        assert post.file_path == original_path  # bytes never moved

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_skipped_reasons_field_present_even_when_empty(self, async_client: AsyncClient, db_session):
        """Backwards-compatible response shape: ``skipped_reasons`` is
        always present (empty list when nothing skipped) so frontend
        code can treat it as the source of truth without optional-chain
        gymnastics."""
        import io

        upload = await async_client.post(
            "/api/v1/library/files",
            files={"file": ("trivial.stl", io.BytesIO(b"x"), "application/octet-stream")},
        )
        assert upload.status_code == 200
        file_id = upload.json()["id"]

        response = await async_client.post(
            "/api/v1/library/files/move",
            json={"file_ids": [file_id], "folder_id": None},
        )
        assert response.status_code == 200
        body = response.json()
        assert "skipped_reasons" in body
        assert body["skipped_reasons"] == []
