import logging
import stat
import platform
import shutil
import tempfile
import typing
import unittest
from pathlib import Path, PurePosixPath
from unittest import mock

from blender_asset_tracer import blendfile, pack, bpathlib
from blender_asset_tracer.pack import progress
from tests.abstract_test import AbstractBlendFileTest


class AbstractPackTest(AbstractBlendFileTest):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        logging.getLogger("blender_asset_tracer.compressor").setLevel(logging.DEBUG)
        logging.getLogger("blender_asset_tracer.pack").setLevel(logging.DEBUG)
        logging.getLogger("blender_asset_tracer.blendfile.open_cached").setLevel(
            logging.DEBUG
        )
        logging.getLogger("blender_asset_tracer.blendfile.open_cached").setLevel(
            logging.DEBUG
        )
        logging.getLogger("blender_asset_tracer.blendfile.BlendFile").setLevel(
            logging.DEBUG
        )

    def setUp(self):
        super().setUp()
        self.tdir = tempfile.TemporaryDirectory(suffix="-packtest")
        self.tpath = Path(self.tdir.name)

    def tearDown(self):
        super().tearDown()
        self.tdir.cleanup()

    @staticmethod
    def rewrites(packer: pack.Packer):
        return {
            path: action.rewrites
            for path, action in packer._actions.items()
            if action.rewrites
        }

    def outside_project(self) -> Path:
        """Return the '_outside_project' path for files in self.blendfiles."""
        # /tmp/target + /workspace/bat/tests/blendfiles → /tmp/target/workspace/bat/tests/blendfiles
        # /tmp/target + C:\workspace\bat\tests\blendfiles → /tmp/target/C/workspace/bat/tests/blendfiles
        extpath = Path(
            self.tpath, "_outside_project", bpathlib.strip_root(self.blendfiles)
        )
        return extpath


