import 'dart:convert';
import 'dart:typed_data';

import 'package:crypto/crypto.dart' show md5;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';

/// The primary parser for Wolfenstein 3D data formats.
///
/// This abstract class serves as the extraction and decompression engine for
/// legacy id Software game assets. It processes raw byte streams into usable
/// objects (walls, sprites, audio, levels, and UI elements) using original
/// algorithms like Carmackization, RLEW, and Huffman expansion.
abstract class WLParser {
  static const int _areaTileBase = 107;

  /// Asynchronously discovers the game version and loads all necessary files.
  ///
  /// Provide a [fileFetcher] callback (e.g., Flutter's `rootBundle.load` or
  /// standard `File.readAsBytes`) that takes a filename string and returns
  /// its [ByteData]. The parser will probe for the `VSWAP` file to determine
  /// the game version and then fetch the corresponding data files.
  static Future<WolfensteinData> loadAsync(
    Future<ByteData> Function(String filename) fileFetcher,
  ) async {
    GameVersion? detectedVersion;
    ByteData? vswap;

    // 1. Probe the data source for VSWAP to determine the GameVersion
    for (final version in GameVersion.values) {
      try {
        vswap = await fileFetcher('VSWAP.${version.fileExtension}');
        detectedVersion = version;
        break;
      } catch (_) {}
    }

    if (detectedVersion == null || vswap == null) {
      throw Exception('Could not locate a valid VSWAP file.');
    }

    final ext = detectedVersion.fileExtension;

    // 2. Determine DataIdentity (Checksum) immediately
    final vswapBytes = vswap.buffer.asUint8List(
      vswap.offsetInBytes,
      vswap.lengthInBytes,
    );
    final vswapHash = md5.convert(vswapBytes).toString();
    final dataIdentity = DataVersion.fromChecksum(vswapHash);

    // 3. Load other required files
    // Special Case: v1.0 Retail uses MAPTEMP instead of GAMEMAPS
    ByteData gameMapsData;
    if (dataIdentity == DataVersion.version10Retail) {
      try {
        gameMapsData = await fileFetcher('MAPTEMP.$ext');
      } catch (_) {
        // Fallback in case v1.0 files were renamed to standard convention
        gameMapsData = await fileFetcher('GAMEMAPS.$ext');
      }
    } else {
      gameMapsData = await fileFetcher('GAMEMAPS.$ext');
    }

    final rawFiles = {
      'MAPHEAD.$ext': await fileFetcher('MAPHEAD.$ext'),
      'VGADICT.$ext': await fileFetcher('VGADICT.$ext'),
      'VGAHEAD.$ext': await fileFetcher('VGAHEAD.$ext'),
      'VGAGRAPH.$ext': await fileFetcher('VGAGRAPH.$ext'),
      'AUDIOHED.$ext': await fileFetcher('AUDIOHED.$ext'),
      'AUDIOT.$ext': await fileFetcher('AUDIOT.$ext'),
    };

    // 4. Final call to load with the required dataIdentity
    return load(
      version: detectedVersion,
      dataIdentity: dataIdentity, // Now correctly passed
      vswap: vswap,
      mapHead: rawFiles['MAPHEAD.$ext']!,
      gameMaps: gameMapsData,
      vgaDict: rawFiles['VGADICT.$ext']!,
      vgaHead: rawFiles['VGAHEAD.$ext']!,
      vgaGraph: rawFiles['VGAGRAPH.$ext']!,
      audioHed: rawFiles['AUDIOHED.$ext']!,
      audioT: rawFiles['AUDIOT.$ext']!,
    );
  }

