package p.packer.services;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.events.PackerEvent;
import p.packer.events.PackerEventKind;
import p.packer.messages.*;
import p.packer.messages.assets.*;
import p.packer.testing.PackerFixtureLocator;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

import static org.junit.jupiter.api.Assertions.*;

final class FileSystemPackerWorkspaceServiceTest {
    private static final ObjectMapper MAPPER = new ObjectMapper();

    @TempDir
    Path tempDir;

    @Test
    void listsRegisteredAndUnregisteredAssetsFromWorkspaceScan() throws Exception {
        final Path projectRoot = copyFixture("workspaces/read-mixed", tempDir.resolve("mixed"));
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.listAssets(new ListAssetsRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals(2, result.assets().size());
        assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.REGISTERED));
        assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.UNREGISTERED));
        assertTrue(result.assets().stream().allMatch(asset -> asset.identity().assetUuid() != null && !asset.identity().assetUuid().isBlank()));
    }

    @Test
    void surfacesMissingRegisteredRootAsStructuralDiagnostic() throws Exception {
        final Path projectRoot = copyFixture("workspaces/read-missing-root", tempDir.resolve("missing-root"));
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.listAssets(new ListAssetsRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("missing asset.json")));
    }

    @Test
    void includesInvalidDeclarationsInSnapshotWithDiagnostics() throws Exception {
        final Path projectRoot = copyFixture("workspaces/read-invalid", tempDir.resolve("invalid"));
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.listAssets(new ListAssetsRequest(project(projectRoot)));

        assertEquals(1, result.assets().size());
        assertEquals(PackerAssetState.UNREGISTERED, result.assets().getFirst().state());
        assertEquals(PackerBuildParticipation.EXCLUDED, result.assets().getFirst().buildParticipation());
        assertTrue(result.assets().getFirst().hasDiagnostics());
    }

    @Test
    void emitsDiscoveryAndDiagnosticsEventsDuringScan() throws Exception {
        final Path projectRoot = copyFixture("workspaces/read-invalid", tempDir.resolve("events"));
        final List<PackerEvent> events = new CopyOnWriteArrayList<>();
        final FileSystemPackerWorkspaceService service = service(events::add);

        service.listAssets(new ListAssetsRequest(project(projectRoot)));

        assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ASSET_DISCOVERED));
        assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.DIAGNOSTICS_UPDATED));
        assertTrue(events.stream().allMatch(event -> event.sequence() >= 0L));
    }

    @Test
    void createsRegisteredAssetAndWritesManifest() throws Exception {
        final Path projectRoot = tempDir.resolve("created");
        final List<PackerEvent> events = new CopyOnWriteArrayList<>();
        final FileSystemPackerWorkspaceService service = service(events::add);

        final var result = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "ui/new-atlas",
                "new_atlas",
                AssetFamilyCatalog.TILE_BANK,
                OutputFormatCatalog.TILES_INDEXED_V1,
                OutputCodecCatalog.NONE,
                true));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertNotNull(result.assetReference());
        assertTrue(Files.isRegularFile(projectRoot.resolve("assets/ui/new-atlas/asset.json")));
        final String manifestJson = Files.readString(projectRoot.resolve("assets/ui/new-atlas/asset.json"));
        assertTrue(manifestJson.contains("\"asset_uuid\""));

        final var snapshot = service.listAssets(new ListAssetsRequest(project(projectRoot)));
        assertEquals(1, snapshot.assets().size());
        assertEquals(PackerAssetState.REGISTERED, snapshot.assets().getFirst().state());
        assertEquals("new_atlas", snapshot.assets().getFirst().identity().assetName());
        assertNotNull(snapshot.assets().getFirst().identity().assetUuid());
        assertEquals(PackerBuildParticipation.INCLUDED, snapshot.assets().getFirst().buildParticipation());
        assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ACTION_APPLIED));
    }

    @Test
    void returnsCreatedAssetThroughRuntimeBackedDetailsWithoutRescanMismatch() throws Exception {
        final Path projectRoot = tempDir.resolve("created-details");
        final FileSystemPackerWorkspaceService service = service();

        final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "ui/new-atlas",
                "new_atlas",
                AssetFamilyCatalog.TILE_BANK,
                OutputFormatCatalog.TILES_INDEXED_V1,
                OutputCodecCatalog.NONE,
                true));

        assertEquals(PackerOperationStatus.SUCCESS, createResult.status());
        final AssetReference assetReference = createResult.assetReference();
        assertNotNull(assetReference);

        final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
                project(projectRoot),
                assetReference));

        assertEquals(PackerOperationStatus.SUCCESS, detailsResult.status());
        assertEquals(PackerAssetState.REGISTERED, detailsResult.details().summary().state());
        assertEquals("new_atlas", detailsResult.details().summary().identity().assetName());
        assertNotNull(detailsResult.details().summary().identity().assetUuid());
        assertTrue(detailsResult.diagnostics().isEmpty());
    }

    @Test
    void createAssetPatchesLoadedSnapshotWithoutWholeProjectReload() throws Exception {
        final Path projectRoot = tempDir.resolve("created-no-reload");
        final CountingLoader loader = countingLoader();
        final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);

        service.listAssets(new ListAssetsRequest(project(projectRoot)));
        assertEquals(1, loader.loadCount());

        final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "ui/new-atlas",
                "new_atlas",
                AssetFamilyCatalog.TILE_BANK,
                OutputFormatCatalog.TILES_INDEXED_V1,
                OutputCodecCatalog.NONE,
                true));

        assertEquals(PackerOperationStatus.SUCCESS, createResult.status());
        assertEquals(1, loader.loadCount());

        final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), createResult.assetReference()));
        assertEquals(PackerOperationStatus.SUCCESS, detailsResult.status());
        assertEquals(1, loader.loadCount());
    }

    @Test
    void listAssetsOnlyReloadsSnapshotWhenHardRefreshIsRequested() throws Exception {
        final Path projectRoot = copyFixture("workspaces/read-mixed", tempDir.resolve("hard-refresh"));
        final CountingLoader loader = countingLoader();
        final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);

        service.listAssets(new ListAssetsRequest(project(projectRoot)));
        assertEquals(1, loader.loadCount());

        service.listAssets(new ListAssetsRequest(project(projectRoot)));
        assertEquals(1, loader.loadCount());

        service.listAssets(new ListAssetsRequest(project(projectRoot), true));
        assertEquals(2, loader.loadCount());
    }

    @Test
    void updatesAssetContractManifestAndReturnsSuccess() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("update-contract-success"));
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.updateAssetContract(new UpdateAssetContractRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                false,
                OutputCodecCatalog.NONE,
                Map.of(
                        "NONE:packMode", "tight",
                        "NONE:palette", "mono"),
                Map.of(
                        "tile_size", "16",
                        "channels", "1")));

        assertTrue(result.success());
        assertNull(result.errorMessage());

        final Path manifestPath = projectRoot.resolve("assets/ui/atlas/asset.json");
        final var manifest = MAPPER.readTree(manifestPath.toFile());
        assertFalse(manifest.path("preload").path("enabled").asBoolean(true));
        assertEquals("NONE", manifest.path("output").path("codec").asText());
        assertEquals("tight", manifest.path("output").path("codec_configuration").path("packMode").asText());
        assertEquals("mono", manifest.path("output").path("codec_configuration").path("palette").asText());
        assertEquals("16", manifest.path("output").path("metadata").path("tile_size").asText());
        assertEquals("1", manifest.path("output").path("metadata").path("channels").asText());
    }

    @Test
    void updateAssetContractPatchesLoadedSnapshotWithoutWholeProjectReload() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("update-contract-no-reload"));
        final CountingLoader loader = countingLoader();
        final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);

        service.listAssets(new ListAssetsRequest(project(projectRoot)));
        assertEquals(1, loader.loadCount());

        final var updateResult = service.updateAssetContract(new UpdateAssetContractRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                false,
                OutputCodecCatalog.NONE,
                Map.of("NONE:packMode", "tight"),
                Map.of()));

        assertTrue(updateResult.success());
        assertEquals(1, loader.loadCount());

        final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
                project(projectRoot),
                AssetReference.forAssetId(1)));
        assertEquals(PackerOperationStatus.SUCCESS, detailsResult.status());
        assertFalse(detailsResult.details().summary().preloadEnabled());
        assertEquals(OutputCodecCatalog.NONE, detailsResult.details().outputCodec());
        assertEquals(1, loader.loadCount());
    }

    @Test
    void returnsFailureWhenAssetManifestIsMissingOnContractUpdate() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("update-contract-missing-manifest"));
        Files.delete(projectRoot.resolve("assets/ui/atlas/asset.json"));
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.updateAssetContract(new UpdateAssetContractRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                true,
                OutputCodecCatalog.NONE,
                Map.of(),
                Map.of()));

        assertFalse(result.success());
        assertNotNull(result.errorMessage());
        assertTrue(result.errorMessage().contains("asset.json was not found"));
    }

    @Test
    void exposesRegisterActionForValidUnregisteredAsset() throws Exception {
        final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("orphan-actions"));
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.getAssetActions(new GetAssetActionsRequest(
                project(projectRoot),
                AssetReference.forRelativeAssetRoot("orphans/ui_sounds")));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals(3, result.actions().size());
        assertEquals(AssetAction.REGISTER, result.actions().get(0).action());
        assertTrue(result.actions().get(0).visible());
        assertTrue(result.actions().get(0).enabled());
        assertNull(result.actions().get(0).reason());
        assertEquals(AssetAction.MOVE, result.actions().get(1).action());
        assertTrue(result.actions().get(1).visible());
        assertTrue(result.actions().get(1).enabled());
        assertNull(result.actions().get(1).reason());
        assertEquals(AssetAction.DELETE, result.actions().get(2).action());
    }

    @Test
    void hidesRegisterActionForRegisteredAsset() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed-actions"));
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.getAssetActions(new GetAssetActionsRequest(
                project(projectRoot),
                AssetReference.forAssetId(1)));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals(2, result.actions().size());
        assertEquals(AssetAction.MOVE, result.actions().get(0).action());
        assertTrue(result.actions().get(0).enabled());
        assertEquals(AssetAction.DELETE, result.actions().get(1).action());
        assertTrue(result.actions().get(1).enabled());
    }

    @Test
    void registersExistingUnregisteredAssetAndRefreshesRuntimeView() throws Exception {
        final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("register-orphan"));
        final List<PackerEvent> events = new CopyOnWriteArrayList<>();
        final FileSystemPackerWorkspaceService service = service(events::add);

        final RegisterAssetResult registerResult = service.registerAsset(new RegisterAssetRequest(
                project(projectRoot),
                AssetReference.forRelativeAssetRoot("orphans/ui_sounds")));

        assertEquals(PackerOperationStatus.SUCCESS, registerResult.status());
        assertNotNull(registerResult.assetReference());
        assertTrue(registerResult.assetReference().isAssetIdReference());

        final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
                project(projectRoot),
                registerResult.assetReference()));

        assertEquals(PackerOperationStatus.SUCCESS, detailsResult.status());
        assertEquals(PackerAssetState.REGISTERED, detailsResult.details().summary().state());
        assertEquals("orphan-uuid-1", detailsResult.details().summary().identity().assetUuid());
        assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ACTION_APPLIED));
    }

    @Test
    void registerAssetPatchesLoadedSnapshotWithoutWholeProjectReload() throws Exception {
        final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("register-no-reload"));
        final CountingLoader loader = countingLoader();
        final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);

        service.listAssets(new ListAssetsRequest(project(projectRoot)));
        assertEquals(1, loader.loadCount());

        final RegisterAssetResult registerResult = service.registerAsset(new RegisterAssetRequest(
                project(projectRoot),
                AssetReference.forRelativeAssetRoot("orphans/ui_sounds")));

        assertEquals(PackerOperationStatus.SUCCESS, registerResult.status());
        assertEquals(1, loader.loadCount());

        final var actionsResult = service.getAssetActions(new GetAssetActionsRequest(
                project(projectRoot),
                registerResult.assetReference()));
        assertEquals(2, actionsResult.actions().size());
        assertEquals(AssetAction.MOVE, actionsResult.actions().get(0).action());
        assertEquals(AssetAction.DELETE, actionsResult.actions().get(1).action());
        assertEquals(1, loader.loadCount());
    }

    @Test
    void blocksRegisterActionForInvalidDeclaration() throws Exception {
        final Path projectRoot = copyFixture("workspaces/invalid-missing-fields", tempDir.resolve("invalid-actions"));
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.getAssetActions(new GetAssetActionsRequest(
                project(projectRoot),
                AssetReference.forRelativeAssetRoot("bad")));

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertEquals(3, result.actions().size());
        assertEquals(AssetAction.REGISTER, result.actions().get(0).action());
        assertFalse(result.actions().get(0).enabled());
        assertNotNull(result.actions().get(0).reason());
        assertEquals(AssetAction.MOVE, result.actions().get(1).action());
        assertTrue(result.actions().get(1).enabled());
        assertEquals(AssetAction.DELETE, result.actions().get(2).action());
    }

    @Test
    void rejectsRegisterWhenAssetUuidIsAlreadyRegisteredElsewhere() throws Exception {
        final Path projectRoot = tempDir.resolve("duplicate-uuid");
        Files.createDirectories(projectRoot.resolve("assets/registered/ui_atlas"));
        Files.createDirectories(projectRoot.resolve("assets/orphans/ui_clone"));
        Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
        Files.writeString(projectRoot.resolve("assets/registered/ui_atlas/asset.json"), """
                {
                  "schema_version": 1,
                  "asset_uuid": "shared-uuid",
                  "name": "ui_atlas",
                  "type": "IMAGE/bank",
                  "inputs": {},
                  "output": {
                    "format": "TILES/indexed_v1",
                    "codec": "none"
                  },
                  "preload": {
                    "enabled": true
                  }
                }
                """);
        Files.writeString(projectRoot.resolve("assets/orphans/ui_clone/asset.json"), """
                {
                  "schema_version": 1,
                  "asset_uuid": "shared-uuid",
                  "name": "ui_clone",
                  "type": "IMAGE/bank",
                  "inputs": {},
                  "output": {
                    "format": "TILES/indexed_v1",
                    "codec": "none"
                  },
                  "preload": {
                    "enabled": false
                  }
                }
                """);
        Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """
                {
                  "schema_version": 1,
                  "next_asset_id": 2,
                  "assets": [
                    {
                      "asset_id": 1,
                      "asset_uuid": "shared-uuid",
                      "root": "registered/ui_atlas",
                      "included_in_build": true
                    }
                  ]
                }
                """);
        final FileSystemPackerWorkspaceService service = service();

        final var actions = service.getAssetActions(new GetAssetActionsRequest(
                project(projectRoot),
                AssetReference.forRelativeAssetRoot("orphans/ui_clone")));
        assertEquals(3, actions.actions().size());
        assertEquals(AssetAction.REGISTER, actions.actions().get(0).action());
        assertFalse(actions.actions().get(0).enabled());
        assertNotNull(actions.actions().get(0).reason());
        assertEquals(AssetAction.MOVE, actions.actions().get(1).action());
        assertTrue(actions.actions().get(1).enabled());
        assertEquals(AssetAction.DELETE, actions.actions().get(2).action());

        final RegisterAssetResult registerResult = service.registerAsset(new RegisterAssetRequest(
                project(projectRoot),
                AssetReference.forRelativeAssetRoot("orphans/ui_clone")));
        assertEquals(PackerOperationStatus.FAILED, registerResult.status());
        assertNull(registerResult.assetReference());
    }

    @Test
    void deletesRegisteredAssetJsonWithoutDeletingDirectoryContents() throws Exception {
        final Path projectRoot = tempDir.resolve("delete-registered");
        final FileSystemPackerWorkspaceService service = service();

        final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "ui/delete-me",
                "delete_me",
                AssetFamilyCatalog.TILE_BANK,
                OutputFormatCatalog.TILES_INDEXED_V1,
                OutputCodecCatalog.NONE,
                false));
        final Path assetRoot = projectRoot.resolve("assets/ui/delete-me");
        Files.writeString(assetRoot.resolve("atlas.png"), "fixture");

        final DeleteAssetResult deleteResult = service.deleteAsset(new DeleteAssetRequest(
                project(projectRoot),
                createResult.assetReference()));

        assertEquals(PackerOperationStatus.SUCCESS, deleteResult.status());
        assertTrue(Files.isDirectory(assetRoot));
        assertFalse(Files.exists(assetRoot.resolve("asset.json")));
        assertTrue(Files.isRegularFile(assetRoot.resolve("atlas.png")));

        final var listResult = service.listAssets(new ListAssetsRequest(project(projectRoot)));
        assertTrue(listResult.assets().isEmpty());
    }

    @Test
    void movesRegisteredAssetAndKeepsRegisteredIdentity() throws Exception {
        final Path projectRoot = tempDir.resolve("move-registered");
        final FileSystemPackerWorkspaceService service = service();
        final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "ui/source-atlas",
                "source_atlas",
                AssetFamilyCatalog.TILE_BANK,
                OutputFormatCatalog.TILES_INDEXED_V1,
                OutputCodecCatalog.NONE,
                true));
        Files.writeString(projectRoot.resolve("assets/ui/source-atlas/atlas.png"), "fixture");

        final MoveAssetResult moveResult = service.moveAsset(new MoveAssetRequest(
                project(projectRoot),
                createResult.assetReference(),
                "ui/renamed-atlas"));

        assertEquals(PackerOperationStatus.SUCCESS, moveResult.status());
        assertEquals(createResult.assetReference(), moveResult.assetReference());
        assertFalse(Files.exists(projectRoot.resolve("assets/ui/source-atlas/asset.json")));
        assertTrue(Files.isRegularFile(projectRoot.resolve("assets/ui/renamed-atlas/asset.json")));
        assertTrue(Files.isRegularFile(projectRoot.resolve("assets/ui/renamed-atlas/atlas.png")));

        final var details = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), createResult.assetReference()));
        assertEquals(PackerOperationStatus.SUCCESS, details.status());
        assertEquals(PackerAssetState.REGISTERED, details.details().summary().state());
        assertEquals(projectRoot.resolve("assets/ui/renamed-atlas").toAbsolutePath().normalize(), details.details().summary().identity().assetRoot());
    }

    @Test
    void movesUnregisteredAssetAndReturnsNewRootReference() throws Exception {
        final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("move-unregistered"));
        final FileSystemPackerWorkspaceService service = service();
        Files.writeString(projectRoot.resolve("assets/orphans/ui_sounds/readme.txt"), "keep");

        final MoveAssetResult moveResult = service.moveAsset(new MoveAssetRequest(
                project(projectRoot),
                AssetReference.forRelativeAssetRoot("orphans/ui_sounds"),
                "recovered/ui_sounds_renamed"));

        assertEquals(PackerOperationStatus.SUCCESS, moveResult.status());
        assertEquals(AssetReference.forRelativeAssetRoot("recovered/ui_sounds_renamed"), moveResult.assetReference());
        assertTrue(Files.isRegularFile(projectRoot.resolve("assets/recovered/ui_sounds_renamed/asset.json")));
        assertTrue(Files.isRegularFile(projectRoot.resolve("assets/recovered/ui_sounds_renamed/readme.txt")));

        final var details = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), moveResult.assetReference()));
        assertEquals(PackerOperationStatus.SUCCESS, details.status());
        assertEquals(PackerAssetState.UNREGISTERED, details.details().summary().state());
        assertEquals(projectRoot.resolve("assets/recovered/ui_sounds_renamed").toAbsolutePath().normalize(), details.details().summary().identity().assetRoot());
    }

    @Test
    void blocksMoveOutsideAssetsRoot() throws Exception {
        final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("move-outside"));
        final FileSystemPackerWorkspaceService service = service();

        final MoveAssetResult moveResult = service.moveAsset(new MoveAssetRequest(
                project(projectRoot),
                AssetReference.forRelativeAssetRoot("orphans/ui_sounds"),
                "../outside/ui_sounds"));

        assertEquals(PackerOperationStatus.FAILED, moveResult.status());
        assertNull(moveResult.assetReference());
    }

    @Test
    void blocksMoveWhenTargetAlreadyContainsAssetManifest() throws Exception {
        final Path projectRoot = tempDir.resolve("move-target-asset");
        final FileSystemPackerWorkspaceService service = service();
        final CreateAssetResult source = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "ui/source",
                "source",
                AssetFamilyCatalog.TILE_BANK,
                OutputFormatCatalog.TILES_INDEXED_V1,
                OutputCodecCatalog.NONE,
                false));
        final CreateAssetResult target = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "ui/target",
                "target",
                AssetFamilyCatalog.TILE_BANK,
                OutputFormatCatalog.TILES_INDEXED_V1,
                OutputCodecCatalog.NONE,
                false));

        final MoveAssetResult moveResult = service.moveAsset(new MoveAssetRequest(
                project(projectRoot),
                source.assetReference(),
                "ui/target"));

        assertEquals(PackerOperationStatus.FAILED, moveResult.status());
        assertNull(moveResult.assetReference());
        assertNotNull(target.assetReference());
    }

    @Test
    void moveAssetPatchesLoadedSnapshotWithoutWholeProjectReload() throws Exception {
        final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("move-no-reload"));
        final CountingLoader loader = countingLoader();
        final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);

        service.listAssets(new ListAssetsRequest(project(projectRoot)));
        assertEquals(1, loader.loadCount());

        final MoveAssetResult moveResult = service.moveAsset(new MoveAssetRequest(
                project(projectRoot),
                AssetReference.forRelativeAssetRoot("orphans/ui_sounds"),
                "recovered/ui_sounds_moved"));

        assertEquals(PackerOperationStatus.SUCCESS, moveResult.status());
        assertEquals(1, loader.loadCount());

        final var details = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), moveResult.assetReference()));
        assertEquals(PackerOperationStatus.SUCCESS, details.status());
        assertEquals(1, loader.loadCount());
    }

    @Test
    void deletesUnregisteredAssetJsonAndRemovesItFromSnapshot() throws Exception {
        final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("delete-unregistered"));
        final FileSystemPackerWorkspaceService service = service();
        final Path assetRoot = projectRoot.resolve("assets/orphans/ui_sounds");
        Files.writeString(assetRoot.resolve("notes.txt"), "keep me");

        final DeleteAssetResult deleteResult = service.deleteAsset(new DeleteAssetRequest(
                project(projectRoot),
                AssetReference.forRelativeAssetRoot("orphans/ui_sounds")));

        assertEquals(PackerOperationStatus.SUCCESS, deleteResult.status());
        assertTrue(Files.isDirectory(assetRoot));
        assertFalse(Files.exists(assetRoot.resolve("asset.json")));
        assertTrue(Files.isRegularFile(assetRoot.resolve("notes.txt")));

        final var listResult = service.listAssets(new ListAssetsRequest(project(projectRoot)));
        assertTrue(listResult.assets().isEmpty());
    }

    @Test
    void rejectsUnsupportedFormatForSelectedFamily() {
        final Path projectRoot = tempDir.resolve("unsupported");
        final List<PackerEvent> events = new CopyOnWriteArrayList<>();
        final FileSystemPackerWorkspaceService service = service(events::add);

        final var result = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "audio/bad",
                "bad_asset",
                AssetFamilyCatalog.TILE_BANK,
                OutputFormatCatalog.SOUND_V1,
                OutputCodecCatalog.NONE,
                false));

        assertEquals(PackerOperationStatus.FAILED, result.status());
        assertNull(result.assetReference());
        assertTrue(result.summary().contains("not supported"));
        assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ACTION_FAILED));
    }

    @Test
    void serializesConcurrentCreateAssetRequestsPerProject() throws Exception {
        final Path projectRoot = tempDir.resolve("concurrent-create");
        final FileSystemPackerWorkspaceService service = service();
        try (var executor = Executors.newFixedThreadPool(2)) {
            final Future<CreateAssetResult> first = executor.submit(() -> service.createAsset(new CreateAssetRequest(
                    project(projectRoot),
                    "ui/atlas-a",
                    "atlas_a",
                    AssetFamilyCatalog.TILE_BANK,
                    OutputFormatCatalog.TILES_INDEXED_V1,
                    OutputCodecCatalog.NONE,
                    true)));
            final Future<CreateAssetResult> second = executor.submit(() -> service.createAsset(new CreateAssetRequest(
                    project(projectRoot),
                    "ui/atlas-b",
                    "atlas_b",
                    AssetFamilyCatalog.TILE_BANK,
                    OutputFormatCatalog.TILES_INDEXED_V1,
                    OutputCodecCatalog.NONE,
                    false)));

            assertEquals(PackerOperationStatus.SUCCESS, first.get().status());
            assertEquals(PackerOperationStatus.SUCCESS, second.get().status());
        }

        final var snapshot = service.listAssets(new ListAssetsRequest(project(projectRoot)));
        assertEquals(2, snapshot.assets().size());
        assertEquals(2, new HashSet<>(snapshot.assets().stream().map(asset -> asset.identity().assetId()).toList()).size());
    }

    private PackerProjectContext project(Path root) {
        return new PackerProjectContext("main", root);
    }

    private FileSystemPackerWorkspaceService service() {
        return service(ignored -> { });
    }

    private FileSystemPackerWorkspaceService service(p.packer.events.PackerEventSink eventSink) {
        return service(eventSink, countingLoader());
    }

    private FileSystemPackerWorkspaceService service(
            p.packer.events.PackerEventSink eventSink,
            PackerRuntimeSnapshotLoader loader) {
        final var foundation = new p.packer.services.PackerWorkspaceFoundation();
        final var parser = new p.packer.services.PackerAssetDeclarationParser(new ObjectMapper());
        final var runtimeRegistry = new p.packer.services.PackerRuntimeRegistry(loader);
        final var resolver = new p.packer.services.PackerAssetReferenceResolver(foundation.lookup());
        final var detailsService = new p.packer.services.PackerAssetDetailsService(runtimeRegistry, resolver);
        final var actionReadService = new p.packer.services.PackerAssetActionReadService(runtimeRegistry, resolver, foundation.lookup());
        final var runtimePatchService = new p.packer.services.PackerRuntimePatchService(parser);
        final var writeCoordinator = new p.packer.services.PackerProjectWriteCoordinator();
        return new FileSystemPackerWorkspaceService(
                new ObjectMapper(),
                foundation,
                detailsService,
                actionReadService,
                runtimePatchService,
                runtimeRegistry,
                writeCoordinator,
                eventSink);
    }

    private CountingLoader countingLoader() {
        final var foundation = new p.packer.services.PackerWorkspaceFoundation();
        final var parser = new p.packer.services.PackerAssetDeclarationParser(new ObjectMapper());
        return new CountingLoader(new p.packer.services.PackerRuntimeLoader(foundation, parser));
    }

    private static final class CountingLoader implements PackerRuntimeSnapshotLoader {
        private final PackerRuntimeSnapshotLoader delegate;
        private int loadCount;

        private CountingLoader(PackerRuntimeSnapshotLoader delegate) {
            this.delegate = delegate;
        }

        @Override
        public synchronized p.packer.models.PackerRuntimeSnapshot load(PackerProjectContext project, long generation) {
            loadCount += 1;
            return delegate.load(project, generation);
        }

        private synchronized int loadCount() {
            return loadCount;
        }
    }

    private Path copyFixture(String relativePath, Path targetRoot) throws Exception {
        final Path sourceRoot = PackerFixtureLocator.fixtureRoot(relativePath);
        try (var stream = Files.walk(sourceRoot)) {
            for (Path source : stream.sorted(Comparator.naturalOrder()).toList()) {
                final Path target = targetRoot.resolve(sourceRoot.relativize(source).toString());
                if (Files.isDirectory(source)) {
                    Files.createDirectories(target);
                } else {
                    Files.createDirectories(target.getParent());
                    Files.copy(source, target);
                }
            }
        }
        return targetRoot;
    }
}