class PackTest(AbstractPackTest):
    def test_strategise_no_rewrite_required(self):
        infile = self.blendfiles / "doubly_linked.blend"

        packer = pack.Packer(infile, self.blendfiles, self.tpath)
        packer.strategise()

        packed_files = (
            "doubly_linked.blend",
            "linked_cube.blend",
            "basic_file.blend",
            "material_textures.blend",
            "textures/Bricks/brick_dotted_04-bump.jpg",
            "textures/Bricks/brick_dotted_04-color.jpg",
        )
        for pf in packed_files:
            path = self.blendfiles / pf
            act = packer._actions[path]
            self.assertEqual(pack.PathAction.KEEP_PATH, act.path_action, "for %s" % pf)
            self.assertEqual(self.tpath / pf, act.new_path, "for %s" % pf)

        self.assertEqual({}, self.rewrites(packer))
        self.assertEqual(len(packed_files), len(packer._actions))

    def test_strategise_rewrite(self):
        ppath = self.blendfiles / "subdir"
        infile = ppath / "doubly_linked_up-windows.blend"

        packer = pack.Packer(infile, ppath, self.tpath)
        packer.strategise()

        external_files = (
            "linked_cube.blend",
            "basic_file.blend",
            "material_textures.blend",
            "textures/Bricks/brick_dotted_04-bump.jpg",
            "textures/Bricks/brick_dotted_04-color.jpg",
        )
        extpath = self.outside_project()

        act = packer._actions[infile]
        self.assertEqual(
            pack.PathAction.KEEP_PATH, act.path_action, "for %s" % infile.name
        )
        self.assertEqual(self.tpath / infile.name, act.new_path, "for %s" % infile.name)
        for fn in external_files:
            path = self.blendfiles / fn
            act = packer._actions[path]
            self.assertEqual(
                pack.PathAction.FIND_NEW_LOCATION, act.path_action, "for %s" % fn
            )
            self.assertEqual(
                extpath / fn,
                act.new_path,
                f"\nEXPECT: {extpath / fn}\nACTUAL: {act.new_path}\nfor {fn}",
            )

        to_rewrite = (
            "linked_cube.blend",
            "material_textures.blend",
            str(infile.relative_to(self.blendfiles)),
        )
        rewrites = self.rewrites(packer)
        self.assertEqual(
            [self.blendfiles / fn for fn in to_rewrite], sorted(rewrites.keys())
        )

        # Library link referencing basic_file.blend should (maybe) be rewritten.
        rw_linked_cube = rewrites[self.blendfiles / "linked_cube.blend"]
        self.assertEqual(1, len(rw_linked_cube))
        self.assertEqual(b"LILib", rw_linked_cube[0].block_name)
        self.assertEqual(b"//basic_file.blend", rw_linked_cube[0].asset_path)

        # Texture links to image assets should (maybe) be rewritten.
        rw_mattex = rewrites[self.blendfiles / "material_textures.blend"]
        self.assertEqual(2, len(rw_mattex))
        rw_mattex.sort()  # for repeatable tests
        self.assertEqual(b"IMbrick_dotted_04-bump", rw_mattex[0].block_name)
        self.assertEqual(
            b"//textures/Bricks/brick_dotted_04-bump.jpg", rw_mattex[0].asset_path
        )
        self.assertEqual(b"IMbrick_dotted_04-color", rw_mattex[1].block_name)
        self.assertEqual(
            b"//textures/Bricks/brick_dotted_04-color.jpg", rw_mattex[1].asset_path
        )

        # Library links from doubly_linked_up.blend to the above to blend files should be rewritten.
        rw_dbllink = rewrites[infile]
        self.assertEqual(2, len(rw_dbllink))
        rw_dbllink.sort()  # for repeatable tests
        self.assertEqual(b"LILib", rw_dbllink[0].block_name)
        self.assertEqual(b"//../linked_cube.blend", rw_dbllink[0].asset_path)
        self.assertEqual(b"LILib.002", rw_dbllink[1].block_name)
        self.assertEqual(b"//../material_textures.blend", rw_dbllink[1].asset_path)

    def test_strategise_relative_only(self):
        infile = self.blendfiles / "absolute_path.blend"

        packer = pack.Packer(infile, self.blendfiles, self.tpath, relative_only=True)
        packer.strategise()

        packed_files = (
            "absolute_path.blend",
            # Linked with a relative path:
            "textures/Bricks/brick_dotted_04-color.jpg",
            # This file links to textures/Textures/Buildings/buildings_roof_04-color.png,
            # but using an absolute path, so that file should be skipped.
        )
        for pf in packed_files:
            path = self.blendfiles / pf
            act = packer._actions[path]
            self.assertEqual(pack.PathAction.KEEP_PATH, act.path_action, "for %s" % pf)
            self.assertEqual(self.tpath / pf, act.new_path, "for %s" % pf)

        self.assertEqual(len(packed_files), len(packer._actions))
        self.assertEqual({}, self.rewrites(packer))

    def test_execute_rewrite_no_touch_origs(self):
        infile, _ = self._pack_with_rewrite()

        # The original file shouldn't be touched.
        bfile = blendfile.open_cached(infile, assert_cached=False)
        libs = sorted(bfile.code_index[b"LI"])

        self.assertEqual(b"LILib", libs[0].id_name)
        self.assertEqual(b"//../linked_cube.blend", libs[0][b"name"])
        self.assertEqual(b"LILib.002", libs[1].id_name)
        self.assertEqual(b"//../material_textures.blend", libs[1][b"name"])

    def test_execute_rewrite(self):
        infile, _ = self._pack_with_rewrite()

        if platform.system() == "Windows":
            extpath = PurePosixPath(
                "//_outside_project",
                self.blendfiles.drive[0],
                *self.blendfiles.parts[1:],
            )
        else:
            extpath = PurePosixPath("//_outside_project", *self.blendfiles.parts[1:])
        extbpath = bpathlib.BlendPath(extpath)

        # Those libraries should be properly rewritten.
        bfile = blendfile.open_cached(self.tpath / infile.name, assert_cached=False)
        libs = sorted(bfile.code_index[b"LI"])
        self.assertEqual(b"LILib", libs[0].id_name)
        self.assertEqual(extbpath / b"linked_cube.blend", libs[0][b"name"])
        self.assertEqual(b"LILib.002", libs[1].id_name)
        self.assertEqual(extbpath / b"material_textures.blend", libs[1][b"name"])

    def test_execute_rewrite_cleanup(self):
        infile, packer = self._pack_with_rewrite()

        # Rewritten blend files shouldn't be in the temp directory any more;
        # they should have been moved to the final directory (not copied).
        self.assertTrue(packer._rewrite_in.exists())
        self.assertEqual([], list(packer._rewrite_in.iterdir()))

        # After closing the packer, the tempdir should also be gone.
        packer.close()
        self.assertFalse(packer._rewrite_in.exists())

    def test_execute_rewrite_readonly_files(self):
        ppath = self.blendfiles / "subdir"
        infile = ppath / "doubly_linked_up.blend"

        # Make the input file read-only for everybody (owner, group, world).
        orig_mode = infile.stat().st_mode
        infile.chmod(stat.S_IREAD | stat.S_IRGRP | stat.S_IROTH)
        try:
            packer = pack.Packer(infile, ppath, self.tpath)
            packer.strategise()
            packer.execute()
        finally:
            # Restore the original file permissions.
            infile.chmod(orig_mode)

        if platform.system() == "Windows":
            extpath = PurePosixPath(
                "//_outside_project",
                self.blendfiles.drive[0],
                *self.blendfiles.parts[1:],
            )
        else:
            extpath = PurePosixPath("//_outside_project", *self.blendfiles.parts[1:])
        extbpath = bpathlib.BlendPath(extpath)

        # Those libraries should be properly rewritten.
        bfile = blendfile.open_cached(self.tpath / infile.name, assert_cached=False)
        libs = sorted(bfile.code_index[b"LI"])
        self.assertEqual(b"LILib", libs[0].id_name)
        self.assertEqual(extbpath / b"linked_cube.blend", libs[0][b"name"])
        self.assertEqual(b"LILib.002", libs[1].id_name)
        self.assertEqual(extbpath / b"material_textures.blend", libs[1][b"name"])

    @unittest.skipIf(
        platform.system() == "Windows",
        "Symlinks on Windows require Administrator rights",
    )
    def test_symlinked_files(self):
        """Test that symlinks are NOT resolved.

        When packing, an asset that is symlinked should be treated as if it
        were really at that location. Symlinks should NOT be resolved.

        As a concrete example, a directory structure with only symlinked files
        in it should still be BAT-packable and produce the same structure.
        """
        orig_ppath = self.blendfiles / "subdir"

        # This is the original structure when packing subdir/doubly_linked_up.blend:
        #   .
        #   ├── basic_file.blend
        #   ├── linked_cube.blend
        #   ├── material_textures.blend
        #   ├── subdir
        #   │   └── doubly_linked_up.blend
        #   └── textures
        #       └── Bricks
        #           ├── brick_dotted_04-bump.jpg
        #           └── brick_dotted_04-color.jpg

        # This test copies the files to a temporary location and renames them,
        # then recreates the above structure with symlinks. Packing the symlinks
        # should be no different than packing the originals.

        orig_paths = [
            Path("basic_file.blend"),
            Path("linked_cube.blend"),
            Path("material_textures.blend"),
            Path("subdir/doubly_linked_up.blend"),
            Path("textures/Bricks/brick_dotted_04-bump.jpg"),
            Path("textures/Bricks/brick_dotted_04-color.jpg"),
        ]

        import hashlib

        with tempfile.TemporaryDirectory(suffix="-bat-symlink") as tmpdir_str:
            tmpdir = Path(tmpdir_str)

            real_file_dir = tmpdir / "real"
            symlinked_dir = tmpdir / "symlinked"

            real_file_dir.mkdir()
            symlinked_dir.mkdir()

            for orig_path in orig_paths:
                hashed_name = hashlib.new("md5", bytes(orig_path)).hexdigest()
                # Copy the file to the temporary project, under a hashed name.
                # This will break Blendfile linking.
                real_file_path = real_file_dir / hashed_name
                shutil.copy(self.blendfiles / orig_path, real_file_path)

                # Create a symlink to the above file, in such a way that it
                # restores the original directory structure, and thus repairs
                # the Blendfile linking.
                symlink = symlinked_dir / orig_path
                symlink.parent.mkdir(parents=True, exist_ok=True)
                symlink.symlink_to(real_file_path)

            # Pack the symlinked directory structure.
            pack_dir = tmpdir / "packed"
            packer = pack.Packer(
                self.blendfiles / "subdir/doubly_linked_up.blend",
                self.blendfiles,
                pack_dir,
            )
            packer.strategise()
            packer.execute()

            for orig_path in orig_paths:
                packed_path = pack_dir / orig_path
                self.assertTrue(packed_path.exists(), f"{packed_path} should exist")

    def _pack_with_rewrite(self):
        ppath = self.blendfiles / "subdir"
        infile = ppath / "doubly_linked_up.blend"

        packer = pack.Packer(infile, ppath, self.tpath)
        packer.strategise()
        packer.execute()

        return infile, packer

    def test_rewrite_sequence(self):
        ppath = self.blendfiles / "subdir"
        infile = ppath / "image_sequence_dir_up.blend"

        with pack.Packer(infile, ppath, self.tpath) as packer:
            packer.strategise()
            packer.execute()

        bf = blendfile.open_cached(self.tpath / infile.name, assert_cached=False)
        scene = bf.code_index[b"SC"][0]
        ed = scene.get_pointer(b"ed")
        seq = ed.get_pointer((b"seqbase", b"first"))
        seq_strip = seq.get_pointer(b"strip")

        imgseq_path = bpathlib.make_absolute(self.blendfiles / "imgseq")
        print(f"imgseq_path: {imgseq_path!r}")
        print(f"     anchor: {imgseq_path.anchor!r}")
        as_bytes = bpathlib.strip_root(imgseq_path).as_posix().encode()
        print(f"as_bytes: {as_bytes!r}")
        relpath = bpathlib.BlendPath(b"//_outside_project") / as_bytes
        print(f"relpath: {relpath!r}")

        # The image sequence base path should be rewritten.
        self.assertEqual(b"SQ000210.png", seq[b"name"])
        self.assertEqual(relpath, seq_strip[b"dir"])

    def test_sequence_udim(self):
        # UDIM tiles are special, because the filename itself has a <UDIM>
        # marker in there and thus doesn't exist itself.
        ppath = self.blendfiles / "udim"
        infile = ppath / "v01_UDIM_BAT_debugging.blend"

        with pack.Packer(infile, ppath, self.tpath) as packer:
            packer.strategise()
            packer.execute()

        # The UDIM files should have been copied.
        self.assertTrue((self.tpath / "cube_UDIM.color.1001.png").exists())
        self.assertTrue((self.tpath / "cube_UDIM.color.1002.png").exists())
        self.assertTrue((self.tpath / "cube_UDIM.color.1003.png").exists())

    def test_noop(self):
        ppath = self.blendfiles / "subdir"
        infile = ppath / "doubly_linked_up.blend"

        packer = pack.Packer(infile, ppath, self.tpath, noop=True)
        packer.strategise()
        packer.execute()

        self.assertEqual([], list(self.tpath.iterdir()))

        # The original file shouldn't be touched.
        bfile = blendfile.open_cached(infile)
        libs = sorted(bfile.code_index[b"LI"])

        self.assertEqual(b"LILib", libs[0].id_name)
        self.assertEqual(b"//../linked_cube.blend", libs[0][b"name"])
        self.assertEqual(b"LILib.002", libs[1].id_name)
        self.assertEqual(b"//../material_textures.blend", libs[1][b"name"])

    def test_missing_files(self):
        infile = self.blendfiles / "missing_textures.blend"
        packer = pack.Packer(infile, self.blendfiles, self.tpath)
        packer.strategise()

        self.assertEqual(
            [
                self.blendfiles
                / "textures/HDRI/Myanmar/Golden Palace 2, Old Bagan-1k.exr",
                self.blendfiles
                / "textures/Textures/Marble/marble_decoration-color.png",
            ],
            sorted(packer.missing_files),
        )

    def test_exclude_filter(self):
        # Files shouldn't be reported missing if they should be ignored.
        infile = self.blendfiles / "image_sequencer.blend"
        with pack.Packer(infile, self.blendfiles, self.tpath) as packer:
            packer.exclude("*.png", "*.nonsense")
            packer.strategise()
            packer.execute()

        self.assertFalse((self.tpath / "imgseq").exists())

    def test_exclude_filter_missing_files(self):
        # Files shouldn't be reported missing if they should be ignored.
        infile = self.blendfiles / "missing_textures.blend"
        with pack.Packer(infile, self.blendfiles, self.tpath) as packer:
            packer.exclude("*.png")
            packer.strategise()

        self.assertEqual(
            [
                self.blendfiles
                / "textures/HDRI/Myanmar/Golden Palace 2, Old Bagan-1k.exr"
            ],
            list(packer.missing_files),
        )

    def test_output_path(self):
        infile = self.blendfiles / "basic_file.blend"
        packer = pack.Packer(infile, self.blendfiles.parent, self.tpath)
        packer.strategise()

        self.assertEqual(
            self.tpath / self.blendfiles.name / infile.name, packer.output_path
        )

    def test_infofile(self):
        blendname = "subdir/doubly_linked_up.blend"
        infile = self.blendfiles / blendname

        packer = pack.Packer(infile, self.blendfiles, self.tpath)
        packer.strategise()
        packer.execute()

        infopath = self.tpath / "pack-info.txt"
        self.assertTrue(infopath.exists())
        info = infopath.open().read().splitlines(keepends=False)
        self.assertEqual(blendname, info[-1].strip())

    def test_compression(self):
        blendname = "subdir/doubly_linked_up.blend"
        imgfile = self.blendfiles / blendname

        packer = pack.Packer(imgfile, self.blendfiles, self.tpath, compress=True)
        packer.strategise()
        packer.execute()

        dest = self.tpath / blendname
        self.assertTrue(dest.exists())
        self.assertTrue(blendfile.open_cached(dest).is_compressed)

        for bpath in self.tpath.rglob("*.blend"):
            if bpath == dest:
                # Only test files that were bundled as dependency; the main
                # file was tested above already.
                continue
            self.assertTrue(
                blendfile.open_cached(bpath).is_compressed,
                "Expected %s to be compressed" % bpath,
            )
            break
        else:
            self.fail(f"Expected to have Blend files in the BAT pack at {self.tpath}.")

        for imgpath in self.tpath.rglob("*.jpg"):
            with imgpath.open("rb") as imgfile:
                magic = imgfile.read(3)
            self.assertEqual(
                b"\xFF\xD8\xFF", magic, "Expected %s to NOT be compressed" % imgpath
            )
            break
        else:
            self.fail(f"Expected to have JPEG files in the BAT pack at {self.tpath}.")

    def test_pack_ies_external(self):
        ppath = self.blendfiles / "ies-lamp"
        infile = ppath / "ies_scene.blend"

        packer = pack.Packer(infile, ppath, self.tpath)
        packer.strategise()
        packer.execute()

