package p.packer.repositories;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils;
import p.packer.messages.assets.ScenePackLayerRequest;
import p.packer.messages.assets.ScenePackRequest;
import p.packer.messages.assets.ScenePackTileRequest;
import p.packer.messages.assets.OutputFormatCatalog;
import p.packer.messages.diagnostics.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.packer.models.*;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

public class PackerAssetWalker {
    private static final int GLYPH_BANK_EMITTED_SHEET_SIZE = 256;
    private static final int GLYPH_BANK_MAX_PALETTES = 64;
    private static final Set<OutputFormatCatalog> GLYPH_BANK_SUPPORTED_FORMATS = Set.of(
            OutputFormatCatalog.GLYPH_INDEXED_V1
    );

    private static final Set<OutputFormatCatalog> SOUND_BANK_SUPPORTED_FORMATS = Set.of(
            OutputFormatCatalog.SOUND_V1
    );
    private static final String SCENE_PACK_REQUEST_FILE = "scene-bank.pack-request.json";

    private final ObjectMapper mapper;
    private final PackerGlyphBankWalker glyphBankWalker;
    private final PackerSoundBankWalker soundBankWalker;

    public PackerAssetWalker(final ObjectMapper mapper) {
        this.mapper = mapper;
        this.glyphBankWalker = new PackerGlyphBankWalker(mapper);
        this.soundBankWalker = new PackerSoundBankWalker(mapper);
    }

    public PackerWalkResult walk(
            final Path assetRoot,
            final PackerAssetDeclaration declaration) {
        return walk(assetRoot, declaration, Optional.empty());
    }

    public PackerWalkResult walk(
            final Path assetRoot,
            final PackerAssetDeclaration declaration,
            final Optional<PackerAssetCacheEntry> priorAssetCache) {
        final List<PackerDiagnostic> diagnostics = new ArrayList<>();
        switch (declaration.assetFamily()) {
            case GLYPH_BANK -> {
                var metadata = declaration.outputMetadata();
                if (MapUtils.isEmpty(metadata)) {
                    diagnostics.add(new PackerDiagnostic(
                            PackerDiagnosticSeverity.WARNING,
                            PackerDiagnosticCategory.HYGIENE,
                            "Output metadata for glyph bank cannot be empty, using default values",
                            assetRoot,
                            false));
                    metadata = Map.of("tile_size", "16x16");
                }
                final var requirementBuildResult = buildGlyphBankRequirements(declaration, metadata);
                if (requirementBuildResult.hasError()) {
                    diagnostics.add(new PackerDiagnostic(
                            PackerDiagnosticSeverity.ERROR,
                            PackerDiagnosticCategory.STRUCTURAL,
                            requirementBuildResult.errorMessage(),
                            assetRoot,
                            true));
                    return new PackerWalkResult(List.of(), diagnostics);
                }
                diagnostics.addAll(glyphBankDeclarationDiagnostics(assetRoot, declaration, requirementBuildResult.requirements));
                final var walkResult = glyphBankWalker.walk(assetRoot, requirementBuildResult.requirements, priorAssetCache);
                diagnostics.addAll(walkResult.diagnostics());
                diagnostics.addAll(glyphBankSelectedArtifactDiagnostics(assetRoot, declaration, walkResult));
                diagnostics.addAll(glyphBankFragileIndexDiagnostics(declaration, walkResult));
                return new PackerWalkResult(walkResult.probeResults(), diagnostics);
            }
            case SOUND_BANK -> {
                var metadata = declaration.outputMetadata();
                if (MapUtils.isEmpty(metadata)) {
                    diagnostics.add(new PackerDiagnostic(
                            PackerDiagnosticSeverity.WARNING,
                            PackerDiagnosticCategory.HYGIENE,
                            "Output metadata for sound bank cannot be empty, using default values",
                            assetRoot,
                            false));
                    metadata = Map.of("sample_rate", "44100", "channels", "1");
                }
                final var result = buildSoundBankRequirements(declaration, metadata);
                if (result.hasError()) {
                    diagnostics.add(new PackerDiagnostic(
                            PackerDiagnosticSeverity.ERROR,
                            PackerDiagnosticCategory.STRUCTURAL,
                            result.errorMessage(),
                            assetRoot,
                            true));
                    return new PackerWalkResult(List.of(), diagnostics);
                }
                final var walkResult = soundBankWalker.walk(assetRoot, result.requirements, priorAssetCache);
                diagnostics.addAll(walkResult.diagnostics());
                return new PackerWalkResult(walkResult.probeResults(), diagnostics);
            }
            case SCENE_BANK -> {
                return walkSceneBank(assetRoot, declaration, diagnostics);
            }
            case UNKNOWN -> {
                diagnostics.add(new PackerDiagnostic(
                        PackerDiagnosticSeverity.WARNING,
                        PackerDiagnosticCategory.STRUCTURAL,
                        "Unknown asset family for declaration, skipping content walk",
                        assetRoot,
                        false));
                return new PackerWalkResult(List.of(), diagnostics);
            }
        }
        return PackerWalkResult.EMPTY;
    }

