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

import 'package:crypto/crypto.dart' show md5;
import 'package:wolf_3d_data/src/data_version.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';

abstract class WLParser {
  // --- Original Song Lookup Tables ---
  static const List<int> _sharewareMusicMap = [
    2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Episode 1
  ];

  static const List<int> _retailMusicMap = [
    2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Ep 1
    8, 9, 10, 11, 8, 9, 11, 10, 6, 12, // Ep 2
    13, 14, 15, 16, 13, 14, 15, 16, 17, 18, // Ep 3
    2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Ep 4
    8, 9, 10, 11, 8, 9, 11, 10, 6, 12, // Ep 5
    13, 14, 15, 16, 13, 14, 15, 16, 17, 19, // Ep 6
  ];

  /// Asynchronously discovers the game version and loads all necessary files.
  /// Provide a [fileFetcher] callback (e.g., Flutter's rootBundle.load) that
  /// takes a filename and returns its ByteData.
  /// Asynchronously discovers the game version and loads all necessary files.
  /// Asynchronously discovers the game version and loads all necessary 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']!,
    );
  }

  /// 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.
  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,
  }) {
    final isShareware = version == GameVersion.shareware;

    // v1.0/1.1 used different HUD strings and had different secret wall bugs
    final isLegacy =
        dataIdentity == DataVersion.version10Retail ||
        dataIdentity == DataVersion.version11Retail;

    final audio = parseAudio(audioHed, audioT, version);

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

  /// Extracts the 64x64 wall textures from VSWAP.WL1
  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 VSWAP.WL1
  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 VSWAP.WL1
  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) {
    final pixels = Uint8List(64 * 64);
    for (int x = 0; x < 64; x++) {
      for (int y = 0; y < 64; y++) {
        // Flat 1D index: x * 64 + y
        pixels[x * 64 + y] = vswap.getUint8(offset + (x * 64) + y);
      }
    }
    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);

    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.getUint16(cmdOffset + 2, Endian.little);
      int startY = vswap.getUint16(cmdOffset + 4, Endian.little);
      startY ~/= 2;

      for (int y = startY; y < endY; y++) {
        // Write directly to the 1D array
        sprite.pixels[x * 64 + y] = vswap.getUint8(baseOffset + pixelOfs + y);
      }

      cmdOffset += 6;
    }
  }

  /// Extracts and decodes the UI graphics, Title Screens, and Menu items
  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.
  static List<Episode> parseEpisodes(
    ByteData mapHead,
    ByteData gameMaps, {
    bool isShareware = true,
  }) {
    List<WolfLevel> allLevels = [];
    int rlewTag = mapHead.getUint16(0, Endian.little);

    // Select the correct music map based on the version
    final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap;
    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 = [];

      for (int y = 0; y < 64; y++) {
        List<int> wallRow = [];
        List<int> objectRow = [];
        for (int x = 0; x < 64; x++) {
          wallRow.add(flatWallGrid[y * 64 + x]);
          objectRow.add(flatObjectGrid[y * 64 + x]);
        }
        wallGrid.add(wallRow);
        objectGrid.add(objectRow);
      }

      // --- ASSIGN MUSIC ---
      int trackIndex = (i < activeMusicMap.length)
          ? activeMusicMap[i]
          : activeMusicMap[i % activeMusicMap.length];

      allLevels.add(
        WolfLevel(
          name: parsedName,
          wallGrid: wallGrid,
          objectGrid: objectGrid,
          musicIndex: trackIndex,
        ),
      );
    }

    // 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.
  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 ---
  static Uint16List _expandCarmack(Uint8List compressed) {
    ByteData data = ByteData.sublistView(compressed);

    // The first 16-bit word is the total length of the expanded data in BYTES.
    int expandedLengthBytes = data.getUint16(0, Endian.little);
    int expandedLengthWords = expandedLengthBytes ~/ 2;
    Uint16List expanded = Uint16List(expandedLengthWords);

    int inIdx = 2; // Skip the length word we just read
    int outIdx = 0;

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

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

      // 0xA7 and 0xA8 are the Carmack Pointer Tags
      if (highByte == 0xA7 || highByte == 0xA8) {
        if (lowByte == 0) {
          // Exception Rule: If the length (lowByte) is 0, it's not a pointer.
          // It's literally just the tag byte followed by another byte.
          int nextByte = data.getUint8(inIdx++);
          expanded[outIdx++] = (nextByte << 8) | highByte;
        } else if (highByte == 0xA7) {
          // 0xA7 = Near Pointer (look back a few spaces)
          int offset = data.getUint8(inIdx++);
          int copyFrom = outIdx - offset;
          for (int i = 0; i < lowByte; i++) {
            expanded[outIdx++] = expanded[copyFrom++];
          }
        } else if (highByte == 0xA8) {
          // 0xA8 = Far Pointer (absolute offset from the very beginning)
          int offset = data.getUint16(inIdx, Endian.little);
          inIdx += 2;
          for (int i = 0; i < lowByte; i++) {
            expanded[outIdx++] = expanded[offset++];
          }
        }
      } else {
        // Normal, uncompressed word
        expanded[outIdx++] = word;
      }
    }
    return expanded;
  }

  // --- ALGORITHM 2: RLEW EXPANSION ---
  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)
  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 ---
  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; // id Software's Huffman root is always node 254

    while (outIdx < expandedLength && byteIdx < compressed.length) {
      // Read the current bit (LSB to MSB)
      int bit = (compressed[byteIdx] & bitMask) == 0 ? 0 : 1;

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

      // Traverse the tree
      int nextVal = bit == 0 ? dict[currentNode].bit0 : dict[currentNode].bit1;

      if (nextVal < 256) {
        // If the value is < 256, we've hit a leaf node (an actual character byte!)
        expanded[outIdx++] = nextVal;
        currentNode =
            254; // Reset to the root of the tree for the next character
      } else {
        // If the value is >= 256, it's a pointer to the next internal node.
        // Node indexes are 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);
}
