package p.packer.services;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
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.repositories.FileSystemPackerCacheRepository;
import p.packer.repositories.PackerAssetWalker;
import p.packer.repositories.PackerRuntimeAssetMaterializer;
import p.packer.repositories.PackerRuntimeLoader;
import p.packer.repositories.PackerRuntimeRegistry;
import p.packer.repositories.PackerRuntimeSnapshotLoader;
import p.packer.testing.PackerFixtureLocator;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.Comparator;
import java.util.HexFormat;
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 packSummaryIncludesOnlyRegisteredAssetsIncludedInBuild() throws Exception {
        final Path projectRoot = copyFixture("workspaces/read-mixed", tempDir.resolve("pack-summary-mixed"));
        final Path manifestPath = projectRoot.resolve("assets/ui/atlas/asset.json");
        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        manifest.putArray("artifacts").addObject().put("file", "confirm.png").put("index", 0);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.getPackWorkspaceSummary(new PackWorkspaceSummaryRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals(1, result.packSummary().totalIncludedAssetCount());
        assertEquals(1, result.packSummary().assets().size());
        assertEquals(1, result.packSummary().assets().getFirst().assetId());
        assertEquals("ui_atlas", result.packSummary().assets().getFirst().assetName());
    }

    @Test
    void packSummaryExcludesRegisteredAssetsMarkedOutOfBuild() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-summary-excluded"));
        final Path registryPath = projectRoot.resolve("assets/.prometeu/index.json");
        final ObjectNode registry = (ObjectNode) MAPPER.readTree(registryPath.toFile());
        ((ObjectNode) registry.path("assets").get(0)).put("included_in_build", false);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryPath.toFile(), registry);
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.getPackWorkspaceSummary(new PackWorkspaceSummaryRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals(0, result.packSummary().totalIncludedAssetCount());
        assertTrue(result.packSummary().assets().isEmpty());
    }

    @Test
    void packSummaryProjectsArtifactCountAndOutputMetadata() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-summary-artifacts"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final Path manifestPath = assetRoot.resolve("asset.json");
        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        final var artifacts = manifest.putArray("artifacts");
        artifacts.addObject().put("file", "confirm.png").put("index", 0);
        final ObjectNode output = (ObjectNode) manifest.path("output");
        final ObjectNode metadata = output.putObject("metadata");
        metadata.put("tile_size", "16x16");
        output.putObject("pipeline").putObject("samples").putObject("1").put("offset", 0).put("length", 64);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.getPackWorkspaceSummary(new PackWorkspaceSummaryRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals(1, result.packSummary().assets().size());
        assertEquals(1, result.packSummary().assets().getFirst().artifactCount());
        assertEquals(OutputFormatCatalog.GLYPH_INDEXED_V1, result.packSummary().assets().getFirst().outputFormat());
        assertEquals("16x16", result.packSummary().assets().getFirst().outputMetadata().get("tile_size"));
        assertEquals(64L, ((Map<?, ?>) ((Map<?, ?>) result.packSummary().assets().getFirst().outputPipeline().get("samples")).get("1")).get("length"));
        assertEquals(0L, result.packSummary().assets().getFirst().lastModified());
    }

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

        final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertTrue(result.canPack());
        assertTrue(result.assets().isEmpty());
    }

    @Test
    void includedAssetsWithoutArtifactsAreExcludedFromSummaryValidationAndPacking() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-no-artifacts-excluded"));
        final FileSystemPackerWorkspaceService service = service();

        final var summary = service.getPackWorkspaceSummary(new PackWorkspaceSummaryRequest(project(projectRoot)));
        final var validation = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));
        final var pack = service.packWorkspace(new PackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, summary.status());
        assertEquals(0, summary.packSummary().totalIncludedAssetCount());
        assertTrue(summary.packSummary().assets().isEmpty());

        assertEquals(PackerOperationStatus.SUCCESS, validation.status());
        assertTrue(validation.canPack());
        assertTrue(validation.assets().isEmpty());

        assertEquals(PackerOperationStatus.SUCCESS, pack.status());
        assertEquals(0, pack.result().packedAssetCount());
        assertEquals(4, pack.result().emittedArtifacts().size());
        assertTrue(Files.isRegularFile(projectRoot.resolve("build/assets.pa")));

        final var assetTable = MAPPER.readTree(projectRoot.resolve("build/asset_table.json").toFile());
        final var preload = MAPPER.readTree(projectRoot.resolve("build/preload.json").toFile());
        assertEquals(0, assetTable.size());
        assertEquals(0, preload.size());
    }

    @Test
    void packValidationReturnsBlockingDiagnosticsPerIncludedAsset() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-blocked"));
        final Path manifestPath = projectRoot.resolve("assets/ui/atlas/asset.json");
        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        manifest.putArray("artifacts").addObject().put("file", "confirm.png").put("index", 0);
        manifest.remove("name");
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertFalse(result.canPack());
        assertEquals(1, result.assets().size());
        assertEquals(1, result.assets().getFirst().assetId());
        assertEquals("atlas", result.assets().getFirst().assetName());
        assertEquals(projectRoot.resolve("assets/ui/atlas").toAbsolutePath().normalize(), result.assets().getFirst().assetPath());
        assertEquals(0L, result.assets().getFirst().lastModified());
        assertTrue(result.assets().getFirst().diagnostics().stream().allMatch(diagnostic -> diagnostic.blocking()));
        assertTrue(result.assets().getFirst().diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("Field 'name' must be a non-blank string")));
    }

    @Test
    void packValidationIgnoresRegisteredAssetsExcludedFromBuild() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-excluded"));
        final Path registryPath = projectRoot.resolve("assets/.prometeu/index.json");
        final ObjectNode registry = (ObjectNode) MAPPER.readTree(registryPath.toFile());
        ((ObjectNode) registry.path("assets").get(0)).put("included_in_build", false);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryPath.toFile(), registry);
        final Path manifestPath = projectRoot.resolve("assets/ui/atlas/asset.json");
        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        manifest.remove("name");
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertTrue(result.canPack());
        assertTrue(result.assets().isEmpty());
    }

    @Test
    void packValidationBlocksGlyphBanksWithoutDeclaredPalettes() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-no-palettes"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final Path manifestPath = assetRoot.resolve("asset.json");
        writeTilePng(assetRoot.resolve("confirm.png"), 16);

        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        manifest.putObject("output").put("format", "GLYPH/indexed_v1").put("codec", "NONE");
        final var artifacts = manifest.putArray("artifacts");
        artifacts.addObject().put("file", "confirm.png").put("index", 0);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);

        final FileSystemPackerWorkspaceService service = service();
        final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertFalse(result.canPack());
        assertTrue(result.assets().getFirst().diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("must declare at least one palette")));
    }

    @Test
    void packWorkspaceRerunsGateOnFreshSnapshotBeforeEmission() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-workspace-rerun-gate"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final Path manifestPath = assetRoot.resolve("asset.json");
        final FileSystemPackerWorkspaceService service = service();

        final var validation = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));
        assertEquals(PackerOperationStatus.SUCCESS, validation.status());

        writeTilePng(assetRoot.resolve("confirm.png"), 16);
        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        final var artifacts = manifest.putArray("artifacts");
        artifacts.addObject().put("file", "confirm.png").put("index", 0);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);

        final var result = service.packWorkspace(new PackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertEquals("assets.pa", result.result().canonicalArtifactName());
        assertTrue(result.result().emittedArtifacts().isEmpty());
        assertFalse(Files.exists(projectRoot.resolve("build/assets.pa")));
    }

    @Test
    void packWorkspaceEmitsGlyphBankArtifactsFromFrozenSnapshot() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-workspace-success"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final Path manifestPath = assetRoot.resolve("asset.json");
        writeTilePng(assetRoot.resolve("confirm.png"), 16);

        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        final ObjectNode output = (ObjectNode) manifest.path("output");
        output.putObject("metadata").put("tile_size", "16x16");
        final ObjectNode pipeline = output.putObject("pipeline");
        final var palettes = pipeline.putArray("palettes");
        final ObjectNode palette = palettes.addObject();
        palette.put("index", 0);
        palette.putObject("palette")
                .putArray("originalArgb8888").add(0xFFFF0000);
        ((ObjectNode) palette.path("palette"))
                .putArray("convertedRgb565").add(0xF800);
        final var artifacts = manifest.putArray("artifacts");
        artifacts.addObject().put("file", "confirm.png").put("index", 0);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);

        final FileSystemPackerWorkspaceService service = service();
        final var result = service.packWorkspace(new PackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals("assets.pa", result.result().canonicalArtifactName());
        assertEquals(1, result.result().packedAssetCount());
        assertEquals(4, result.result().emittedArtifacts().size());
        assertTrue(Files.isRegularFile(projectRoot.resolve("build/assets.pa")));
        assertTrue(Files.isRegularFile(projectRoot.resolve("build/asset_table.json")));
        assertTrue(Files.isRegularFile(projectRoot.resolve("build/preload.json")));
        assertTrue(Files.isRegularFile(projectRoot.resolve("build/asset_table_metadata.json")));

        final byte[] assetsPa = Files.readAllBytes(projectRoot.resolve("build/assets.pa"));
        assertTrue(assetsPa.length > 32);
        assertEquals('A', assetsPa[0]);
        assertEquals('S', assetsPa[1]);
        assertEquals('P', assetsPa[2]);
        assertEquals('A', assetsPa[3]);
        assertEquals(1, readLeInt(assetsPa, 4));
        assertEquals(32 + readLeInt(assetsPa, 8), readLeLong(assetsPa, 12));

        final var assetTable = MAPPER.readTree(projectRoot.resolve("build/asset_table.json").toFile());
        assertEquals(1, assetTable.size());
        final int payloadOffset = (int) readLeLong(assetsPa, 12);
        final int assetOffset = assetTable.get(0).path("offset").asInt();
        final int packedPixelBytes = (assetTable.get(0).path("metadata").path("width").asInt()
                * assetTable.get(0).path("metadata").path("height").asInt()) / 2;
        final int paletteStart = payloadOffset + assetOffset + packedPixelBytes;
        assertEquals(0xF81F, readLeUnsignedShort(assetsPa, paletteStart));
        assertEquals(0xF800, readLeUnsignedShort(assetsPa, paletteStart + 2));
        assertEquals(1, assetTable.get(0).path("asset_id").asInt());
        assertEquals("GLYPH", assetTable.get(0).path("bank_type").asText());
        assertEquals("NONE", assetTable.get(0).path("codec").asText());
        assertEquals(16, assetTable.get(0).path("metadata").path("tile_size").asInt());
        assertEquals(256, assetTable.get(0).path("metadata").path("width").asInt());
        assertEquals(256, assetTable.get(0).path("metadata").path("height").asInt());
        assertEquals(64, assetTable.get(0).path("metadata").path("palette_count").asInt());
        assertEquals(1, assetTable.get(0).path("metadata").path("palette_authored").asInt());
        assertTrue(assetTable.get(0).path("metadata").path("pipeline").isMissingNode());

        final var preload = MAPPER.readTree(projectRoot.resolve("build/preload.json").toFile());
        assertEquals(1, preload.size());
        assertEquals(1, preload.get(0).path("asset_id").asInt());
        assertEquals(0, preload.get(0).path("slot").asInt());

        final var assetTableMetadata = MAPPER.readTree(projectRoot.resolve("build/asset_table_metadata.json").toFile());
        assertEquals(1, assetTableMetadata.size());
        assertEquals(1, assetTableMetadata.get(0).path("metadata").path("palette_authored").asInt());
        assertEquals(1, assetTableMetadata.get(0).path("metadata").path("pipeline").path("palettes").size());
        assertEquals(0, assetTableMetadata.get(0).path("metadata").path("pipeline").path("palettes").get(0).path("index").asInt());
    }

    @Test
    void packWorkspaceRemapsGlyphTileIndicesUsingGlyphMaterialSchema() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-workspace-remap-material"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final Path manifestPath = assetRoot.resolve("asset.json");
        writeTwoColorTilePng(assetRoot.resolve("confirm.png"), 16);

        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        final ObjectNode output = (ObjectNode) manifest.path("output");
        output.putObject("metadata").put("tile_size", "16x16");
        final ObjectNode pipeline = output.putObject("pipeline");
        final var palettes = pipeline.putArray("palettes");
        final ObjectNode palette = palettes.addObject();
        palette.put("index", 0);
        palette.putObject("palette")
                .putArray("originalArgb8888").add(0xFF00FF00).add(0xFFFF0000);
        ((ObjectNode) palette.path("palette"))
                .putArray("convertedRgb565").add(0x07E0).add(0xF800);
        final var artifacts = manifest.putArray("artifacts");
        artifacts.addObject()
                .put("artifact_id", "art-confirm")
                .put("file", "confirm.png")
                .put("index", 0);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
        writeGlyphMaterialJson(assetRoot, """
                {
                  "schema_version": 1,
                  "schemas": [
                    {
                      "schema_id": "schema-1",
                      "status": "ok",
                      "version": 1,
                      "material_indices": {
                        "1": "#00FF00",
                        "2": "#FF0000"
                      },
                      "artifact_bindings": [
                        {
                          "artifact_id": "art-confirm",
                          "fingerprint": "%s"
                        }
                      ]
                    }
                  ],
                  "palettes": [
                    {
                      "palette_id": "palette-1",
                      "schema_id": "schema-1",
                      "version": 1,
                      "colors": [ "#00FF00", "#FF0000" ]
                    }
                  ]
                }
                """.formatted(fingerprint(assetRoot.resolve("confirm.png"))));

        final FileSystemPackerWorkspaceService service = service();
        final var result = service.packWorkspace(new PackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        final byte[] assetsPa = Files.readAllBytes(projectRoot.resolve("build/assets.pa"));
        final int payloadOffset = (int) readLeLong(assetsPa, 12);
        final var assetTable = MAPPER.readTree(projectRoot.resolve("build/asset_table.json").toFile());
        final int assetOffset = assetTable.get(0).path("offset").asInt();
        assertEquals(0x21, assetsPa[payloadOffset + assetOffset] & 0xFF);
    }

    @Test
    void validateAndPackIgnoreBlockingDiagnosticsFromUnselectedTileFiles() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-ignore-unselected"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final Path manifestPath = assetRoot.resolve("asset.json");
        writeTilePng(assetRoot.resolve("selected.png"), 16);
        writeTilePng(assetRoot.resolve("unused-bad.png"), 32);

        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        final ObjectNode output = (ObjectNode) manifest.path("output");
        output.putObject("metadata").put("tile_size", "16x16");
        final ObjectNode pipeline = output.putObject("pipeline");
        final var palettes = pipeline.putArray("palettes");
        final ObjectNode palette = palettes.addObject();
        palette.put("index", 0);
        palette.putObject("palette")
                .putArray("originalArgb8888").add(0xFFFF0000);
        ((ObjectNode) palette.path("palette"))
                .putArray("convertedRgb565").add(0xF800);
        final var artifacts = manifest.putArray("artifacts");
        artifacts.addObject().put("file", "selected.png").put("index", 0);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);

        final FileSystemPackerWorkspaceService service = service();
        final var validation = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));
        final var pack = service.packWorkspace(new PackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, validation.status());
        assertTrue(validation.canPack());
        assertEquals(PackerOperationStatus.SUCCESS, pack.status());
        assertTrue(Files.isRegularFile(projectRoot.resolve("build/assets.pa")));
    }

    @Test
    void packValidationAndPackBlockUnsupportedIncludedAssetFamiliesGracefully() throws Exception {
        final Path projectRoot = tempDir.resolve("pack-unsupported-family");
        FileSystemPackerWorkspaceService service = service();

        final var createResult = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "audio/ui-sounds",
                "ui_sounds",
                AssetFamilyCatalog.SOUND_BANK,
                OutputFormatCatalog.SOUND_V1,
                OutputCodecCatalog.NONE,
                true));
        assertEquals(PackerOperationStatus.SUCCESS, createResult.status());

        final Path manifestPath = projectRoot.resolve("assets/audio/ui-sounds/asset.json");
        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        manifest.putArray("artifacts").addObject().put("file", "ui-click.wav").put("index", 0);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
        service = service();

        final var validation = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));
        final var pack = service.packWorkspace(new PackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.PARTIAL, validation.status());
        assertFalse(validation.canPack());
        assertTrue(validation.assets().getFirst().diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("not implemented yet for asset family sound_bank")));

        assertEquals(PackerOperationStatus.PARTIAL, pack.status());
        assertTrue(pack.summary().contains("blocking diagnostics"));
        assertTrue(pack.result().emittedArtifacts().isEmpty());
    }

    @Test
    void packValidationIncludesBlockingFileScopedTileDiagnostics() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-file-diagnostics"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final Path manifestPath = assetRoot.resolve("asset.json");
        writeTilePng(assetRoot.resolve("oversized.png"), 32);

        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        final ObjectNode output = (ObjectNode) manifest.path("output");
        output.putObject("metadata").put("tile_size", "16x16");
        final ObjectNode pipeline = output.putObject("pipeline");
        final var palettes = pipeline.putArray("palettes");
        final ObjectNode palette = palettes.addObject();
        palette.put("index", 0);
        palette.putObject("palette")
                .putArray("originalArgb8888").add(0xFFFF0000);
        ((ObjectNode) palette.path("palette"))
                .putArray("convertedRgb565").add(0xF800);
        final var artifacts = manifest.putArray("artifacts");
        artifacts.addObject().put("file", "oversized.png").put("index", 0);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);

        final FileSystemPackerWorkspaceService service = service();
        final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertFalse(result.canPack());
        assertTrue(result.assets().getFirst().diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("Invalid glyph dimensions for oversized.png")));
    }

    @Test
    void fragileTileIndicesRemainWarningOnlyForValidation() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-validation-fragile-indices"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final Path manifestPath = assetRoot.resolve("asset.json");
        writeTwoColorTilePng(assetRoot.resolve("checker.png"), 16);

        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        final ObjectNode output = (ObjectNode) manifest.path("output");
        output.putObject("metadata").put("tile_size", "16x16");
        final ObjectNode pipeline = output.putObject("pipeline");
        final var palettes = pipeline.putArray("palettes");
        final ObjectNode palette = palettes.addObject();
        palette.put("index", 0);
        palette.putObject("palette")
                .putArray("originalArgb8888").add(0xFFFF0000);
        ((ObjectNode) palette.path("palette"))
                .putArray("convertedRgb565").add(0xF800);
        final var artifacts = manifest.putArray("artifacts");
        artifacts.addObject().put("file", "checker.png").put("index", 0);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);

        final FileSystemPackerWorkspaceService service = service();
        final var details = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), AssetReference.forAssetId(1)));
        final var validation = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, validation.status());
        assertTrue(validation.canPack());
        assertTrue(details.diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("fragile palette indices")));
    }

    @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.GLYPH_BANK,
                OutputFormatCatalog.GLYPH_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\""));
        assertTrue(manifestJson.contains("\"pipeline\""));

        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 createsSceneBankAssetAndWritesStudioSupportFile() throws Exception {
        final Path projectRoot = tempDir.resolve("created-scene-bank");
        final FileSystemPackerWorkspaceService service = service();

        final var result = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "scenes/overworld",
                "overworld",
                AssetFamilyCatalog.SCENE_BANK,
                OutputFormatCatalog.SCENE_TILED_V1,
                OutputCodecCatalog.NONE,
                false));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        final Path assetRoot = projectRoot.resolve("assets/scenes/overworld");
        assertTrue(Files.isRegularFile(assetRoot.resolve("asset.json")));
        assertTrue(Files.isRegularFile(assetRoot.resolve("scene-bank.studio.json")));
        final var supportFile = MAPPER.readTree(assetRoot.resolve("scene-bank.studio.json").toFile());
        assertEquals(16, supportFile.path("map_width").asInt());
        assertEquals(16, supportFile.path("map_height").asInt());
        assertEquals(4, supportFile.path("layer_count").asInt());
        assertEquals(false, supportFile.path("layers").get(0).path("enabled").asBoolean());
        assertEquals("Layer 1", supportFile.path("layers").get(0).path("name").asText());
        assertEquals(16, supportFile.path("layers").get(0).path("width").asInt());
        assertEquals(16, supportFile.path("layers").get(0).path("height").asInt());
        assertEquals("scene.tmx", supportFile.path("layers").get(0).path("tilemap").asText());
        assertEquals(1.0d, supportFile.path("layers").get(0).path("parallax_factor_x").asDouble());
        assertEquals(1.0d, supportFile.path("layers").get(0).path("parallax_factor_y").asDouble());
        assertEquals(0, supportFile.path("layers").get(0).path("default_palette_id").asInt());
    }

    @Test
    void packValidationBlocksSceneBankWhenAcceptedRequestArtifactIsMissing() throws Exception {
        final Path projectRoot = tempDir.resolve("scene-pack-missing-request");
        final FileSystemPackerWorkspaceService service = service();
        final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "scenes/overworld",
                "overworld",
                AssetFamilyCatalog.SCENE_BANK,
                OutputFormatCatalog.SCENE_TILED_V1,
                OutputCodecCatalog.NONE,
                false));

        assertEquals(PackerOperationStatus.SUCCESS, createResult.status());
        enableSceneLayer(projectRoot.resolve("assets/scenes/overworld/scene-bank.studio.json"));

        final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertFalse(result.canPack());
        assertTrue(result.assets().stream()
                .flatMap(asset -> asset.diagnostics().stream())
                .anyMatch(diagnostic -> diagnostic.message().contains("Accepted Scene Bank request artifact is missing")));
    }

    @Test
    void packsSceneBankFromAcceptedRequestArtifactWithoutParsingEditorFiles() throws Exception {
        final Path projectRoot = tempDir.resolve("scene-pack-success");
        final FileSystemPackerWorkspaceService service = service();
        final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "scenes/overworld",
                "overworld",
                AssetFamilyCatalog.SCENE_BANK,
                OutputFormatCatalog.SCENE_TILED_V1,
                OutputCodecCatalog.NONE,
                true));

        assertEquals(PackerOperationStatus.SUCCESS, createResult.status());
        final Path assetRoot = projectRoot.resolve("assets/scenes/overworld");
        enableSceneLayer(assetRoot.resolve("scene-bank.studio.json"));
        Files.writeString(assetRoot.resolve("layer-1.tmx"), "editor surface should be ignored by packer");
        writeScenePackRequest(assetRoot.resolve("scene-bank.pack-request.json"));

        final var result = service.packWorkspace(new PackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        final byte[] assetsPa = Files.readAllBytes(projectRoot.resolve("build/assets.pa"));
        final int headerLength = readLeInt(assetsPa, 8);
        final long payloadOffset = readLeLong(assetsPa, 12);
        final byte[] payload = java.util.Arrays.copyOfRange(assetsPa, (int) payloadOffset, assetsPa.length);
        assertEquals('S', payload[0]);
        assertEquals('C', payload[1]);
        assertEquals('N', payload[2]);
        assertEquals('E', payload[3]);
        final var assetTable = MAPPER.readTree(projectRoot.resolve("build/asset_table.json").toFile());
        assertEquals(1, assetTable.size());
        assertEquals("SCENE", assetTable.get(0).path("bank_type").asText());
        assertEquals("NONE", assetTable.get(0).path("codec").asText());
        assertEquals(expectedSceneDecodedSize(4, 256), assetTable.get(0).path("decoded_size").asInt());
        assertEquals(1, readLeShort(payload, 4));
        assertEquals(4, readLeShort(payload, 6));
        assertEquals(0b0000_0001, payload[12] & 0xFF);
        assertEquals(7, readLeInt(payload, 13));
        assertEquals(16, payload[17] & 0xFF);
        assertEquals(16, readLeInt(payload, 28));
        assertEquals(16, readLeInt(payload, 32));
        assertEquals(256, readLeInt(payload, 36));
        assertEquals(0b0000_0011, payload[44] & 0xFF);
        assertEquals(2, payload[45] & 0xFF);
        assertEquals(11, readLeShort(payload, 46));
        assertTrue(headerLength > 0);
    }

    @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.GLYPH_BANK,
                OutputFormatCatalog.GLYPH_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().stream().noneMatch(diagnostic -> diagnostic.blocking()));
        assertTrue(detailsResult.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Output metadata for glyph bank cannot be empty")));
    }

    @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.GLYPH_BANK,
                OutputFormatCatalog.GLYPH_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", "16x16",
                        "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("16x16", manifest.path("output").path("metadata").path("tile_size").asText());
        assertEquals("1", manifest.path("output").path("metadata").path("channels").asText());
        assertTrue(manifest.path("output").path("pipeline").isMissingNode() || manifest.path("output").path("pipeline").isObject());
    }

    @Test
    void updateAssetContractPreservesPipelineMetadataUnderOutput() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("update-contract-pipeline"));
        final Path manifestPath = projectRoot.resolve("assets/ui/atlas/asset.json");
        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
        final ObjectNode pipelineNode = ((ObjectNode) manifest.path("output")).putObject("pipeline");
        pipelineNode.putObject("samples").putObject("1").put("offset", 0).put("length", 64);
        pipelineNode.put("normalized", true);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);

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

        assertTrue(result.success());

        final var updatedManifest = MAPPER.readTree(manifestPath.toFile());
        assertTrue(updatedManifest.path("output").path("pipeline").path("normalized").asBoolean());
        assertEquals(64, updatedManifest.path("output").path("pipeline").path("samples").path("1").path("length").asInt());
    }

    @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 updateAssetBuildParticipationPatchesRegistryAndLoadedSnapshotWithoutWholeProjectReload() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("update-build-participation"));
        final CountingLoader loader = countingLoader();
        final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);

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

        final var result = service.updateAssetBuildParticipation(new UpdateAssetBuildParticipationRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                false));

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

        final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
                project(projectRoot),
                AssetReference.forAssetId(1)));
        assertEquals(PackerBuildParticipation.EXCLUDED, detailsResult.details().summary().buildParticipation());

        final var registry = MAPPER.readTree(projectRoot.resolve("assets/.prometeu/index.json").toFile());
        assertFalse(registry.path("assets").get(0).path("included_in_build").asBoolean(true));
    }

    @Test
    void updateAssetBuildParticipationBlocksSceneBankWithoutEnabledLayers() throws Exception {
        final Path projectRoot = tempDir.resolve("update-build-participation-scene");
        final FileSystemPackerWorkspaceService service = service();

        final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "scenes/overworld",
                "overworld",
                AssetFamilyCatalog.SCENE_BANK,
                OutputFormatCatalog.SCENE_TILED_V1,
                OutputCodecCatalog.NONE,
                false));

        assertEquals(PackerOperationStatus.SUCCESS, createResult.status());

        final var result = service.updateAssetBuildParticipation(new UpdateAssetBuildParticipationRequest(
                project(projectRoot),
                createResult.assetReference(),
                true));

        assertEquals(PackerOperationStatus.FAILED, result.status());
        assertTrue(result.summary().contains("enable at least one layer"));
    }

    @Test
    void updateAssetContractDropsPersistedBankCompositionWhenContractFingerprintChanges() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("update-contract-drops-bank"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        writeTilePng(assetRoot.resolve("confirm.png"), 16);
        writeTilePng(assetRoot.resolve("cancel.png"), 16);
        final FileSystemPackerWorkspaceService service = service();

        final var applyResult = service.applyBankComposition(new ApplyBankCompositionRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                List.of("cancel.png", "confirm.png")));
        assertTrue(applyResult.success());

        final var updateResult = service.updateAssetContract(new UpdateAssetContractRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                true,
                OutputCodecCatalog.NONE,
                Map.of(),
                Map.of("tile_size", "32x32")));
        assertTrue(updateResult.success());

        final var manifest = MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
        assertTrue(manifest.path("inputs").isMissingNode());
        assertTrue(manifest.path("artifacts").isArray());
        assertTrue(manifest.path("artifacts").isEmpty());

        final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
                project(projectRoot),
                AssetReference.forAssetId(1)));
        assertTrue(detailsResult.details().bankComposition().selectedFiles().isEmpty());
    }

    @Test
    void applyBankCompositionWritesArtifactsAndRefreshesSnapshotWithoutWholeProjectReload() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("apply-bank-composition"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        writeTilePng(assetRoot.resolve("confirm.png"), 16);
        writeTilePng(assetRoot.resolve("cancel.png"), 16);
        final CountingLoader loader = countingLoader();
        final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);

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

        final var applyResult = service.applyBankComposition(new ApplyBankCompositionRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                List.of("cancel.png", "confirm.png")));

        assertTrue(applyResult.success());
        assertNull(applyResult.errorMessage());
        assertEquals(1, loader.loadCount());

        final var manifest = MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
        assertTrue(manifest.path("inputs").isMissingNode());
        assertTrue(manifest.path("artifacts").get(0).hasNonNull("artifact_id"));
        assertEquals("cancel.png", manifest.path("artifacts").get(0).path("file").asText());
        assertEquals(0, manifest.path("artifacts").get(0).path("index").asInt());
        assertTrue(manifest.path("artifacts").get(0).hasNonNull("fingerprint"));
        assertTrue(manifest.path("artifacts").get(1).hasNonNull("artifact_id"));
        assertEquals("confirm.png", manifest.path("artifacts").get(1).path("file").asText());
        assertEquals(1, manifest.path("artifacts").get(1).path("index").asInt());
        assertTrue(manifest.path("artifacts").get(1).hasNonNull("fingerprint"));

        final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
                project(projectRoot),
                AssetReference.forAssetId(1)));
        assertEquals(PackerOperationStatus.PARTIAL, detailsResult.status());
        assertTrue(detailsResult.diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("must declare at least one palette")));
        assertEquals(
                List.of("cancel.png", "confirm.png"),
                detailsResult.details().bankComposition().selectedFiles().stream().map(file -> file.path()).toList());
        assertNotNull(detailsResult.details().bankComposition().selectedFiles().getFirst().metadata().get("artifactId"));
        assertEquals(1, loader.loadCount());
    }

    @Test
    void getAssetDetailsBackfillsLegacyArtifactIdsAndFingerprints() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("legacy-artifact-identity"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        writeTilePng(assetRoot.resolve("confirm.png"), 16);
        final ObjectNode manifest = (ObjectNode) MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
        final var artifacts = manifest.putArray("artifacts");
        artifacts.addObject()
                .put("file", "confirm.png")
                .put("index", 0);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(assetRoot.resolve("asset.json").toFile(), manifest);
        final FileSystemPackerWorkspaceService service = service();

        final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
                project(projectRoot),
                AssetReference.forAssetId(1)));

        final var migrated = MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
        final var migratedArtifact = migrated.path("artifacts").get(0);
        assertTrue(migratedArtifact.hasNonNull("artifact_id"));
        assertTrue(migratedArtifact.hasNonNull("fingerprint"));
        final var selectedFile = detailsResult.details().bankComposition().selectedFiles().getFirst();
        assertEquals(migratedArtifact.path("artifact_id").asText(), selectedFile.metadata().get("artifactId"));
        assertEquals(migratedArtifact.path("fingerprint").asText(), selectedFile.metadata().get("selectedFingerprint"));
    }

    @Test
    void packValidationBlocksGlyphMaterialPaletteWhenSchemaVersionIsNewer() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("glyph-material-stale-palette"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        writeTilePng(assetRoot.resolve("confirm.png"), 16);
        final FileSystemPackerWorkspaceService service = service();

        assertTrue(service.applyBankComposition(new ApplyBankCompositionRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                List.of("confirm.png"))).success());
        assertTrue(service.applyPaletteOverhauling(new ApplyPaletteOverhaulingRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                List.of(Map.of(
                        "originalArgb8888", List.of(0xFFFF0000),
                        "convertedRgb565", List.of(0xF800))))).success());

        final var manifest = MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
        final var selectedArtifact = manifest.path("artifacts").get(0);
        writeGlyphMaterialJson(
                assetRoot,
                """
                {
                  "schema_version": 1,
                  "schemas": [
                    {
                      "schema_id": "schema-1",
                      "status": "ok",
                      "version": 2,
                      "material_indices": { "1": "#FF0000" },
                      "artifact_bindings": [
                        { "artifact_id": "%s", "fingerprint": "%s" }
                      ]
                    }
                  ],
                  "palettes": [
                    {
                      "palette_id": "palette-1",
                      "schema_id": "schema-1",
                      "version": 1,
                      "colors": ["#FF0000"]
                    }
                  ]
                }
                """.formatted(
                        selectedArtifact.path("artifact_id").asText(),
                        selectedArtifact.path("fingerprint").asText()));

        final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertFalse(result.canPack());
        assertTrue(result.assets().getFirst().diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("is stale because version 1 is older than schema 'schema-1' version 2")));
    }

    @Test
    void packValidationBlocksGlyphMaterialSchemaWhenArtifactFingerprintDrifts() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("glyph-material-drift"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final Path tilePath = assetRoot.resolve("confirm.png");
        writeTilePng(tilePath, 16);
        final FileSystemPackerWorkspaceService service = service();

        assertTrue(service.applyBankComposition(new ApplyBankCompositionRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                List.of("confirm.png"))).success());
        assertTrue(service.applyPaletteOverhauling(new ApplyPaletteOverhaulingRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                List.of(Map.of(
                        "originalArgb8888", List.of(0xFFFF0000),
                        "convertedRgb565", List.of(0xF800))))).success());

        final var manifest = MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
        final var selectedArtifact = manifest.path("artifacts").get(0);
        writeGlyphMaterialJson(
                assetRoot,
                """
                {
                  "schema_version": 1,
                  "schemas": [
                    {
                      "schema_id": "schema-1",
                      "status": "ok",
                      "version": 1,
                      "material_indices": { "1": "#FF0000" },
                      "artifact_bindings": [
                        { "artifact_id": "%s", "fingerprint": "%s" }
                      ]
                    }
                  ],
                  "palettes": [
                    {
                      "palette_id": "palette-1",
                      "schema_id": "schema-1",
                      "version": 1,
                      "colors": ["#FF0000"]
                    }
                  ]
                }
                """.formatted(
                        selectedArtifact.path("artifact_id").asText(),
                        selectedArtifact.path("fingerprint").asText()));
        writeSolidTilePng(tilePath, 16, 0xFF00FF00);

        final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertFalse(result.canPack());
        assertTrue(result.assets().getFirst().diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("artifact '" + selectedArtifact.path("artifact_id").asText() + "' drifted")));
        assertTrue(result.assets().getFirst().diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("palette 'palette-1' is invalid because schema 'schema-1' is inconsistent")));
    }

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

        final var response = service.applyPaletteOverhauling(new ApplyPaletteOverhaulingRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                List.of(
                        Map.of(
                                "originalArgb8888", List.of(0xFFFF0000, 0xFF00FF00),
                                "convertedRgb565", List.of(0xF800, 0x07E0)),
                        Map.of(
                                "originalArgb8888", List.of(0xFF0000FF),
                                "convertedRgb565", List.of(0x001F)))));

        assertTrue(response.success());

        final var manifest = MAPPER.readTree(projectRoot.resolve("assets/ui/atlas/asset.json").toFile());
        assertEquals(2, manifest.path("output").path("pipeline").path("palettes").size());
        assertEquals(0, manifest.path("output").path("pipeline").path("palettes").get(0).path("index").asInt());
        assertEquals(0xFFFF0000, manifest.path("output").path("pipeline").path("palettes").get(0).path("palette").path("originalArgb8888").get(0).asInt());
        assertEquals(1, manifest.path("output").path("pipeline").path("palettes").get(1).path("index").asInt());
        assertEquals(0x001F, manifest.path("output").path("pipeline").path("palettes").get(1).path("palette").path("convertedRgb565").get(0).asInt());
    }

    @Test
    void applyPaletteOverhaulingFailsWhenSelectionIsEmpty() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("apply-palette-overhauling-empty"));
        final FileSystemPackerWorkspaceService service = service();

        final var response = service.applyPaletteOverhauling(new ApplyPaletteOverhaulingRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                List.of()));

        assertFalse(response.success());
        assertTrue(response.errorMessage().contains("At least one palette"));
    }

    @Test
    void deepSyncPreservesContractDrivenBankInvalidationAfterPatchUpdate() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("deep-sync-bank-composition"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        writeTilePng(assetRoot.resolve("confirm.png"), 16);
        writeTilePng(assetRoot.resolve("cancel.png"), 16);
        final FileSystemPackerWorkspaceService service = service();

        final var applyResult = service.applyBankComposition(new ApplyBankCompositionRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                List.of("cancel.png", "confirm.png")));
        assertTrue(applyResult.success());

        final var updateResult = service.updateAssetContract(new UpdateAssetContractRequest(
                project(projectRoot),
                AssetReference.forAssetId(1),
                true,
                OutputCodecCatalog.NONE,
                Map.of(),
                Map.of("tile_size", "32x32")));
        assertTrue(updateResult.success());

        final var patchedDetails = service.getAssetDetails(new GetAssetDetailsRequest(
                project(projectRoot),
                AssetReference.forAssetId(1)));
        assertTrue(patchedDetails.details().bankComposition().availableFiles().isEmpty());
        assertTrue(patchedDetails.details().bankComposition().selectedFiles().isEmpty());

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

        final var refreshedDetails = service.getAssetDetails(new GetAssetDetailsRequest(
                project(projectRoot),
                AssetReference.forAssetId(1)));
        assertTrue(refreshedDetails.details().bankComposition().availableFiles().isEmpty());
        assertTrue(refreshedDetails.details().bankComposition().selectedFiles().isEmpty());
    }

    @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",
                  "output": {
                    "format": "GLYPH/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",
                  "output": {
                    "format": "GLYPH/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.GLYPH_BANK,
                OutputFormatCatalog.GLYPH_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.GLYPH_BANK,
                OutputFormatCatalog.GLYPH_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.GLYPH_BANK,
                OutputFormatCatalog.GLYPH_INDEXED_V1,
                OutputCodecCatalog.NONE,
                false));
        final CreateAssetResult target = service.createAsset(new CreateAssetRequest(
                project(projectRoot),
                "ui/target",
                "target",
                AssetFamilyCatalog.GLYPH_BANK,
                OutputFormatCatalog.GLYPH_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.GLYPH_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.GLYPH_BANK,
                    OutputFormatCatalog.GLYPH_INDEXED_V1,
                    OutputCodecCatalog.NONE,
                    true)));
            final Future<CreateAssetResult> second = executor.submit(() -> service.createAsset(new CreateAssetRequest(
                    project(projectRoot),
                    "ui/atlas-b",
                    "atlas_b",
                    AssetFamilyCatalog.GLYPH_BANK,
                    OutputFormatCatalog.GLYPH_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 mapper = new ObjectMapper();
        final var foundation = new p.packer.services.PackerWorkspaceFoundation(mapper);
        final var parser = new p.packer.services.PackerAssetDeclarationParser(mapper);
        final var assetWalker = new PackerAssetWalker(mapper);
        final var cacheRepository = new FileSystemPackerCacheRepository(mapper);
        final var assetMaterializer = new PackerRuntimeAssetMaterializer(assetWalker);
        final var runtimeRegistry = new 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, assetMaterializer);
        final var writeCoordinator = new p.packer.services.PackerProjectWriteCoordinator();
        return new FileSystemPackerWorkspaceService(
                new ObjectMapper(),
                foundation,
                detailsService,
                actionReadService,
                runtimePatchService,
                runtimeRegistry,
                cacheRepository,
                writeCoordinator,
                eventSink);
    }

    private CountingLoader countingLoader() {
        final var mapper = new ObjectMapper();
        final var foundation = new p.packer.services.PackerWorkspaceFoundation(mapper);
        final var parser = new p.packer.services.PackerAssetDeclarationParser(mapper);
        final var assetWalker = new PackerAssetWalker(mapper);
        final var cacheRepository = new FileSystemPackerCacheRepository(mapper);
        final var assetMaterializer = new PackerRuntimeAssetMaterializer(assetWalker);
        return new CountingLoader(new PackerRuntimeLoader(foundation, parser, cacheRepository, assetMaterializer));
    }

    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;
    }

    private void writeTilePng(Path path, int tileSize) throws Exception {
        writeSolidTilePng(path, tileSize, 0xFFFF0000);
    }

    private void writeSolidTilePng(Path path, int tileSize, int argb) throws Exception {
        Files.createDirectories(path.getParent());
        final BufferedImage image = new BufferedImage(tileSize, tileSize, BufferedImage.TYPE_INT_ARGB);
        for (int y = 0; y < tileSize; y += 1) {
            for (int x = 0; x < tileSize; x += 1) {
                image.setRGB(x, y, argb);
            }
        }
        ImageIO.write(image, "png", path.toFile());
    }

    private void writeTwoColorTilePng(Path path, int tileSize) throws Exception {
        Files.createDirectories(path.getParent());
        final BufferedImage image = new BufferedImage(tileSize, tileSize, BufferedImage.TYPE_INT_ARGB);
        for (int y = 0; y < tileSize; y += 1) {
            for (int x = 0; x < tileSize; x += 1) {
                image.setRGB(x, y, (x + y) % 2 == 0 ? 0xFFFF0000 : 0xFF00FF00);
            }
        }
        ImageIO.write(image, "png", path.toFile());
    }

    private void writeGlyphMaterialJson(Path assetRoot, String content) throws Exception {
        Files.writeString(assetRoot.resolve("glyph-material.json"), content);
    }

    private String fingerprint(Path path) throws Exception {
        return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path)));
    }

    private void enableSceneLayer(Path supportFilePath) throws Exception {
        final ObjectNode supportFile = (ObjectNode) MAPPER.readTree(supportFilePath.toFile());
        ((ObjectNode) supportFile.path("layers").get(0)).put("enabled", true);
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(supportFilePath.toFile(), supportFile);
    }

    private void writeScenePackRequest(Path path) throws Exception {
        final ObjectNode root = MAPPER.createObjectNode();
        root.put("schema_version", 1);
        root.put("format", "SCENE/tilemap_v1");
        root.put("version", 1);
        final ArrayNode layers = root.putArray("layers");
        for (int layerIndex = 0; layerIndex < 4; layerIndex += 1) {
            final ObjectNode layer = layers.addObject();
            final boolean active = layerIndex == 0;
            layer.put("active", active);
            layer.put("glyph_asset_id", active ? 7 : 0);
            layer.put("tile_size", active ? 16 : 0);
            layer.put("parallax_factor_x", 1.0);
            layer.put("parallax_factor_y", 1.0);
            layer.put("width", 16);
            layer.put("height", 16);
            final ArrayNode tiles = layer.putArray("tiles");
            for (int tileIndex = 0; tileIndex < 256; tileIndex += 1) {
                final ObjectNode tile = tiles.addObject();
                final boolean tileActive = active && tileIndex == 0;
                tile.put("active", tileActive);
                tile.put("glyph_id", tileActive ? 11 : 0);
                tile.put("palette_id", tileActive ? 2 : 0);
                tile.put("flip_x", tileActive);
                tile.put("flip_y", false);
            }
        }
        MAPPER.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), root);
    }

    private static int readLeInt(byte[] bytes, int offset) {
        return (bytes[offset] & 0xFF)
                | ((bytes[offset + 1] & 0xFF) << 8)
                | ((bytes[offset + 2] & 0xFF) << 16)
                | ((bytes[offset + 3] & 0xFF) << 24);
    }

    private static int readLeShort(byte[] bytes, int offset) {
        return (bytes[offset] & 0xFF)
                | ((bytes[offset + 1] & 0xFF) << 8);
    }

    private static long readLeLong(byte[] bytes, int offset) {
        return ((long) bytes[offset] & 0xFF)
                | (((long) bytes[offset + 1] & 0xFF) << 8)
                | (((long) bytes[offset + 2] & 0xFF) << 16)
                | (((long) bytes[offset + 3] & 0xFF) << 24)
                | (((long) bytes[offset + 4] & 0xFF) << 32)
                | (((long) bytes[offset + 5] & 0xFF) << 40)
                | (((long) bytes[offset + 6] & 0xFF) << 48)
                | (((long) bytes[offset + 7] & 0xFF) << 56);
    }

    private static int expectedSceneDecodedSize(int layerCount, int tilesPerLayer) {
        return layerCount * (16 + (tilesPerLayer * 8));
    }

    private static int readLeUnsignedShort(byte[] bytes, int offset) {
        return (bytes[offset] & 0xFF) | ((bytes[offset + 1] & 0xFF) << 8);
    }
}