  /// Async file-by-file equivalent of [loadAsync]; exposed for use from
  /// external callers that need a [registryOverride] in async contexts.
  static Future<WolfensteinData> loadAsyncWithOverride(
    Future<ByteData> Function(String filename) fileFetcher, {
    AssetRegistry? registryOverride,
  }) async {
    GameVersion? detectedVersion;
    ByteData? vswap;

    for (final version in GameVersion.values) {
      try {
        vswap = await fileFetcher('VSWAP.${version.fileExtension}');
        detectedVersion = version;
        break;
      } catch (_) {}
    }

    if (detectedVersion == null || vswap == null) {
      throw Exception('Could not locate a valid VSWAP file.');
    }

    final ext = detectedVersion.fileExtension;
    final vswapBytes = vswap.buffer.asUint8List(
      vswap.offsetInBytes,
      vswap.lengthInBytes,
    );
    final vswapHash = md5.convert(vswapBytes).toString();
    final dataIdentity = DataVersion.fromChecksum(vswapHash);

    ByteData gameMapsData;
    if (dataIdentity == DataVersion.version10Retail) {
      try {
        gameMapsData = await fileFetcher('MAPTEMP.$ext');
      } catch (_) {
        gameMapsData = await fileFetcher('GAMEMAPS.$ext');
      }
    } else {
      gameMapsData = await fileFetcher('GAMEMAPS.$ext');
    }

    final rawFiles = {
      'MAPHEAD.$ext': await fileFetcher('MAPHEAD.$ext'),
      'VGADICT.$ext': await fileFetcher('VGADICT.$ext'),
      'VGAHEAD.$ext': await fileFetcher('VGAHEAD.$ext'),
      'VGAGRAPH.$ext': await fileFetcher('VGAGRAPH.$ext'),
      'AUDIOHED.$ext': await fileFetcher('AUDIOHED.$ext'),
      'AUDIOT.$ext': await fileFetcher('AUDIOT.$ext'),
    };

    return load(
      version: detectedVersion,
      dataIdentity: dataIdentity,
      vswap: vswap,
      mapHead: rawFiles['MAPHEAD.$ext']!,
      gameMaps: gameMapsData,
      vgaDict: rawFiles['VGADICT.$ext']!,
      vgaHead: rawFiles['VGAHEAD.$ext']!,
      vgaGraph: rawFiles['VGAGRAPH.$ext']!,
      audioHed: rawFiles['AUDIOHED.$ext']!,
      audioT: rawFiles['AUDIOT.$ext']!,
      registryOverride: registryOverride,
    );
  }

  /// Parses all raw ByteData upfront and returns a fully populated [WolfensteinData] object.
  ///
  /// By using named parameters, the compiler guarantees no files are missing or misnamed.
  /// The [dataIdentity] parameter is used to apply version-specific bug fixes or
  /// legacy parsing adjustments (e.g., HUD string differences in v1.0).
  static WolfensteinData load({
    required GameVersion version,
    required ByteData vswap,
    required ByteData mapHead,
    required ByteData gameMaps,
    required ByteData vgaDict,
    required ByteData vgaHead,
    required ByteData vgaGraph,
    required ByteData audioHed,
    required ByteData audioT,
    required DataVersion dataIdentity,
    AssetRegistry? registryOverride,
  }) {
    final audio = parseAudio(audioHed, audioT, version);
    final vgaImages = parseVgaImages(vgaDict, vgaHead, vgaGraph);

    // Resolve the appropriate registry for this data identity, unless the
    // caller has supplied an explicit override (e.g. a modded asset pack).
    final registry =
        registryOverride ??
        _resolveRegistry(
          version: version,
          dataVersion: dataIdentity,
          vgaImages: vgaImages,
        );

    return WolfensteinData(
      version: version,
      dataVersion: dataIdentity,
      registry: registry,
      walls: parseWalls(vswap),
      sprites: parseSprites(vswap),
      sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(),
      episodes: parseEpisodes(mapHead, gameMaps, version: version),
      vgaImages: vgaImages,
      adLibSounds: audio.adLib,
      music: audio.music,
    );
  }

  /// Selects the registry for [version]/[dataVersion] and, for shareware,
  /// initialises the menu module's runtime image-offset heuristic.
  static AssetRegistry _resolveRegistry({
    required GameVersion version,
    required DataVersion dataVersion,
    required List<VgaImage> vgaImages,
  }) {
    final context = RegistrySelectionContext(
      gameVersion: version,
      dataVersion: dataVersion,
    );
    final registry = const BuiltInAssetRegistryResolver().resolve(context);

    // Initialise the shareware menu heuristic now that images are available.
    if (registry is SharewareAssetRegistry) {
      final sizes = vgaImages
          .map((img) => (width: img.width, height: img.height))
          .toList();
      registry.sharewareMenu.initWithImageSizes(sizes);
      registry.sharewareHud.initWithImageSizes(sizes);
    }

    return registry;
  }