class ProgressTest(AbstractPackTest):
    def test_strategise(self):
        cb = mock.Mock(progress.Callback)
        infile = self.blendfiles / "subdir/doubly_linked_up.blend"
        with pack.Packer(infile, self.blendfiles, self.tpath) as packer:
            packer.progress_cb = cb
            packer.strategise()

        self.assertEqual(1, cb.pack_start.call_count)
        self.assertEqual(0, cb.pack_done.call_count)

        expected_calls = [
            mock.call(self.blendfiles / "subdir/doubly_linked_up.blend"),
            mock.call(self.blendfiles / "linked_cube.blend"),
            mock.call(self.blendfiles / "basic_file.blend"),
            mock.call(self.blendfiles / "material_textures.blend"),
        ]
        cb.trace_blendfile.assert_has_calls(expected_calls, any_order=True)
        self.assertEqual(len(expected_calls), cb.trace_blendfile.call_count)

        expected_calls = [
            mock.call(self.blendfiles / "linked_cube.blend"),
            mock.call(self.blendfiles / "basic_file.blend"),
            mock.call(self.blendfiles / "material_textures.blend"),
            mock.call(self.blendfiles / "textures/Bricks/brick_dotted_04-color.jpg"),
            mock.call(self.blendfiles / "textures/Bricks/brick_dotted_04-bump.jpg"),
        ]
        cb.trace_asset.assert_has_calls(expected_calls, any_order=True)
        self.assertEqual(len(expected_calls), cb.trace_asset.call_count)

        self.assertEqual(0, cb.rewrite_blendfile.call_count)
        self.assertEqual(0, cb.transfer_file.call_count)
        self.assertEqual(0, cb.transfer_file_skipped.call_count)
        self.assertEqual(0, cb.transfer_progress.call_count)
        self.assertEqual(0, cb.missing_file.call_count)

    def test_execute_with_rewrite(self):
        cb = mock.Mock(progress.Callback)
        infile = self.blendfiles / "subdir/doubly_linked_up.blend"
        with pack.Packer(infile, infile.parent, self.tpath) as packer:
            packer.progress_cb = cb
            packer.strategise()
            packer.execute()

        self.assertEqual(1, cb.pack_start.call_count)
        self.assertEqual(1, cb.pack_done.call_count)

        # rewrite_blendfile should only be called paths in a blendfile are
        # actually rewritten.
        cb.rewrite_blendfile.assert_called_with(
            self.blendfiles / "subdir/doubly_linked_up.blend"
        )
        self.assertEqual(1, cb.rewrite_blendfile.call_count)

        # mock.ANY is used for temporary files in temporary paths, because they
        # are hard to predict.
        extpath = self.outside_project()
        expected_calls = [
            mock.call(mock.ANY, self.tpath / "doubly_linked_up.blend"),
            mock.call(mock.ANY, self.tpath / "pack-info.txt"),
            mock.call(mock.ANY, extpath / "linked_cube.blend"),
            mock.call(mock.ANY, extpath / "basic_file.blend"),
            mock.call(mock.ANY, extpath / "material_textures.blend"),
            mock.call(
                self.blendfiles / "textures/Bricks/brick_dotted_04-color.jpg",
                extpath / "textures/Bricks/brick_dotted_04-color.jpg",
            ),
            mock.call(
                self.blendfiles / "textures/Bricks/brick_dotted_04-bump.jpg",
                extpath / "textures/Bricks/brick_dotted_04-bump.jpg",
            ),
        ]
        cb.transfer_file.assert_has_calls(expected_calls, any_order=True)
        self.assertEqual(len(expected_calls), cb.transfer_file.call_count)

        self.assertEqual(0, cb.transfer_file_skipped.call_count)
        self.assertGreaterEqual(
            cb.transfer_progress.call_count,
            6,
            "transfer_progress() should be called at least once per asset",
        )
        self.assertEqual(0, cb.missing_file.call_count)

    def test_missing_files(self):
        cb = mock.Mock(progress.Callback)
        infile = self.blendfiles / "missing_textures.blend"
        with pack.Packer(infile, self.blendfiles, self.tpath) as packer:
            packer.progress_cb = cb
            packer.strategise()
            packer.execute()

        self.assertEqual(1, cb.pack_start.call_count)
        self.assertEqual(1, cb.pack_done.call_count)

        cb.rewrite_blendfile.assert_not_called()
        cb.transfer_file.assert_has_calls(
            [
                mock.call(infile, self.tpath / "missing_textures.blend"),
                mock.call(mock.ANY, self.tpath / "pack-info.txt"),
            ],
            any_order=True,
        )

        self.assertEqual(0, cb.transfer_file_skipped.call_count)
        self.assertGreaterEqual(
            cb.transfer_progress.call_count,
            1,
            "transfer_progress() should be called at least once per asset",
        )

        expected_calls = [
            mock.call(
                self.blendfiles
                / "textures/HDRI/Myanmar/Golden Palace 2, Old Bagan-1k.exr"
            ),
            mock.call(
                self.blendfiles / "textures/Textures/Marble/marble_decoration-color.png"
            ),
        ]
        cb.missing_file.assert_has_calls(expected_calls, any_order=True)
        self.assertEqual(len(expected_calls), cb.missing_file.call_count)

    def test_particle_cache(self):
        # The particle cache uses a glob to indicate which files to pack.
        cb = mock.Mock(progress.Callback)
        infile = self.blendfiles / "T55539-particles/particle.blend"
        with pack.Packer(infile, self.blendfiles, self.tpath) as packer:
            packer.progress_cb = cb
            packer.strategise()
            packer.execute()

        # We should have all the *.bphys files now.
        count = len(
            list((self.tpath / "T55539-particles/blendcache_particle").glob("*.bphys"))
        )
        self.assertEqual(27, count)

        # Physics files + particle.blend + pack_info.txt
        self.assertGreaterEqual(
            cb.transfer_progress.call_count,
            29,
            "transfer_progress() should be called at least once per asset",
        )

    def test_particle_cache_with_ignore_glob(self):
        cb = mock.Mock(progress.Callback)
        infile = self.blendfiles / "T55539-particles/particle.blend"
        with pack.Packer(infile, self.blendfiles, self.tpath) as packer:
            packer.progress_cb = cb
            packer.exclude("*.bphys")
            packer.strategise()
            packer.execute()

        # We should have none of the *.bphys files now.
        count = len(
            list((self.tpath / "T55539-particles/blendcache_particle").glob("*.bphys"))
        )
        self.assertEqual(0, count)

        # Just particle.blend + pack_info.txt
        self.assertGreaterEqual(
            cb.transfer_progress.call_count,
            2,
            "transfer_progress() should be called at least once per asset",
        )

    def test_smoke_cache(self):
        # The smoke cache uses a glob to indicate which files to pack.
        cb = mock.Mock(progress.Callback)
        infile = self.blendfiles / "T55542-smoke/smoke_cache.blend"
        with pack.Packer(infile, self.blendfiles, self.tpath) as packer:
            packer.progress_cb = cb
            packer.strategise()
            packer.execute()

        # We should have all the *.bphys files now.
        count = len(
            list((self.tpath / "T55542-smoke/blendcache_smoke_cache").glob("*.bphys"))
        )
        self.assertEqual(10, count)

        # Physics files + smoke_cache.blend + pack_info.txt
        self.assertGreaterEqual(
            cb.transfer_progress.call_count,
            12,
            "transfer_progress() should be called at least once per asset",
        )