    private PackerWalkResult walkSceneBank(
            Path assetRoot,
            PackerAssetDeclaration declaration,
            List<PackerDiagnostic> diagnostics) {
        if (declaration.outputFormat() != OutputFormatCatalog.SCENE_TILED_V1) {
            diagnostics.add(new PackerDiagnostic(
                    PackerDiagnosticSeverity.ERROR,
                    PackerDiagnosticCategory.STRUCTURAL,
                    "Unsupported output format for Scene Bank: " + declaration.outputFormat(),
                    assetRoot,
                    true));
            return new PackerWalkResult(List.of(), diagnostics);
        }
        final Path requestPath = assetRoot.resolve(SCENE_PACK_REQUEST_FILE).toAbsolutePath().normalize();
        if (!Files.isRegularFile(requestPath)) {
            diagnostics.add(new PackerDiagnostic(
                    PackerDiagnosticSeverity.ERROR,
                    PackerDiagnosticCategory.STRUCTURAL,
                    "Accepted Scene Bank request artifact is missing: " + SCENE_PACK_REQUEST_FILE,
                    requestPath,
                    true));
            return new PackerWalkResult(List.of(), diagnostics);
        }
        try {
            final byte[] bytes = Files.readAllBytes(requestPath);
            final ScenePackRequest request = parseScenePackRequest(bytes);
            final PackerFileProbe probe = new PackerFileProbe(
                    requestPath,
                    "application/json",
                    bytes.length,
                    Math.max(0L, Files.getLastModifiedTime(requestPath).toMillis()),
                    bytes);
            return new PackerWalkResult(
                    List.of(new PackerProbeResult(
                            probe,
                            Map.of("scenePackRequest", request),
                            List.of())),
                    diagnostics);
        } catch (IOException | IllegalArgumentException exception) {
            diagnostics.add(new PackerDiagnostic(
                    PackerDiagnosticSeverity.ERROR,
                    PackerDiagnosticCategory.STRUCTURAL,
                    "Unable to read accepted Scene Bank request artifact: " + exception.getMessage(),
                    requestPath,
                    true));
            return new PackerWalkResult(List.of(), diagnostics);
        }
    }

    private ScenePackRequest parseScenePackRequest(byte[] bytes) throws IOException {
        final JsonNode root = mapper.readTree(bytes);
        if (root == null || !root.isObject()) {
            throw new IllegalArgumentException("request root must be a JSON object");
        }
        final String format = root.path("format").asText("SCENE/tilemap_v1").trim();
        if (!"SCENE/tilemap_v1".equals(format)) {
            throw new IllegalArgumentException("request format must be SCENE/tilemap_v1");
        }
        if (!(root.path("layers") instanceof ArrayNode layersNode) || layersNode.size() != 4) {
            throw new IllegalArgumentException("request must contain exactly 4 layers");
        }
        final List<ScenePackLayerRequest> layers = new ArrayList<>();
        for (JsonNode layerNode : layersNode) {
            final int width = layerNode.path("width").asInt(0);
            final int height = layerNode.path("height").asInt(0);
            if (!(layerNode.path("tiles") instanceof ArrayNode tilesNode)) {
                throw new IllegalArgumentException("each layer must contain a tiles array");
            }
            final List<ScenePackTileRequest> tiles = new ArrayList<>();
            for (JsonNode tileNode : tilesNode) {
                tiles.add(new ScenePackTileRequest(
                        tileNode.path("active").asBoolean(false),
                        tileNode.path("glyph_id").asInt(0),
                        tileNode.path("palette_id").asInt(0),
                        tileNode.path("flip_x").asBoolean(false),
                        tileNode.path("flip_y").asBoolean(false)));
            }
            layers.add(new ScenePackLayerRequest(
                    layerNode.path("active").asBoolean(false),
                    layerNode.path("glyph_asset_id").asInt(0),
                    layerNode.path("tile_size").asInt(0),
                    (float) layerNode.path("parallax_factor_x").asDouble(0.0d),
                    (float) layerNode.path("parallax_factor_y").asDouble(0.0d),
                    width,
                    height,
                    tiles));
        }
        return new ScenePackRequest(root.path("version").asInt(0), layers);
    }