  /// Extracts the 64x64 wall textures from the VSWAP file.
  ///
  /// Wall textures are stored sequentially at the beginning of the chunks array.
  static List<Sprite> parseWalls(ByteData vswap) {
    final header = _VswapHeader(vswap);

    return header.offsets
        .take(header.spriteStart)
        .where((offset) => offset != 0) // Skip empty chunks
        .map((offset) => _parseWallChunk(vswap, offset))
        .toList();
  }

  /// Extracts the compiled scaled sprites from the VSWAP file.
  ///
  /// Sprites use a column-based "sparse" format to allow for transparency
  /// and efficient vertical scaling in the raycaster.
  static List<Sprite> parseSprites(ByteData vswap) {
    final header = _VswapHeader(vswap);
    final sprites = <Sprite>[];

    // Sprites are located between the walls and the sounds
    for (int i = header.spriteStart; i < header.soundStart; i++) {
      int offset = header.offsets[i];
      if (offset != 0) {
        sprites.add(_parseSingleSprite(vswap, offset));
      }
    }

    return sprites;
  }

  /// Extracts digitized sound effects (PCM Audio) from the VSWAP file.
  ///
  /// Sounds are stored as raw 8-bit PCM audio bytes.
  static List<Uint8List> parseSounds(ByteData vswap) {
    final header = _VswapHeader(vswap);
    final lengthStart = 6 + (header.chunks * 4);
    final sounds = <Uint8List>[];

    // Sounds start after the sprites and go to the end of the chunks
    for (int i = header.soundStart; i < header.chunks; i++) {
      int offset = header.offsets[i];
      int length = vswap.getUint16(lengthStart + (i * 2), Endian.little);

      if (offset == 0 || length == 0) {
        sounds.add(Uint8List(0)); // Empty placeholder
      } else {
        // Extract the raw 8-bit PCM audio bytes
        sounds.add(vswap.buffer.asUint8List(offset, length));
      }
    }

    return sounds;
  }

  // --- Private Helpers ---

  static Sprite _parseWallChunk(ByteData vswap, int offset) {
    // --- SAFETY CHECK ---
    // Prevent crashes from corrupted VSWAP offsets or dummy EOF chunks
    if (offset + 4096 > vswap.lengthInBytes) {
      // Return a blank transparent sprite if the data is invalid
      return Sprite(Uint8List(4096)..fillRange(0, 4096, 255));
    }

    // --- OPTIMIZATION ---
    // Instead of looping 4,096 times, we slice the exact memory block instantly.
    // Because Wolf3D walls are natively stored in column-major order on disk,
    // they perfectly match our 1D Sprite array layout without any math!
    final rawView = vswap.buffer.asUint8List(
      vswap.offsetInBytes + offset,
      4096,
    );

    // Create a new Uint8List copy so we don't hold the entire file in memory
    final pixels = Uint8List.fromList(rawView);

    return Sprite(pixels);
  }

  static Sprite _parseSingleSprite(ByteData vswap, int offset) {
    // Initialize the 1D array with 255 (Transparency)
    final pixels = Uint8List(64 * 64)..fillRange(0, 4096, 255);
    Sprite sprite = Sprite(pixels);

    int leftPix = vswap.getUint16(offset, Endian.little);
    int rightPix = vswap.getUint16(offset + 2, Endian.little);

    // --- SAFETY CHECK ---
    // If the bounds are outside 0-63, this is a dummy or corrupted chunk.
    if (leftPix < 0 ||
        leftPix > 63 ||
        rightPix < 0 ||
        rightPix > 63 ||
        leftPix > rightPix) {
      return sprite;
    }

    for (int x = leftPix; x <= rightPix; x++) {
      int colOffset = vswap.getUint16(
        offset + 4 + ((x - leftPix) * 2),
        Endian.little,
      );
      if (colOffset != 0) {
        _parseSpriteColumn(vswap, sprite, x, offset, offset + colOffset);
      }
    }
    return sprite;
  }