class KeepHierarchyPackTest(AbstractPackTest):
    def hierarchy_path(self, filepath) -> Path:
        """Return the keep-hierarchy path for a file: target / strip_root(abs_path)."""
        return Path(self.tpath, bpathlib.strip_root(filepath))

    def test_strategise_keep_hierarchy_no_rewrite(self):
        """When all deps are in-project with relative paths, no rewriting is needed."""
        infile = self.blendfiles / "doubly_linked.blend"

        packer = pack.Packer(
            infile, self.blendfiles, self.tpath, keep_hierarchy=True
        )
        packer.strategise()

        packed_files = (
            "doubly_linked.blend",
            "linked_cube.blend",
            "basic_file.blend",
            "material_textures.blend",
            "textures/Bricks/brick_dotted_04-bump.jpg",
            "textures/Bricks/brick_dotted_04-color.jpg",
        )
        for pf in packed_files:
            path = self.blendfiles / pf
            act = packer._actions[path]
            self.assertEqual(
                pack.PathAction.KEEP_PATH, act.path_action, "for %s" % pf
            )
            # In keep_hierarchy mode, paths use strip_root(abs_path) instead of
            # relative_to(project).
            self.assertEqual(
                self.hierarchy_path(path), act.new_path, "for %s" % pf
            )

        self.assertEqual({}, self.rewrites(packer))
        self.assertEqual(len(packed_files), len(packer._actions))

    def test_strategise_keep_hierarchy_rewrite(self):
        """Deps outside the project go to target/strip_root(path), not _outside_project/."""
        ppath = self.blendfiles / "subdir"
        infile = ppath / "doubly_linked_up.blend"

        packer = pack.Packer(infile, ppath, self.tpath, keep_hierarchy=True)
        packer.strategise()

        # The blendfile itself should be at target / strip_root(abs_path)
        act = packer._actions[infile]
        self.assertEqual(pack.PathAction.KEEP_PATH, act.path_action)
        self.assertEqual(self.hierarchy_path(infile), act.new_path)

        # External files should NOT be under _outside_project/
        external_files = (
            "linked_cube.blend",
            "basic_file.blend",
            "material_textures.blend",
            "textures/Bricks/brick_dotted_04-bump.jpg",
            "textures/Bricks/brick_dotted_04-color.jpg",
        )
        for fn in external_files:
            path = self.blendfiles / fn
            act = packer._actions[path]
            self.assertEqual(
                pack.PathAction.FIND_NEW_LOCATION, act.path_action, "for %s" % fn
            )
            # Should be at target / strip_root(abs_path), NOT target/_outside_project/...
            expected = self.hierarchy_path(path)
            self.assertEqual(
                expected,
                act.new_path,
                f"\nEXPECT: {expected}\nACTUAL: {act.new_path}\nfor {fn}",
            )

        # There should be no _outside_project in any new_path
        for path, action in packer._actions.items():
            self.assertNotIn(
                "_outside_project",
                str(action.new_path),
                f"_outside_project should not appear in keep_hierarchy mode for {path}",
            )

    def test_execute_keep_hierarchy(self):
        """Verify files are copied to correct hierarchy and paths are rewritten."""
        ppath = self.blendfiles / "subdir"
        infile = ppath / "doubly_linked_up.blend"

        with pack.Packer(infile, ppath, self.tpath, keep_hierarchy=True) as packer:
            packer.strategise()
            packer.execute()

        # The blendfile should be at its hierarchy position
        packed_blend = self.hierarchy_path(infile)
        self.assertTrue(packed_blend.exists(), "Blendfile should be in hierarchy")

        # There should be NO _outside_project directory
        self.assertFalse(
            (self.tpath / "_outside_project").exists(),
            "_outside_project should not exist in keep_hierarchy mode",
        )

        # Dependencies should be at their hierarchy positions
        for fn in ("linked_cube.blend", "basic_file.blend", "material_textures.blend"):
            dep_path = self.hierarchy_path(self.blendfiles / fn)
            self.assertTrue(dep_path.exists(), "%s should be in hierarchy" % fn)

        # Verify paths were rewritten correctly in the packed blend file.
        # The rewritten paths should be relative from the packed blend to
        # the packed dependencies.
        bfile = blendfile.open_cached(packed_blend, assert_cached=False)
        libs = sorted(bfile.code_index[b"LI"])

        # Since keep_hierarchy preserves relative positions, the relative paths
        # from subdir/doubly_linked_up.blend to the parent blendfiles should
        # be the same as the originals (//../linked_cube.blend etc.)
        self.assertEqual(b"LILib", libs[0].id_name)
        self.assertEqual(b"//../linked_cube.blend", libs[0][b"name"])
        self.assertEqual(b"LILib.002", libs[1].id_name)
        self.assertEqual(b"//../material_textures.blend", libs[1][b"name"])

    def test_execute_keep_hierarchy_no_touch_origs(self):
        """Original files should not be modified."""
        ppath = self.blendfiles / "subdir"
        infile = ppath / "doubly_linked_up.blend"

        with pack.Packer(infile, ppath, self.tpath, keep_hierarchy=True) as packer:
            packer.strategise()
            packer.execute()

        # The original file shouldn't be touched.
        bfile = blendfile.open_cached(infile, assert_cached=False)
        libs = sorted(bfile.code_index[b"LI"])
        self.assertEqual(b"LILib", libs[0].id_name)
        self.assertEqual(b"//../linked_cube.blend", libs[0][b"name"])
        self.assertEqual(b"LILib.002", libs[1].id_name)
        self.assertEqual(b"//../material_textures.blend", libs[1][b"name"])

    def test_keep_hierarchy_output_path(self):
        """output_path should use the full hierarchy path."""
        infile = self.blendfiles / "basic_file.blend"
        packer = pack.Packer(
            infile, self.blendfiles, self.tpath, keep_hierarchy=True
        )
        packer.strategise()

        self.assertEqual(self.hierarchy_path(infile), packer.output_path)


class AbortTest(AbstractPackTest):
    def test_abort_strategise(self):
        infile = self.blendfiles / "subdir/doubly_linked_up.blend"
        packer = pack.Packer(infile, self.blendfiles, self.tpath)

        class AbortingCallback(progress.Callback):
            def trace_blendfile(self, filename: Path):
                # Call abort() somewhere during the strategise() call.
                if filename.name == "linked_cube.blend":
                    packer.abort()

        packer.progress_cb = AbortingCallback()
        with packer, self.assertRaises(pack.Aborted):
            packer.strategise()

    def test_abort_transfer(self):
        infile = self.blendfiles / "subdir/doubly_linked_up.blend"
        packer = pack.Packer(infile, self.blendfiles, self.tpath)

        first_file_size = infile.stat().st_size

        class AbortingCallback(progress.Callback):
            def transfer_progress(self, total_bytes: int, transferred_bytes: int):
                # Call abort() somewhere during the file transfer.
                if total_bytes > first_file_size * 1.1:
                    packer.abort()

        packer.progress_cb = AbortingCallback()
        with packer:
            packer.strategise()
            with self.assertRaises(pack.Aborted):
                packer.execute()