    private RequirementBuildResult<PackerGlyphBankRequirements> buildGlyphBankRequirements(
            final PackerAssetDeclaration declaration,
            final Map<String, String> metadata) {
        if (!GLYPH_BANK_SUPPORTED_FORMATS.contains(declaration.outputFormat())) {
            return RequirementBuildResult.fail("Unsupported output format for glyph bank: " + declaration.outputFormat());
        }
        final var tileSizeStr = metadata.get("tile_size");
        if (StringUtils.isBlank(tileSizeStr)) {
            return RequirementBuildResult.fail("Tile size metadata for glyph bank cannot be empty");
        }
        final int tileSize;
        switch (tileSizeStr) {
            case "8x8": {
                tileSize = 8;
            } break;
            case "16x16": {
                tileSize = 16;
            } break;
            case "32x32": {
                tileSize = 32;
            } break;
            default: {
                return RequirementBuildResult.fail("Unsupported tile size for glyph bank: " + tileSizeStr);
            }
        }
        return RequirementBuildResult.success(new PackerGlyphBankRequirements(tileSize));
    }

    private List<PackerDiagnostic> glyphBankDeclarationDiagnostics(
            final Path assetRoot,
            final PackerAssetDeclaration declaration,
            final PackerGlyphBankRequirements requirements) {
        final List<PackerDiagnostic> diagnostics = new ArrayList<>();
        final List<PackerAssetArtifactSelection> artifacts = declaration.artifacts();
        final Set<Integer> seenIndices = new java.util.HashSet<>();
        for (int expectedIndex = 0; expectedIndex < artifacts.size(); expectedIndex += 1) {
            final PackerAssetArtifactSelection artifact = artifacts.get(expectedIndex);
            if (!seenIndices.add(artifact.index())) {
                diagnostics.add(new PackerDiagnostic(
                        PackerDiagnosticSeverity.ERROR,
                        PackerDiagnosticCategory.STRUCTURAL,
                        "Tile bank artifacts contain duplicate index " + artifact.index() + ".",
                        assetRoot,
                        true));
            }
            if (artifact.index() != expectedIndex) {
                diagnostics.add(new PackerDiagnostic(
                        PackerDiagnosticSeverity.ERROR,
                        PackerDiagnosticCategory.STRUCTURAL,
                        "Tile bank artifacts must form a contiguous index range starting at 0.",
                        assetRoot,
                        true));
                break;
            }
        }

        final int tileCapacity = (GLYPH_BANK_EMITTED_SHEET_SIZE / requirements.tileSize())
                * (GLYPH_BANK_EMITTED_SHEET_SIZE / requirements.tileSize());
        if (artifacts.size() > tileCapacity) {
            diagnostics.add(new PackerDiagnostic(
                    PackerDiagnosticSeverity.ERROR,
                    PackerDiagnosticCategory.STRUCTURAL,
                    "Tile bank exceeds emitted sheet capacity for tile_size "
                            + requirements.tileSize() + "x" + requirements.tileSize()
                            + ": capacity is " + tileCapacity + " tiles.",
                    assetRoot,
                    true));
        }

        final List<DeclaredPalette> declaredPalettes = declaredPalettes(declaration);
        if (!artifacts.isEmpty() && declaredPalettes.isEmpty()) {
            diagnostics.add(new PackerDiagnostic(
                    PackerDiagnosticSeverity.ERROR,
                    PackerDiagnosticCategory.STRUCTURAL,
                    "Tile bank must declare at least one palette in output.pipeline.palettes.",
                    assetRoot,
                    true));
        }
        if (declaredPalettes.size() > GLYPH_BANK_MAX_PALETTES) {
            diagnostics.add(new PackerDiagnostic(
                    PackerDiagnosticSeverity.ERROR,
                    PackerDiagnosticCategory.STRUCTURAL,
                    "Glyph bank cannot declare more than " + GLYPH_BANK_MAX_PALETTES + " palettes.",
                    assetRoot,
                    true));
        }
        return diagnostics;
    }

    private List<PackerDiagnostic> glyphBankSelectedArtifactDiagnostics(
            final Path assetRoot,
            final PackerAssetDeclaration declaration,
            final PackerWalkResult walkResult) {
        final List<PackerDiagnostic> diagnostics = new ArrayList<>();
        final Set<String> discoveredPaths = walkResult.probeResults().stream()
                .map(result -> assetRoot.toAbsolutePath().normalize()
                        .relativize(result.fileProbe().path().toAbsolutePath().normalize())
                        .toString()
                        .replace('\\', '/'))
                .collect(java.util.stream.Collectors.toSet());
        declaration.artifacts().stream()
                .map(PackerAssetArtifactSelection::file)
                .filter(path -> !discoveredPaths.contains(path))
                .forEach(path -> diagnostics.add(new PackerDiagnostic(
                        PackerDiagnosticSeverity.ERROR,
                        PackerDiagnosticCategory.STRUCTURAL,
                        "Selected tile artifact is missing or unsupported: " + path,
                        assetRoot.resolve(path),
                        true)));
        return diagnostics;
    }