  static void _parseSpriteColumn(
    ByteData vswap,
    Sprite sprite,
    int x,
    int baseOffset,
    int cmdOffset,
  ) {
    while (true) {
      int endY = vswap.getUint16(cmdOffset, Endian.little);
      if (endY == 0) break;
      endY ~/= 2;

      int pixelOfs = vswap.getInt16(cmdOffset + 2, Endian.little);

      int startY = vswap.getUint16(cmdOffset + 4, Endian.little);
      startY ~/= 2;

      // Keep the safety clamps for retail version
      int safeStartY = startY.clamp(0, 63);
      int safeEndY = endY.clamp(0, 64);

      for (int y = safeStartY; y < safeEndY; y++) {
        sprite.pixels[x * 64 + y] = vswap.getUint8(baseOffset + pixelOfs + y);
      }

      cmdOffset += 6;
    }
  }

  /// Extracts and decodes the UI graphics, Title Screens, and Menu items.
  ///
  /// The images are built using a dictionary (`VGADICT`), metadata (`VGAHEAD`),
  /// and Huffman-compressed image chunks (`VGAGRAPH`).
  static List<VgaImage> parseVgaImages(
    ByteData vgaDict,
    ByteData vgaHead,
    ByteData vgaGraph,
  ) {
    // 1. Get all raw decompressed chunks using the Huffman algorithm
    List<Uint8List> rawChunks = _parseVgaRaw(vgaDict, vgaHead, vgaGraph);

    // 2. Chunk 0 is the Picture Table (Dimensions for all images)
    // It contains consecutive 16-bit widths and 16-bit heights
    ByteData picTable = ByteData.sublistView(rawChunks[0]);
    int numPics = picTable.lengthInBytes ~/ 4;

    List<VgaImage> images = [];

    // 3. In Wolf3D, Chunk 1 and 2 are fonts. Pictures start at Chunk 3!
    int picStartIndex = 3;

    for (int i = 0; i < numPics; i++) {
      int width = picTable.getUint16(i * 4, Endian.little);
      int height = picTable.getUint16(i * 4 + 2, Endian.little);

      // Safety check: ensure we don't read out of bounds
      if (picStartIndex + i < rawChunks.length) {
        images.add(
          VgaImage(
            width: width,
            height: height,
            pixels: rawChunks[picStartIndex + i],
          ),
        );
      }
    }

    return images;
  }

  // --- Episode Names (From the original C Executable) ---
  static const List<String> _sharewareEpisodeNames = [
    "Episode 1\nEscape from Wolfenstein",
  ];

  static const List<String> _retailEpisodeNames = [
    "Episode 1\nEscape from Wolfenstein",
    "Episode 2\nOperation: Eisenfaust",
    "Episode 3\nDie, Fuhrer, Die!",
    "Episode 4\nA Dark Secret",
    "Episode 5\nTrail of the Madman",
    "Episode 6\nConfrontation",
  ];

