package p.packer.services;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.messages.AssetReference;
import p.packer.messages.GetAssetDetailsRequest;
import p.packer.messages.PackerOperationStatus;
import p.packer.messages.PackerProjectContext;
import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.PackerAssetState;
import p.packer.messages.assets.PackerBuildParticipation;
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.testing.PackerFixtureLocator;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Map;

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

final class PackerAssetDetailsServiceTest {
    @TempDir
    Path tempDir;

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

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

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals(PackerAssetState.REGISTERED, result.details().summary().state());
        assertEquals(PackerBuildParticipation.INCLUDED, result.details().summary().buildParticipation());
        assertEquals("fixture-uuid-1", result.details().summary().identity().assetUuid());
        assertEquals("ui_atlas", result.details().summary().identity().assetName());
        assertEquals("GLYPH/indexed_v1", result.details().outputFormat().displayName());
        assertEquals(List.of(OutputCodecCatalog.NONE), result.details().availableOutputCodecs());
        assertEquals(List.of(), result.details().codecConfigurationFieldsByCodec().get(OutputCodecCatalog.NONE));
        assertNotNull(result.details().bankComposition());
        assertTrue(result.details().bankComposition().selectedFiles().isEmpty());
        assertTrue(result.details().pipelinePalettes().isEmpty());
        assertTrue(result.diagnostics().stream().noneMatch(diagnostic -> diagnostic.blocking()));
        assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Output metadata for glyph bank cannot be empty")));
    }

    @Test
    void exposesPipelinePalettesFromOutputPipelineMetadata() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed-pipeline-palettes"));
        final Path manifestPath = projectRoot.resolve("assets/ui/atlas/asset.json");

        final ObjectMapper mapper = new ObjectMapper();
        final ObjectNode manifest = (ObjectNode) mapper.readTree(manifestPath.toFile());
        final ObjectNode pipeline = ((ObjectNode) manifest.path("output")).putObject("pipeline");
        final var palettes = pipeline.putArray("palettes");
        final ObjectNode declaration = palettes.addObject();
        declaration.put("index", 3);
        declaration.putObject("palette")
                .putArray("originalArgb8888").add(0xFFFF0000).add(0xFF00FF00);
        ((ObjectNode) declaration.path("palette"))
                .putArray("convertedRgb565").add(0xF800).add(0x07E0);
        mapper.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);

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

        assertEquals(1, result.details().pipelinePalettes().size());
        assertEquals(3, result.details().pipelinePalettes().getFirst().get("index"));
        assertEquals(
                List.of(0xFFFF0000, 0xFF00FF00),
                result.details().pipelinePalettes().getFirst().get("originalArgb8888"));
    }

    @Test
    void projectsBankCompositionAvailableAndSelectedFiles() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed-bank-composition"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final BufferedImage tile = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
        ImageIO.write(tile, "png", assetRoot.resolve("confirm.png").toFile());
        ImageIO.write(tile, "png", assetRoot.resolve("cancel.png").toFile());
        final Path manifestPath = assetRoot.resolve("asset.json");

        final ObjectMapper mapper = new ObjectMapper();
        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 PackerAssetDetailsService service = service();
        final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), AssetReference.forAssetId(1)));

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertTrue(result.diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("must declare at least one palette")));
        assertTrue(result.details().bankComposition().availableFiles().stream()
                .anyMatch(file -> file.path().equals("confirm.png")));
        assertTrue(result.details().bankComposition().selectedFiles().stream()
                .anyMatch(file -> file.path().equals("confirm.png")));
        assertTrue(result.details().bankComposition().availableFiles().stream()
                .allMatch(file -> !file.displayName().isBlank()));
        assertFalse(Files.exists(projectRoot.resolve("assets/ui/atlas/confirm.tile.json")));
        assertFalse(Files.exists(projectRoot.resolve("assets/ui/atlas/confirm.palette.json")));
        assertFalse(Files.exists(projectRoot.resolve("assets/.prometeu/ui/atlas/confirm.tile.json")));
        assertFalse(Files.exists(projectRoot.resolve("assets/.prometeu/ui/atlas/confirm.palette.json")));
    }

    @Test
    void prefersArtifactsSelectionOrder() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed-artifacts-order"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        final BufferedImage tile = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
        ImageIO.write(tile, "png", assetRoot.resolve("a.png").toFile());
        ImageIO.write(tile, "png", assetRoot.resolve("b.png").toFile());
        final Path manifestPath = assetRoot.resolve("asset.json");

        final ObjectMapper mapper = new ObjectMapper();
        final ObjectNode manifest = (ObjectNode) mapper.readTree(manifestPath.toFile());
        final var artifacts = manifest.putArray("artifacts");
        artifacts.addObject().put("file", "b.png").put("index", 0);
        artifacts.addObject().put("file", "a.png").put("index", 1);
        mapper.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);

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

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertTrue(result.diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("must declare at least one palette")));
        assertEquals(
                List.of("b.png", "a.png"),
                result.details().bankComposition().selectedFiles().stream().map(file -> file.path()).toList());
    }

    @Test
    void includesFileScopedDiagnosticsInAssetDetailsDiagnostics() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed-file-diagnostics"));
        final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
        writeTilePng(assetRoot.resolve("wrong-palette.png"), 32);

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

        assertEquals(PackerOperationStatus.PARTIAL, result.status());
        assertTrue(result.diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("Invalid glyph dimensions for wrong-palette.png")));
        assertTrue(result.details().diagnostics().stream()
                .anyMatch(diagnostic -> diagnostic.message().contains("Invalid glyph dimensions for wrong-palette.png")));
    }

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

        final ObjectMapper mapper = new ObjectMapper();
        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 foundation = new PackerWorkspaceFoundation(mapper);
        final var parser = new PackerAssetDeclarationParser(mapper);
        final var assetWalker = new PackerAssetWalker(mapper);
        final var cacheRepository = new FileSystemPackerCacheRepository(mapper);
        final var assetMaterializer = new PackerRuntimeAssetMaterializer(assetWalker);
        final var runtimeLoader = new PackerRuntimeLoader(foundation, parser, cacheRepository, assetMaterializer);
        final PackerProjectContext project = project(projectRoot);
        final var loadedSnapshot = runtimeLoader.load(project, 1L);
        final var originalAsset = loadedSnapshot.assets().getFirst();
        final var mutatedAsset = new p.packer.models.PackerRuntimeAsset(
                originalAsset.assetRoot(),
                originalAsset.manifestPath(),
                originalAsset.registryEntry(),
                originalAsset.parsedDeclaration(),
                p.packer.models.PackerRuntimeWalkProjection.EMPTY,
                originalAsset.walkDiagnostics());
        final var mutatedSnapshot = new p.packer.models.PackerRuntimeSnapshot(
                loadedSnapshot.generation(),
                loadedSnapshot.registry(),
                List.of(mutatedAsset),
                loadedSnapshot.cacheState());

        final var runtimeRegistry = new PackerRuntimeRegistry((ignoredProject, ignoredGeneration) -> mutatedSnapshot);
        final var resolver = new PackerAssetReferenceResolver(foundation.lookup());
        final PackerAssetDetailsService service = new PackerAssetDetailsService(runtimeRegistry, resolver);
        final var result = service.getAssetDetails(new GetAssetDetailsRequest(project, AssetReference.forAssetId(1)));

        final var selectedFile = result.details().bankComposition().selectedFiles().getFirst();
        final var tile = (Map<?, ?>) selectedFile.metadata().get("tile");
        final var palette = (Map<?, ?>) selectedFile.metadata().get("palette");
        assertEquals(16, ((Number) tile.get("width")).intValue());
        assertEquals(16, ((Number) tile.get("height")).intValue());
        assertFalse(((List<?>) palette.get("originalArgb8888")).isEmpty());
    }

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

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

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals(PackerAssetState.UNREGISTERED, result.details().summary().state());
        assertEquals(PackerBuildParticipation.EXCLUDED, result.details().summary().buildParticipation());
        assertEquals("orphan-uuid-1", result.details().summary().identity().assetUuid());
        assertEquals("ui_sounds", result.details().summary().identity().assetName());
        assertEquals(List.of(OutputCodecCatalog.NONE), result.details().availableOutputCodecs());
        assertEquals(List.of(), result.details().codecConfigurationFieldsByCodec().get(OutputCodecCatalog.NONE));
    }

    @Test
    void exposesMetadataFieldsFromOutputMetadataContract() throws Exception {
        final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed-with-metadata"));
        final Path manifestPath = projectRoot.resolve("assets/ui/atlas/asset.json");

        final ObjectMapper mapper = new ObjectMapper();
        final var manifest = mapper.readTree(manifestPath.toFile());
        final ObjectNode output = (ObjectNode) manifest.path("output");
        final ObjectNode metadata = output.putObject("metadata");
        metadata.put("tile_size", "16x16");
        metadata.put("channels", "1");
        mapper.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);

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

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals(
                Map.of("tile_size", "16x16", "channels", "1"),
                result.details().metadataFields().stream().collect(java.util.stream.Collectors.toMap(
                        field -> field.key(),
                        field -> field.value())));
    }

    @Test
    void exposesAudioMetadataDefaultsFromOutputContract() throws Exception {
        final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("orphan-audio-defaults"));
        final PackerAssetDetailsService service = service();

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

        assertEquals(PackerOperationStatus.SUCCESS, result.status());
        assertEquals(
                Map.of("sample_rate", "44100", "channels", "1"),
                result.details().metadataFields().stream().collect(java.util.stream.Collectors.toMap(
                        field -> field.key(),
                        field -> field.value())));
    }

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

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

        assertEquals(PackerOperationStatus.FAILED, result.status());
        assertEquals(PackerAssetState.UNREGISTERED, result.details().summary().state());
        assertEquals(PackerBuildParticipation.EXCLUDED, result.details().summary().buildParticipation());
        assertFalse(result.diagnostics().isEmpty());
    }

    @Test
    void returnsFailureWhenReferenceCannotBeResolved() {
        final PackerAssetDetailsService service = service();

        final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(tempDir.resolve("empty")), AssetReference.forRelativeAssetRoot("missing/root")));

        assertEquals(PackerOperationStatus.FAILED, result.status());
        assertEquals(PackerAssetState.UNREGISTERED, result.details().summary().state());
        assertEquals(PackerBuildParticipation.EXCLUDED, result.details().summary().buildParticipation());
        assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("could not be resolved")));
    }

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

    private PackerAssetDetailsService service() {
        final var mapper = new ObjectMapper();
        final var foundation = new PackerWorkspaceFoundation(mapper);
        final var parser = new 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(new PackerRuntimeLoader(
                foundation,
                parser,
                cacheRepository,
                assetMaterializer));
        final var resolver = new PackerAssetReferenceResolver(foundation.lookup());
        return new PackerAssetDetailsService(runtimeRegistry, resolver);
    }

    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 {
        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, 0xFFFF0000);
            }
        }
        ImageIO.write(image, "png", path.toFile());
    }
}