    private List<PackerDiagnostic> glyphBankFragileIndexDiagnostics(
            final PackerAssetDeclaration declaration,
            final PackerWalkResult walkResult) {
        final List<DeclaredPalette> declaredPalettes = declaredPalettes(declaration);
        if (declaredPalettes.isEmpty()) {
            return List.of();
        }

        final List<PackerDiagnostic> diagnostics = new ArrayList<>();
        for (PackerProbeResult probeResult : walkResult.probeResults()) {
            if (probeResult.diagnostics().stream().anyMatch(PackerDiagnostic::blocking)) {
                continue;
            }
            final Object tileValue = probeResult.metadata().get("tile");
            if (!(tileValue instanceof PackerTileIndexedV1 tile)) {
                continue;
            }
            int maxUsedIndex = 0;
            for (byte value : tile.paletteIndices()) {
                maxUsedIndex = Math.max(maxUsedIndex, Byte.toUnsignedInt(value));
            }
            if (maxUsedIndex == 0) {
                continue;
            }
            final int requiredColorCount = maxUsedIndex;
            final Optional<DeclaredPalette> fragilePalette = declaredPalettes.stream()
                    .filter(palette -> palette.colorCount() < requiredColorCount)
                    .findFirst();
            if (fragilePalette.isEmpty()) {
                continue;
            }
            diagnostics.add(new PackerDiagnostic(
                    PackerDiagnosticSeverity.WARNING,
                    PackerDiagnosticCategory.HYGIENE,
                    "Tile uses fragile palette indices up to " + requiredColorCount
                            + ", but declared palette " + fragilePalette.get().index()
                            + " only covers " + fragilePalette.get().colorCount() + " colors.",
                    probeResult.fileProbe().path(),
                    false));
        }
        return diagnostics;
    }

    private List<DeclaredPalette> declaredPalettes(final PackerAssetDeclaration declaration) {
        final JsonNode palettesNode = declaration.outputPipelineMetadata().get("palettes");
        if (!(palettesNode instanceof com.fasterxml.jackson.databind.node.ArrayNode palettesArray)) {
            return List.of();
        }
        final List<DeclaredPalette> palettes = new ArrayList<>();
        for (JsonNode paletteDeclaration : palettesArray) {
            final JsonNode indexNode = paletteDeclaration.path("index");
            final JsonNode paletteNode = paletteDeclaration.path("palette");
            if (!indexNode.isInt() || !paletteNode.isObject()) {
                continue;
            }
            final int originalCount = paletteNode.path("originalArgb8888").isArray()
                    ? paletteNode.path("originalArgb8888").size()
                    : 0;
            final int convertedCount = paletteNode.path("convertedRgb565").isArray()
                    ? paletteNode.path("convertedRgb565").size()
                    : 0;
            palettes.add(new DeclaredPalette(indexNode.intValue(), Math.min(originalCount, convertedCount)));
        }
        palettes.sort(java.util.Comparator.comparingInt(DeclaredPalette::index));
        return List.copyOf(palettes);
    }

    private RequirementBuildResult<PackerSoundBankRequirements> buildSoundBankRequirements(
            final PackerAssetDeclaration declaration,
            final Map<String, String> metadata) {
        if (!SOUND_BANK_SUPPORTED_FORMATS.contains(declaration.outputFormat())) {
            return RequirementBuildResult.fail("Unsupported output format for sound bank: " + declaration.outputFormat());
        }
        final var sampleRateStr = metadata.get("sample_rate");
        if (StringUtils.isBlank(sampleRateStr)) {
            return RequirementBuildResult.fail("Missing sample rate for sound bank");
        }
        final var sampleRate = Integer.parseInt(sampleRateStr);
        final var channelsStr = metadata.get("channels");
        if (StringUtils.isBlank(channelsStr)) {
            return RequirementBuildResult.fail("Missing channels for sound bank");
        }
        final var channels = Integer.parseInt(channelsStr);
        return RequirementBuildResult.success(new PackerSoundBankRequirements(sampleRate, channels));
    }

    private record RequirementBuildResult<T>(
            T requirements,
            String errorMessage) {
        static <T> RequirementBuildResult<T> success(T requirements) {
            return new RequirementBuildResult<>(requirements, null);
        }

        static <T> RequirementBuildResult<T> fail(String errorMessage) {
            return new RequirementBuildResult<>(null, errorMessage);
        }

        public boolean hasError() {
            return StringUtils.isNotBlank(errorMessage);
        }
    }

    private record DeclaredPalette(
            int index,
            int colorCount) {
    }
}