  /// Parses `MAPHEAD` and `GAMEMAPS` to extract the raw level data.
  ///
  /// Level files undergo two layers of decompression (Carmack + RLEW). The map
  /// data is then translated into 64x64 grids representing the walls and objects.
  static List<Episode> parseEpisodes(
    ByteData mapHead,
    ByteData gameMaps, {
    required GameVersion version,
  }) {
    List<WolfLevel> allLevels = [];
    int rlewTag = mapHead.getUint16(0, Endian.little);

    final isShareware = version == GameVersion.shareware;
    final episodeNames = isShareware
        ? _sharewareEpisodeNames
        : _retailEpisodeNames;

    // The game allows for up to 100 maps per file
    for (int i = 0; i < 100; i++) {
      int mapOffset = mapHead.getUint32(2 + (i * 4), Endian.little);
      if (mapOffset == 0) continue; // Empty map slot

      int plane0Offset = gameMaps.getUint32(mapOffset + 0, Endian.little);
      int plane1Offset = gameMaps.getUint32(mapOffset + 4, Endian.little);

      int plane0Length = gameMaps.getUint16(mapOffset + 12, Endian.little);
      int plane1Length = gameMaps.getUint16(mapOffset + 14, Endian.little);

      // --- EXTRACT ACTUAL GAME DATA NAME ---
      // The name is exactly 16 bytes long, starting at offset 22
      List<int> nameBytes = [];
      for (int n = 0; n < 16; n++) {
        int charCode = gameMaps.getUint8(mapOffset + 22 + n);
        if (charCode == 0) break; // Stop at the null-terminator
        nameBytes.add(charCode);
      }
      String parsedName = ascii.decode(nameBytes);

      // --- DECOMPRESS PLANES ---
      final compressedWallData = gameMaps.buffer.asUint8List(
        plane0Offset,
        plane0Length,
      );
      Uint16List carmackExpandedWalls = _expandCarmack(compressedWallData);
      List<int> flatWallGrid = _expandRlew(carmackExpandedWalls, rlewTag);

      final compressedObjectData = gameMaps.buffer.asUint8List(
        plane1Offset,
        plane1Length,
      );
      Uint16List carmackExpandedObjects = _expandCarmack(compressedObjectData);
      List<int> flatObjectGrid = _expandRlew(carmackExpandedObjects, rlewTag);

      // --- BUILD 64x64 GRIDS ---
      List<List<int>> wallGrid = [];
      List<List<int>> objectGrid = [];
      List<List<int>> areaGrid = [];

      for (int y = 0; y < 64; y++) {
        List<int> wallRow = [];
        List<int> objectRow = [];
        List<int> areaRow = [];
        for (int x = 0; x < 64; x++) {
          final rawWallId = flatWallGrid[y * 64 + x];
          wallRow.add(rawWallId);
          objectRow.add(flatObjectGrid[y * 64 + x]);
          if (rawWallId >= _areaTileBase) {
            areaRow.add(rawWallId - _areaTileBase);
          } else {
            areaRow.add(-1);
          }
        }
        wallGrid.add(wallRow);
        objectGrid.add(objectRow);
        areaGrid.add(areaRow);
      }

      // --- ASSIGN MUSIC ---
      final episodeIndex = i ~/ 10;
      final levelIndex = i % 10;
      final levelMusic = Music.levelFor(version, episodeIndex, levelIndex);

      allLevels.add(
        WolfLevel(
          name: parsedName,
          wallGrid: wallGrid,
          objectGrid: objectGrid,
          areaGrid: areaGrid,
          music: levelMusic,
        ),
      );
    }

    // 2. Group the parsed levels into Episodes!
    List<Episode> episodes = [];

    // Calculate how many episodes we need (10 levels per episode)
    int totalEpisodes = (allLevels.length / 10).ceil();

    for (int i = 0; i < totalEpisodes; i++) {
      int startIndex = i * 10;
      int endIndex = startIndex + 10;

      // Safety clamp for incomplete episodes at the end of the file
      if (endIndex > allLevels.length) {
        endIndex = allLevels.length;
      }

      // If we run out of hardcoded id Software names, generate a custom one!
      String epName = (i < episodeNames.length)
          ? episodeNames[i]
          : "Episode ${i + 1}\nCustom Maps";

      episodes.add(
        Episode(
          name: epName,
          levels: allLevels.sublist(startIndex, endIndex),
        ),
      );
    }

    return episodes;
  }

  /// Extracts AdLib sounds and IMF music tracks from the audio files.
  ///
  /// Splits the sequential chunks in `AUDIOT` based on the offsets provided
  /// in `AUDIOHED`. Converts raw music bytes into structured [ImfMusic] objects.
  static ({List<PcmSound> adLib, List<ImfMusic> music}) parseAudio(
    ByteData audioHed,
    ByteData audioT,
    GameVersion version,
  ) {
    List<int> offsets = [];
    for (int i = 0; i < audioHed.lengthInBytes ~/ 4; i++) {
      offsets.add(audioHed.getUint32(i * 4, Endian.little));
    }

    List<Uint8List> allAudioChunks = [];

    for (int i = 0; i < offsets.length - 1; i++) {
      int start = offsets[i];
      int next = offsets[i + 1];

      if (start == 0xFFFFFFFF || start >= audioT.lengthInBytes) {
        allAudioChunks.add(Uint8List(0));
        continue;
      }

      int length = next - start;
      if (length <= 0) {
        allAudioChunks.add(Uint8List(0));
      } else {
        allAudioChunks.add(
          audioT.buffer.asUint8List(audioT.offsetInBytes + start, length),
        );
      }
    }

    // In Wolf3D v1.4 (Shareware and Retail), Music ALWAYS starts at chunk 261.
    // Chunks 0-86: PC Sounds
    // Chunks 87-173: AdLib Sounds
    // Chunks 174-260: Digitized Sounds
    int musicStartIndex = 261;

    List<PcmSound> adLib = allAudioChunks
        .take(musicStartIndex)
        .map((bytes) => PcmSound(bytes))
        .toList();

    List<ImfMusic> music = allAudioChunks
        .skip(musicStartIndex)
        .where((chunk) => chunk.isNotEmpty)
        .map((bytes) => ImfMusic.fromBytes(bytes))
        .toList();

    return (adLib: adLib, music: music);
  }

  // --- ALGORITHM 1: CARMACK EXPANSION ---
  /// Expands data using the "Carmackization" near/far pointer compression.
  ///
  /// This is the first layer of decompression used for level data in GAMEMAPS.
  /// It works similarly to LZSS, matching near (`0xA7`) and far (`0xA8`) pointers
  /// to replay previously decompressed data words.
  static Uint16List _expandCarmack(Uint8List compressed) {
    ByteData data = ByteData.sublistView(compressed);
    int expandedLengthWords = data.getUint16(0, Endian.little) ~/ 2;
    Uint16List expanded = Uint16List(expandedLengthWords);

    int inIdx = 2; // Word-based index
    int outIdx = 0;

    while (outIdx < expandedLengthWords) {
      int word = data.getUint16(inIdx, Endian.little);
      inIdx += 2;

      int highByte = word >> 8;
      int lowByte = word & 0xFF;

      // Carmack pointers: 0xA7 (Near) and 0xA8 (Far)
      if (highByte == 0xA7 || highByte == 0xA8) {
        if (lowByte == 0) {
          // Edge case: if low byte is 0, it's a literal tag byte
          int nextByte = data.getUint8(inIdx++);
          expanded[outIdx++] = (nextByte << 8) | highByte;
        } else if (highByte == 0xA7) {
          // Near pointer: Copy [lowByte] words from [offset] back in the output
          int offset = data.getUint8(inIdx++);
          int copyFrom = outIdx - offset;
          for (int i = 0; i < lowByte; i++) {
            expanded[outIdx++] = expanded[copyFrom++];
          }
        } else {
          // Far pointer: Copy [lowByte] words from absolute [offset] in the output
          int offset = data.getUint16(inIdx, Endian.little);
          inIdx += 2;
          for (int i = 0; i < lowByte; i++) {
            expanded[outIdx++] = expanded[offset++];
          }
        }
      } else {
        expanded[outIdx++] = word;
      }
    }
    return expanded;
  }

  // --- ALGORITHM 2: RLEW EXPANSION ---
  /// Expands data using Run-Length Encoding on Word boundaries.
  ///
  /// This is the second layer of decompression for level maps. It identifies a
  /// special marker ([rlewTag]) and expands the subsequent count and value.
  static List<int> _expandRlew(Uint16List carmackExpanded, int rlewTag) {
    // The first word is the expanded length in BYTES
    int expandedLengthBytes = carmackExpanded[0];
    int expandedLengthWords = expandedLengthBytes ~/ 2;
    List<int> rlewExpanded = List<int>.filled(expandedLengthWords, 0);

    int inIdx = 1; // Skip the length word
    int outIdx = 0;

    while (outIdx < expandedLengthWords && inIdx < carmackExpanded.length) {
      int word = carmackExpanded[inIdx++];

      if (word == rlewTag) {
        // We found an RLEW tag!
        // The next word is the count, the word after that is the value.
        int count = carmackExpanded[inIdx++];
        int value = carmackExpanded[inIdx++];
        for (int i = 0; i < count; i++) {
          rlewExpanded[outIdx++] = value;
        }
      } else {
        // Normal word
        rlewExpanded[outIdx++] = word;
      }
    }
    return rlewExpanded;
  }

  /// Extracts decompressed VGA data chunks (UI, Fonts, Pictures)
  ///
  /// Safely manages offsets and handles out-of-bounds safety checks when
  /// reading from the `VGAGRAPH` blob.
  static List<Uint8List> _parseVgaRaw(
    ByteData vgaDict,
    ByteData vgaHead,
    ByteData vgaGraph,
  ) {
    List<Uint8List> vgaChunks = [];

    // 1. Parse the Huffman Dictionary from VGADICT
    List<_HuffmanNode> dict = [];
    for (int i = 0; i < 255; i++) {
      dict.add(
        _HuffmanNode(
          vgaDict.getUint16(i * 4, Endian.little),
          vgaDict.getUint16(i * 4 + 2, Endian.little),
        ),
      );
    }

    // 2. Read VGAHEAD to get the offsets for VGAGRAPH
    List<int> offsets = [];
    int numChunks = vgaHead.lengthInBytes ~/ 3;
    int lastOffset = -1;

    for (int i = 0; i < numChunks; i++) {
      int offset =
          vgaHead.getUint8(i * 3) |
          (vgaHead.getUint8(i * 3 + 1) << 8) |
          (vgaHead.getUint8(i * 3 + 2) << 16);

      if (offset == 0x00FFFFFF) break;

      // --- SAFETY FIX ---
      // 1. Offsets cannot point beyond the file length.
      // 2. Offsets must not go backward (this means we hit the junk padding).
      if (offset > vgaGraph.lengthInBytes || offset < lastOffset) {
        break;
      }

      offsets.add(offset);
      lastOffset = offset;
    }

    // 3. Decompress the chunks from VGAGRAPH
    for (int i = 0; i < offsets.length; i++) {
      int offset = offsets[i];

      // --- BOUNDARY CHECK ---
      // If the offset is exactly at the end of the file, or doesn't leave
      // enough room for a 4-byte length header, it's an empty chunk.
      if (offset + 4 > vgaGraph.lengthInBytes) {
        vgaChunks.add(Uint8List(0));
        continue;
      }

      // The first 4 bytes of a compressed chunk specify its decompressed size
      int expandedLength = vgaGraph.getUint32(offset, Endian.little);

      if (expandedLength == 0) {
        vgaChunks.add(Uint8List(0));
        continue;
      }

      // Decompress starting immediately after the 4-byte length header.
      // We pass the exact slice of bytes remaining so we don't read out of bounds.
      Uint8List expandedData = _expandHuffman(
        vgaGraph.buffer.asUint8List(
          vgaGraph.offsetInBytes + offset + 4,
          vgaGraph.lengthInBytes - (offset + 4),
        ),
        dict,
        expandedLength,
      );

      vgaChunks.add(expandedData);
    }

    return vgaChunks;
  }

  // --- ALGORITHM 3: HUFFMAN EXPANSION ---
  /// Decompresses data using id Software's Huffman algorithm.
  ///
  /// Traverses a binary tree derived from `VGADICT`. Bits from the [compressed]
  /// data stream dictate left/right traversal until a leaf node is found, at which
  /// point the data byte is appended to the output buffer.
  static Uint8List _expandHuffman(
    Uint8List compressed,
    List<_HuffmanNode> dict,
    int expandedLength,
  ) {
    Uint8List expanded = Uint8List(expandedLength);

    int outIdx = 0;
    int byteIdx = 0;
    int bitMask = 1;
    int currentNode = 254; // Root node is always 254 in id's implementation

    while (outIdx < expandedLength && byteIdx < compressed.length) {
      // Extract bits from Least Significant Bit (LSB) to Most (MSB)
      int bit = (compressed[byteIdx] & bitMask) == 0 ? 0 : 1;

      // Move to next bit/byte
      bitMask <<= 1;
      if (bitMask > 128) {
        bitMask = 1;
        byteIdx++;
      }

      // If bit is 0, take the left path; if 1, take the right path
      int nextVal = bit == 0 ? dict[currentNode].bit0 : dict[currentNode].bit1;

      if (nextVal < 256) {
        // Found a leaf node: write the byte and reset to root
        expanded[outIdx++] = nextVal;
        currentNode = 254;
      } else {
        // Internal node: pointer to the next branch (offset by 256)
        currentNode = nextVal - 256;
      }
    }
    return expanded;
  }
}

/// Helper class to parse and store redundant VSWAP header data
class _VswapHeader {
  final int chunks;
  final int spriteStart;
  final int soundStart;
  final List<int> offsets;

  _VswapHeader(ByteData vswap)
    : chunks = vswap.getUint16(0, Endian.little),
      spriteStart = vswap.getUint16(2, Endian.little),
      soundStart = vswap.getUint16(4, Endian.little),
      offsets = List.generate(
        vswap.getUint16(0, Endian.little), // total chunks
        (i) => vswap.getUint32(6 + (i * 4), Endian.little),
      );
}

class _HuffmanNode {
  final int bit0;
  final int bit1;

  _HuffmanNode(this.bit0, this.bit1);
}
