package com.riiablo.codec;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.files.FileHandle;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.glutils.PixmapTextureData;
import com.badlogic.gdx.utils.GdxRuntimeException;
import com.badlogic.gdx.utils.StreamUtils;
import com.riiablo.codec.util.BBox;
import com.riiablo.codec.util.BitStream;
import com.riiablo.graphics.PaletteIndexedPixmap;
import com.riiablo.util.BufferUtils;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.builder.ToStringBuilder;

import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;

public class DCC extends com.riiablo.codec.DC {
  private static final String TAG = "DCC";
  private static final boolean DEBUG            = !true;
  private static final boolean DEBUG_DIRECTIONS = DEBUG && true;
  private static final boolean DEBUG_FRAMES     = DEBUG && true;
  private static final boolean DEBUG_SHEETS     = DEBUG && false;
  private static final boolean DEBUG_PB_SIZE    = DEBUG && true;

  public static final String EXT = "dcc";

  Header    header;
  Direction directions[];
  Frame     frames[][];
  BBox      box;

  Texture   textures[][];

  private DCC(Header header, Direction[] directions, Frame[][] frames, BBox box) {
    this.header     = header;
    this.directions = directions;
    this.frames     = frames;
    this.box        = box;
    this.regions    = new TextureRegion[header.directions][];
  }

  @Override
  public void dispose() {
    disposeFrames();
    disposeTextures();
  }

  private void disposeFrames() {
    final int numDirections = header.directions;
    for (int d = 0; d < numDirections; d++) {
      Frame[] frames = this.frames[d];
      if (frames != null) for (Frame frame : frames) {
        if (frame.pixmap == null) continue;
        frame.pixmap.dispose();
        frame.pixmap = null;
      }
    }
  }

  private void disposeTextures() {
    if (textures == null) return;
    final int numDirections = header.directions;
    for (int d = 0; d < numDirections; d++) {
      Texture[] frames = textures[d];
      if (frames != null) for (Texture frame : frames) frame.dispose();
    }

    textures = null;
  }

  @Override
  public Pixmap getPixmap(int d, int f) {
    return frames[d][f].pixmap;
  }

  @Override
  public TextureRegion getTexture(int d, int i) {
    assert regions[d] != null : "loadDirection(d) must be called before getTexture(d,i)";
    return regions[d][i];
  }

  @Override
  public boolean isPreloaded(int d) {
    return true;
  }

  @Override
  public void preloadDirections(boolean combineFrames) {
    assert !combineFrames;
  }

  @Override
  public void preloadDirection(int d, boolean combineFrames) {
    assert !combineFrames;
  }

  @Override
  public boolean isLoaded(int d) {
    return textures != null && textures[d] != null;
  }

  @Override
  public void loadDirections(boolean combineFrames) {
    final int numDirections = header.directions;
    for (int d = 0; d < numDirections; d++) loadDirection(d);
  }

  @Override
  public void loadDirection(int d, boolean combineFrames) {
    if (textures == null) textures = new Texture[header.directions][];
    else if (textures[d] != null) return;
    preloadDirection(d);

    textures[d] = new Texture[header.framesPerDir];
    for (int f = 0; f < header.framesPerDir; f++) {
      Pixmap pixmap = frames[d][f].pixmap;
      Texture texture = new Texture(new PixmapTextureData(pixmap, null, false, false, false));
      //texture.setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);
      texture.setWrap(Texture.TextureWrap.ClampToEdge, Texture.TextureWrap.ClampToEdge);
      textures[d][f] = texture;
    }

    regions[d] = new TextureRegion[header.framesPerDir];
    for (int f = 0; f < header.framesPerDir; f++) {
      regions[d][f] = new TextureRegion(textures[d][f]);
    }
  }

  public int getVersion() {
    return header.version;
  }

  @Override
  public int getNumDirections() {
    return header.directions;
  }

  @Override
  public int getNumFramesPerDir() {
    return header.framesPerDir;
  }

  @Override
  public Direction getDirection(int d) {
    return directions[d];
  }

  @Override
  public Frame getFrame(int d, int f) {
    return frames[d][f];
  }

  @Override
  public BBox getBox() {
    return box;
  }

  @Override
  public BBox getBox(int d) {
    return directions[d].box;
  }

  @Override
  public BBox getBox(int d, int f) {
    return getBox(d);
    //return frames[d][f].box;
  }

  /*
  public Pixmap frame(int d, int f) {
    Direction dir = directions[d];
    Frame frame = frames[d][f];
    return new PaletteIndexedPixmap(dir.box.width, dir.box.height, frame.colormap);
  }

  @Override
  public Pixmap[] frames(int d) {
    Validate.isTrue(0 <= d && d < header.directions, "Invalid direction specified: " + d);
    final int numFrames = header.framesPerDir;
    Pixmap[] pages = new Pixmap[numFrames];
    // TODO: optimize
    for (int f = 0; f < numFrames; f++) {
      pages[f] = frame(d, f);
    }

    return pages;
  }

  @Override
  public Pixmap[] frameSheets(int d) {
    Validate.isTrue(0 <= d && d < header.directions, "Invalid direction specified: " + d);

    Direction dir = directions[d];
    if (DEBUG_SHEETS) Gdx.app.debug(TAG, "dir.box = " + dir);
    final int columnWidth = dir.box.width;
    final int columnHeight = dir.box.height;
    final int numFrames = header.framesPerDir;
    final int columns = 2048 / columnWidth;
    final int width = columns * columnWidth;
    if (DEBUG_SHEETS) Gdx.app.debug(TAG, "framesPerDir = " + numFrames);
    if (DEBUG_SHEETS) Gdx.app.debug(TAG, "columns = " + columns);
    if (DEBUG_SHEETS) Gdx.app.debug(TAG, "width = " + width);

    final int rows = (numFrames + columns - 1) / columns;
    int height = rows * (columnHeight);
    if (DEBUG_SHEETS) Gdx.app.debug(TAG, "rows = " + rows);
    if (DEBUG_SHEETS) Gdx.app.debug(TAG, "height = " + height);

    final int pages = (height + 2048 - 1) / 2048;
    if (DEBUG_SHEETS) Gdx.app.debug(TAG, "pages = " + pages);
    final int rowsPerPage = (Math.min(height, 2048) / columnHeight);
    if (DEBUG_SHEETS) Gdx.app.debug(TAG, "rowsPerPage = " + rowsPerPage);

    Pixmap[] sheets = new Pixmap[pages];
    for (int i = 0; i < pages; i++) {
      int h = height;
      if (h > 2048) {
        h = rowsPerPage * columnHeight;
        height -= 2048;
      } else if (h < columnHeight) {
        h = columnHeight;
      }

      if (DEBUG_SHEETS) Gdx.app.debug(TAG, "h = " + h);
      Pixmap sheet = new PaletteIndexedPixmap(width, h);
      sheets[i] = sheet;
      if (DEBUG_SHEETS) Gdx.app.debug(TAG, i + " size = " + (width * height) + " bytes");
    }

    Frame[] src = new Frame[columns];
    for (int p = 0, r = 0, f = 0; p < pages && f < numFrames; f++) {
      Pixmap sheet = sheets[p];
      ByteBuffer buffer = sheet.getPixels();
      for (int rpp = 0; rpp < rowsPerPage && r < rows && f < numFrames; rpp++, r++) {
        int size = 0;
        while (size < columns && f < numFrames) {
          src[size++] = frames[d][f++];
        }

        for (int h = 0; h < columnHeight; h++) {
          int rowStart = buffer.position();
          for (int c = 0; c < size; c++) {
            Frame frame = src[c];
            int columnStart = buffer.position();
            if (h < frame.height) {
              buffer.put(frame.colormap, h * frame.width, frame.width);
            }

            buffer.position(columnStart + columnWidth);
          }

          buffer.position(rowStart + width);
        }
      }

      buffer.rewind();
    }

    return sheets;
  }

  @Override
  public Animation getAnimation() {
    return Animation.newAnimation(this);
  }

  @Override
  public Animation getAnimation(int d) {
    return Animation.newAnimation(this, d);
  }

  @Override
  public Animation.Layer getLayer() {
    return new Animation.Layer(this);
  }

  @Override
  public Animation.Layer getLayer(int blendMode) {
    return new Animation.Layer(this, blendMode);
  }*/

  public static DCC loadFromFile(FileHandle handle) {
    return loadFromStream(handle.read());
  }

  public static DCC loadFromStream(InputStream in) {
    try {
      final int fileSize = in.available();

      Header header = Header.obtain(in);
      if (DEBUG) Gdx.app.debug(TAG, header.toString());

      final int numDirections = header.directions;
      final int numFrames = header.framesPerDir;

      int[] dirOffsets = new int[numDirections + 1];
      ByteBuffer.wrap(IOUtils.readFully(in, numDirections << 2))
          .order(ByteOrder.LITTLE_ENDIAN)
          .asIntBuffer()
          .get(dirOffsets, 0, numDirections);
      dirOffsets[numDirections] = fileSize;
      if (DEBUG) Gdx.app.debug(TAG, "direction offsets = " + Arrays.toString(dirOffsets));

      BBox box = new BBox();
      box.xMin = box.yMin = Integer.MAX_VALUE;
      box.xMax = box.yMax = Integer.MIN_VALUE;

      Direction[] directions = new Direction[numDirections];
      Frame[][]   frames     = new Frame    [numDirections][numFrames];
      int start = dirOffsets[0], end;
      for (int d = 0; d < numDirections; d++) {
        end = dirOffsets[d + 1];
        Direction dir = directions[d] = Direction.obtain(in, end - start, frames[d]);
        if (DEBUG_DIRECTIONS) Gdx.app.debug(TAG, dir.toString());
        if (DEBUG_FRAMES) for (Frame frame : frames[d]) Gdx.app.debug(TAG, frame.toString());

        Cache cache = new Cache(header);
        fillPixelBuffer(cache, dir, frames[d]);
        makeFrames(cache, dir, frames[d]);
        if (DEBUG_PB_SIZE) Gdx.app.debug(TAG, "pixelBuffer.size = " + cache.numEntries);

        start = end;

        assert dir.equalCellBitStream.tell() == dir.equalCellBitStream.sizeInBits();
        assert dir.pixelMaskBitStream.tell() == dir.pixelMaskBitStream.sizeInBits();
        assert dir.encodingTypeBitStream.tell() == dir.encodingTypeBitStream.sizeInBits();
        assert dir.rawPixelCodesBitStream.tell() == dir.rawPixelCodesBitStream.sizeInBits();
        assert dir.pixelCodeAndDisplacementBitStream.tell() + 7 >= dir.pixelCodeAndDisplacementBitStream.sizeInBits();

        if (dir.box.xMin < box.xMin) box.xMin = dir.box.xMin;
        if (dir.box.yMin < box.yMin) box.yMin = dir.box.yMin;
        if (dir.box.xMax > box.xMax) box.xMax = dir.box.xMax;
        if (dir.box.yMax > box.yMax) box.yMax = dir.box.yMax;
      }

      box.width  = box.xMax - box.xMin + 1;
      box.height = box.yMax - box.yMin + 1;

      return new DCC(header, directions, frames, box);
    } catch (Throwable t) {
      throw new GdxRuntimeException("Couldn't load DCC from stream.", t);
    } finally {
      StreamUtils.closeQuietly(in);
    }
  }

  private static void fillPixelBuffer(Cache cache, Direction dir, Frame[] frames) {
    cache.pixelBuffer = new PixelBuffer[PixelBuffer.MAX_VALUE];
    cache.frameBuffer = Bitmap.create(dir.box.width, dir.box.height);
    prepareBufferCells(cache, dir);

    final int frameBufferCellsW = cache.frameBufferCellsW;
    final int frameBufferCellsH = cache.frameBufferCellsH;
    final int numCells = frameBufferCellsW * frameBufferCellsH;
    PixelBuffer[] cellBuffer = new PixelBuffer[numCells];

    int cellsW, cellsH;
    int cellX, cellY;

    int tmp, pixelMask = 0;
    int lastPixel, pixels, decodedPixels;
    int[] readPixel = new int[4];
    int encodingType, pixelDisplacement;

    PixelBuffer oldEntry, newEntry;
    int curId, pixelBufferId = 0;

    int curCX, curCY, curCell;
    for (int f = 0; f < frames.length; f++) {
      Frame frame = frames[f];
      Cache.FrameCache frameCache = cache.frame[f] = new Cache.FrameCache();
      prepareFrameCells(cache, frameCache, dir, frame);

      cellsW = frameCache.cellsW;
      cellsH = frameCache.cellsH;
      cellX  = (frame.box.xMin - dir.box.xMin) / 4;
      cellY  = (frame.box.yMin - dir.box.yMin) / 4;

      for (int cy = 0; cy < cellsH; cy++) {
        curCY = cellY + cy;
        for (int cx = 0; cx < cellsW; cx++) {
          curCX = cellX + cx;
          curCell = curCY * frameBufferCellsW + curCX;
          assert curCell < numCells;

          boolean nextCell = false;
          if (cellBuffer[curCell] != null) {
            if (dir.equalCellBitStreamSize > 0) {
              tmp = dir.equalCellBitStream.readBit();
            } else {
              tmp = 0;
            }

            if (tmp == 0) {
              pixelMask = dir.pixelMaskBitStream.readUnsigned8OrLess(4);
              assert pixelMask >= 0;
            } else {
              nextCell = true;
            }
          } else {
            pixelMask = 0xF;
          }

          if (!nextCell) {
            Arrays.fill(readPixel, 0);
            pixels = PixelBuffer.PIXEL_TABLE[pixelMask];
            if (pixels > 0 && dir.encodingTypeBitStreamSize > 0) {
              encodingType = dir.encodingTypeBitStream.readBit();
              assert encodingType >= 0;
            } else {
              encodingType = 0;
            }

            lastPixel = 0;
            decodedPixels = 0;
            for (int i = 0; i < pixels; i++) {
              if (encodingType > 0) {
                readPixel[i] = dir.rawPixelCodesBitStream.readUnsigned8OrLess(8);
              } else {
                readPixel[i] = lastPixel;
                do {
                  pixelDisplacement = dir.pixelCodeAndDisplacementBitStream.readUnsigned8OrLess(4);
                  readPixel[i] += pixelDisplacement;
                } while (pixelDisplacement == 0xF);
              }

              if (readPixel[i] == lastPixel) {
                readPixel[i] = 0;
                break;
              } else {
                lastPixel = readPixel[i];
                decodedPixels++;
              }
            }

            oldEntry = cellBuffer[curCell];
            if (pixelBufferId >= PixelBuffer.MAX_VALUE) {
              throw new IllegalStateException("Pixel buffer full, cannot add more entries");
            }

            newEntry = cache.pixelBuffer[pixelBufferId++] = new PixelBuffer();
            curId = decodedPixels - 1;
            for (int i = 0; i < 4; i++) {
              if ((pixelMask & (1 << i)) != 0) {
                if (curId >= 0) {
                  newEntry.val[i] = (byte) readPixel[curId--];
                } else {
                  newEntry.val[i] = 0;
                }
              } else {
                newEntry.val[i] = oldEntry.val[i];
              }
            }

            cellBuffer[curCell] = newEntry;
            newEntry.frame = f; // TODO: I'm not sure how this will behave with f as df
            newEntry.frameCellIndex = cy * cellsW + cx;
          }
        }
      }
    }

    PixelBuffer pbe;
    for (int i = 0; i < pixelBufferId; i++) {
      for (int x = 0; x < 4; x++) {
        pbe = cache.pixelBuffer[i];
        int y = pbe.val[x] & 0xFF;
        pbe.val[x] = dir.pixelValues[y];
      }
    }

    cache.numEntries = pixelBufferId;
  }

  private static void prepareBufferCells(Cache cache, Direction dir) {
    final int bufferW = dir.box.width;
    final int bufferH = dir.box.height;

    final int cellsW = cache.frameBufferCellsW = 1 + ((bufferW - 1) / 4);
    final int[] cellW = new int[cellsW];
    if (cellsW == 1) {
      cellW[0] = bufferW;
    } else {
      int cellMax = cellsW - 1;
      Arrays.fill(cellW, 0, cellMax, 4);
      cellW[cellMax] = bufferW - (4 * cellMax);
    }

    final int cellsH = cache.frameBufferCellsH = 1 + ((bufferH - 1) / 4);
    final int[] cellH = new int[cellsH];
    if (cellsH == 1) {
      cellH[0] = bufferH;
    } else {
      int cellMax = cellsH - 1;
      Arrays.fill(cellH, 0, cellMax, 4);
      cellH[cellMax] = bufferH - (4 * cellMax);
    }

    final int numCells = cellsW * cellsH;
    cache.frameBufferCells = new Cell[numCells];

    //int id = 0;
    int y = 0, x = 0;
    for (int cy = 0; cy < cellsH; cy++, y += 4, x = 0) {
      for (int cx = 0; cx < cellsW; cx++, x += 4) {
        //assert id == cy * cellsW + cx : "Making sure this optimization doesn't bite me in the ass: " + id + " =? " + (y * cellsH + x);
        // TODO: Cell implements Poolable
        Cell cell = cache.frameBufferCells[cy * cellsW + cx] = new Cell();
        cell.w = cellW[cx];
        cell.h = cellH[cy];
        cell.bmp = cache.frameBuffer.getSubimage(x, y, cell.w, cell.h);
      }
    }

    //assert id == numCells;
  }

  private static void prepareFrameCells(Cache cache, Cache.FrameCache frameCache, Direction dir, Frame frame) {
    int tmp, tmpSize;
    final int frameW = frame.box.width;
    final int frameH = frame.box.height;

    final int cellsW;
    final int w = 4 - ((frame.box.xMin - dir.box.xMin) % 4); // TODO: & 0x3
    if (frameW - w <= 1) {
      cellsW = 1;
    } else {
      tmp = frameW - w - 1;
      tmpSize = 2 + (tmp / 4);
      if (tmp % 4 == 0) tmpSize--;
      cellsW = tmpSize;
    }

    final int cellsH;
    final int h = 4 - ((frame.box.yMin - dir.box.yMin) % 4); // TODO: & 0x3
    if (frameH - h <= 1) {
      cellsH = 1;
    } else {
      tmp = frameH - h - 1;
      tmpSize = 2 + (tmp / 4);
      if (tmp % 4 == 0) tmpSize--;
      cellsH = tmpSize;
    }

    final int[] cellW = new int[cellsW];
    if (cellsW == 1) {
      cellW[0] = frameW;
    } else {
      int cellMax = cellsW - 1;
      cellW[0] = w;
      Arrays.fill(cellW, 1, cellMax, 4);
      cellW[cellMax] = frameW - w - (4 * (cellMax - 1));
    }

    final int[] cellH = new int[cellsH];
    if (cellsH == 1) {
      cellH[0] = frameH;
    } else {
      int cellMax = cellsH - 1;
      cellH[0] = h;
      Arrays.fill(cellH, 1, cellMax, 4);
      cellH[cellMax] = frameH - h - (4 * (cellMax - 1));
    }

    frameCache.cellsW = cellsW;
    frameCache.cellsH = cellsH;

    final int numCells = cellsW * cellsH;
    frameCache.cells = new Cell[numCells];


    int id = 0;
    Cell cell = null;
    final int xReset = frame.box.xMin - dir.box.xMin;
    int y = frame.box.yMin - dir.box.yMin, x = xReset;
    for (int cy = 0; cy < cellsH; cy++, y += cell.h, x = xReset) {
      for (int cx = 0; cx < cellsW; cx++, x += cell.w) {
        assert id == cy * cellsW + cx : "Making sure this optimization doesn't bite me in the ass";
        // TODO: Cell implements Poolable
        cell = frameCache.cells[id++] = new Cell();
        cell.x = x;
        cell.y = y;
        cell.w = cellW[cx];
        cell.h = cellH[cy];
        cell.bmp = cache.frameBuffer.getSubimage(cell.x, cell.y, cell.w, cell.h);
      }
    }

    assert id == numCells;
  }

  private static void makeFrames(Cache cache, Direction dir, Frame[] frames) {
    int size = cache.frameBufferCellsW * cache.frameBufferCellsH;
    for (int c = 0; c < size; c++) {
      cache.frameBufferCells[c].lastW = -1;
      cache.frameBufferCells[c].lastH = -1;
    }

    int pbId = 0;
    PixelBuffer pbe;

    int numCells, cellX, cellY, cellId;

    Frame frame;
    Cache.FrameCache frameCache;
    Bitmap frameBmp = Bitmap.create(dir.box.width, dir.box.height);
    for (int f = 0; f < frames.length; f++, frameBmp.clear()) {
      frame = frames[f];
      frameCache = cache.frame[f];
      numCells = frameCache.cellsW * frameCache.cellsH;
      for (int c = 0; c < numCells; c++) {
        pbe = cache.pixelBuffer[pbId];
        Cell cell = frameCache.cells[c];
        cellX = cell.x / 4;
        cellY = cell.y / 4;
        cellId = cellY * cache.frameBufferCellsW + cellX;
        Cell bufferCell = cache.frameBufferCells[cellId];
        if (pbe == null || pbe.frame != f || pbe.frameCellIndex != c) {
          if (cell.w != bufferCell.lastW || cell.h != bufferCell.lastH) {
            cell.bmp.clear();
          } else {
            Bitmap.copy(cache.frameBuffer, cache.frameBuffer,
                bufferCell.lastX, bufferCell.lastY,
                cell.x, cell.y,
                cell.w, cell.h);

            Bitmap.copy(cell.bmp, frameBmp,
                0, 0,
                cell.x, cell.y,
                cell.w, cell.h);
          }
        } else {
          if (pbe.val[0] == pbe.val[1]) {
            cell.bmp.fill(pbe.val[0]);
          } else {
            int bits;
            if (pbe.val[1] == pbe.val[2]) {
              bits = 1;
            } else {
              bits = 2;
            }

            for (int y = 0; y < cell.h; y++) {
              for (int x = 0; x < cell.w; x++) {
                int pix = dir.pixelCodeAndDisplacementBitStream.readUnsigned8OrLess(bits);
                cell.bmp.setPixel(x, y, pbe.val[pix]);
              }
            }
          }

          Bitmap.copy(cell.bmp, frameBmp,
              0, 0,
              cell.x, cell.y,
              cell.w, cell.h);
          pbId++;
        }

        bufferCell.lastX = cell.x;
        bufferCell.lastY = cell.y;
        bufferCell.lastW = cell.w;
        bufferCell.lastH = cell.h;
      }

      saveFrame(frame, frameBmp);
    }
  }

  private static void saveFrame(Frame frame, Bitmap frameBmp) {
    frame.colormap = frameBmp.copy();
    frame.pixmap   = new PaletteIndexedPixmap(frameBmp.width, frameBmp.height, frame.colormap);
  }

  static class Header {
    static final int SIZE = 15;

    int signature;
    int version;
    int directions;
    int framesPerDir;
    int tag;
    int finalDC6Size;

    static Header obtain(InputStream in) throws IOException {
      return new Header().read(in);
    }

    Header read(InputStream in) throws IOException {
      ByteBuffer buffer = ByteBuffer.wrap(IOUtils.readFully(in, SIZE)).order(ByteOrder.LITTLE_ENDIAN);
      signature    = BufferUtils.readUnsignedByte(buffer);
      version      = BufferUtils.readUnsignedByte(buffer);
      directions   = BufferUtils.readUnsignedByte(buffer);
      framesPerDir = buffer.getInt();
      tag          = buffer.getInt();
      finalDC6Size = buffer.getInt();
      assert !buffer.hasRemaining();
      return this;
    }

    @Override
    public String toString() {
      return new ToStringBuilder(this)
          .append("signature", "0x" + Integer.toHexString(signature))
          .append("version", version)
          .append("directions", directions)
          .append("framesPerDir", framesPerDir)
          .append("tag", "0x" + Integer.toHexString(tag))
          .append("finalDC6Size", finalDC6Size)
          .build();
    }
  }
  static class Direction extends com.riiablo.codec.DC.Direction {
    static final int MAX_VALUE = 32;

    static final int HasRawPixelEncoding = 0x1;
    static final int CompressEqualCells  = 0x2;

    int  outsizeCoded;
    byte compressionFlags;  // 2 bits
    byte variable0Bits;     // 4 bits
    byte widthBits;         // 4 bits
    byte heightBits;        // 4 bits
    byte xOffsetBits;       // 4 bits
    byte yOffsetBits;       // 4 bits
    byte optionalBytesBits; // 4 bits
    byte codedBytesBits;    // 4 bits

    long equalCellBitStreamSize;
    long pixelMaskBitStreamSize;
    long encodingTypeBitStreamSize;
    long rawPixelCodesBitStreamSize;

    BitStream equalCellBitStream;
    BitStream pixelMaskBitStream;
    BitStream encodingTypeBitStream;
    BitStream rawPixelCodesBitStream;
    BitStream pixelCodeAndDisplacementBitStream;

    byte pixelValues[]; // unsigned

    //BBox  box; // inherited

    static Direction obtain(InputStream in, int size, Frame[] frames) throws IOException {
      return new Direction().read(in, size, frames);
    }

    Direction read(InputStream in, int size, Frame[] frames) throws IOException {
      BitStream bitStream = new BitStream(IOUtils.readFully(in, size), size * Byte.SIZE);
      outsizeCoded      = (int)  bitStream.readUnsigned(32);
      compressionFlags  = (byte) bitStream.readUnsigned8OrLess(2);
      variable0Bits     = (byte) bitStream.readUnsigned8OrLess(4);
      widthBits         = (byte) bitStream.readUnsigned8OrLess(4);
      heightBits        = (byte) bitStream.readUnsigned8OrLess(4);
      xOffsetBits       = (byte) bitStream.readUnsigned8OrLess(4);
      yOffsetBits       = (byte) bitStream.readUnsigned8OrLess(4);
      optionalBytesBits = (byte) bitStream.readUnsigned8OrLess(4);
      codedBytesBits    = (byte) bitStream.readUnsigned8OrLess(4);

      box = new BBox();
      box.xMin = box.yMin = Integer.MAX_VALUE;
      box.xMax = box.yMax = Integer.MIN_VALUE;

      long optionalBytes = 0;
      for (int f = 0; f < frames.length; f++) {
        Frame frame = frames[f] = Frame.obtain(bitStream, this);
        if (frame.box.xMin < box.xMin) box.xMin = frame.box.xMin;
        if (frame.box.yMin < box.yMin) box.yMin = frame.box.yMin;
        if (frame.box.xMax > box.xMax) box.xMax = frame.box.xMax;
        if (frame.box.yMax > box.yMax) box.yMax = frame.box.yMax;
        optionalBytes += frame.optionalBytes;
      }

      box.width  = box.xMax - box.xMin + 1;
      box.height = box.yMax - box.yMin + 1;

      if (optionalBytes > 0) readOptionalBytes(bitStream, frames);
      readBitStreamSizes(bitStream);
      readPixelValues(bitStream);
      initDirBitStreams(bitStream);
      return this;
    }

    private void readOptionalBytes(BitStream bitStream, Frame[] frames) {
      bitStream.alignToByte();
      for (Frame frame : frames) {
        if (frame.optionalBytes > 0) {
          frame.optionalBytesData = bitStream.readFully(frame.optionalBytes);
        }
      }
    }

    private void readBitStreamSizes(BitStream bitStream) {
      equalCellBitStreamSize = 0;
      pixelMaskBitStreamSize = 0;
      encodingTypeBitStreamSize = 0;
      rawPixelCodesBitStreamSize = 0;

      if ((compressionFlags & CompressEqualCells) == CompressEqualCells) {
        equalCellBitStreamSize = bitStream.readUnsigned(20);
      }

      pixelMaskBitStreamSize = bitStream.readUnsigned(20);

      if ((compressionFlags & HasRawPixelEncoding) == HasRawPixelEncoding) {
        encodingTypeBitStreamSize = bitStream.readUnsigned(20);
        rawPixelCodesBitStreamSize = bitStream.readUnsigned(20);
      }
    }

    private void readPixelValues(BitStream bitStream) {
      int index = 0;
      pixelValues = new byte[Palette.COLORS];
      for (int i = 0; i < Palette.COLORS; i++) {
        if (bitStream.readBoolean()) pixelValues[index++] = (byte) i;
      }
    }

    private void initDirBitStreams(BitStream bitStream) {
      assert (compressionFlags & CompressEqualCells) != CompressEqualCells || equalCellBitStreamSize > 0;
      equalCellBitStream = bitStream.createSubView(equalCellBitStreamSize);
      bitStream.skip(equalCellBitStreamSize);

      pixelMaskBitStream = bitStream.createSubView(pixelMaskBitStreamSize);
      bitStream.skip(pixelMaskBitStreamSize);

      assert (compressionFlags & HasRawPixelEncoding) != HasRawPixelEncoding
          || (encodingTypeBitStreamSize > 0 && rawPixelCodesBitStreamSize > 0);
      encodingTypeBitStream = bitStream.createSubView(encodingTypeBitStreamSize);
      bitStream.skip(encodingTypeBitStreamSize);
      rawPixelCodesBitStream = bitStream.createSubView(rawPixelCodesBitStreamSize);
      bitStream.skip(rawPixelCodesBitStreamSize);

      pixelCodeAndDisplacementBitStream = bitStream.createSubView(bitStream.sizeInBits() - bitStream.tell());
    }

    public String getFlags() {
      StringBuilder builder = new StringBuilder();
      if ((compressionFlags & HasRawPixelEncoding) == HasRawPixelEncoding) {
        builder.append("HasRawPixelEncoding|");
      }

      if ((compressionFlags & CompressEqualCells) == CompressEqualCells) {
        builder.append("CompressEqualCells|");
      }

      int length = builder.length();
      if (length > 0) {
        builder.setLength(length - 1);
      }

      return builder.toString();
    }

    @Override
    public String toString() {
      return new ToStringBuilder(this)
          .append("outsizeCoded", outsizeCoded)
          .append("compressionFlags", getFlags())
          .append("variable0Bits", variable0Bits)
          .append("widthBits", widthBits)
          .append("heightBits", heightBits)
          .append("xOffsetBits", xOffsetBits)
          .append("yOffsetBits", yOffsetBits)
          .append("optionalBytesBits", optionalBytesBits)
          .append("codedBytesBits", codedBytesBits)
          //.append("pixelBufferCellsX", pixelBufferCellsX)
          //.append("pixelBufferCellsY", pixelBufferCellsY)
          .append("box", box)
          .build();
    }
  }
  static class Frame extends DC.Frame {
    static final int MAX_VALUE = 256;

    static final int BITS_WIDTH_TABLE[] = {
        0, 1, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 26, 28, 30, 32
    };

    int variable0;
    //int width; // inherited
    //int height; // inherited
    //int xOffset; // inherited
    //int yOffset; // inherited
    int optionalBytes;
    int codedBytes;
    //int flip; // inherited

    byte optionalBytesData[];

    //BBox   box; // inherited
    Pixmap pixmap;

    static Frame obtain(BitStream bitStream, Direction direction) throws IOException {
      return new Frame().read(bitStream, direction);
    }

    Frame read(BitStream bitStream, Direction d) throws IOException {
      variable0     = (int) bitStream.readUnsigned(BITS_WIDTH_TABLE[d.variable0Bits]);
      width         = (int) bitStream.readUnsigned(BITS_WIDTH_TABLE[d.widthBits]);
      height        = (int) bitStream.readUnsigned(BITS_WIDTH_TABLE[d.heightBits]);
      xOffset       =       bitStream.readSigned  (BITS_WIDTH_TABLE[d.xOffsetBits]);
      yOffset       =       bitStream.readSigned  (BITS_WIDTH_TABLE[d.yOffsetBits]);
      optionalBytes = (int) bitStream.readUnsigned(BITS_WIDTH_TABLE[d.optionalBytesBits]);
      codedBytes    = (int) bitStream.readUnsigned(BITS_WIDTH_TABLE[d.codedBytesBits]);
      flip          =       bitStream.readBit();

      optionalBytesData = ArrayUtils.EMPTY_BYTE_ARRAY;

      box = new BBox();
      box.xMin = xOffset;
      box.xMax = box.xMin + width - 1;
      if (flip != 0) { // bottom-up
        box.yMin = yOffset;
        box.yMax = box.yMin + height - 1;
      } else {        // top-down
        box.yMax = yOffset;
        box.yMin = box.yMax - height + 1;
      }

      box.width  = box.xMax - box.xMin + 1;
      box.height = box.yMax - box.yMin + 1;
      return this;
    }

    @Override
    public String toString() {
      return new ToStringBuilder(this)
          .append("variable0", variable0)
          .append("width", width)
          .append("height", height)
          .append("xOffset", xOffset)
          .append("yOffset", yOffset)
          .append("optionalBytes", optionalBytes)
          .append("codedBytes", codedBytes)
          .append("flip", flip)
          .append("box", box)
          .build();
    }
  }
  static class Cache {
    int  frameBufferCellsW, frameBufferCellsH;
    Cell frameBufferCells[];

    PixelBuffer pixelBuffer[];
    int numEntries;

    Bitmap frameBuffer;

    FrameCache frame[];

    Cache(Header header) {
      frame = new FrameCache[header.framesPerDir];
    }

    static class FrameCache {
      int  cellsW, cellsH;
      Cell cells[];
    }
  }
  static class Cell {
    int x, y;
    int w, h;

    int lastX, lastY;
    int lastW, lastH;

    Bitmap bmp;

    @Override
    public String toString() {
      return new ToStringBuilder(this)
          .append("x", x)
          .append("y", y)
          .append("w", w)
          .append("h", h)
          //.append("lastX", lastX)
          //.append("lastY", lastY)
          //.append("lastW", lastW)
          //.append("lastH", lastH)
          .build();
    }
  }
  static class PixelBuffer {
    // TODO: Find more accurate value, 5625 per direction?
    //static final int MAX_VALUE = 5625;
    static final int MAX_VALUE = 65586;
    static final int[] PIXEL_TABLE = { 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4 };

    byte val[] = new byte[4];
    int  frame = -1;
    int  frameCellIndex = -1;
  }
  static class Bitmap {
    byte colormap[];
    int  x, y;
    int  width, height;
    int  stride;

    static Bitmap create(int width, int height) {
      return new Bitmap(new byte[width * height], width, height);
    }

    Bitmap(byte[] colormap, int w, int h) {
      this.colormap = colormap;
      x = y = 0;
      width = stride = w;
      height = h;
    }

    Bitmap(Bitmap bmp, int x, int y, int w, int h) {
      colormap = bmp.colormap;
      this.x = bmp.x + x;
      this.y = bmp.y + y;
      width = w;
      height = h;
      stride = bmp.stride;

      assert x + w <= bmp.width && y + h <= bmp.height;
    }

    Bitmap getSubimage(int x, int y, int width, int height) {
      return new Bitmap(this, x, y, width, height);
    }

    void clear() {
      fill((byte) 0);
    }

    void fill(byte id) {
      fillBytes(0, 0, width, height, id);
    }

    void fillBytes(int x, int y, int w, int h, byte id) {
      x += this.x;
      y += this.y;
      int start = y * stride + x, end;
      for (int r = 0; r < h; r++) {
        end = start + w;
        Arrays.fill(colormap, start, end, id);
        start += stride;
      }
    }

    void setPixel(int x, int y, byte i) {
      x += this.x;
      y += this.y;
      colormap[y * stride + x] = i;
    }

    static void copy(Bitmap src, Bitmap dst,
                     int srcX, int srcY,
                     int dstX, int dstY,
                     int width, int height) {
      assert srcX + width <= src.width && dstX + width <= dst.width;
      srcX += src.x;
      srcY += src.y;
      int fromIndexSrc = srcY * src.stride + srcX;
      dstX += dst.x;
      dstY += dst.y;
      int fromIndexDst = dstY * dst.stride + dstX;
      for (int r = 0; r < height; r++) {
        System.arraycopy(
            src.colormap, fromIndexSrc,
            dst.colormap, fromIndexDst,
            width);
        fromIndexSrc += src.stride;
        fromIndexDst += dst.stride;
      }
    }

    void copy(Bitmap dst) {
      copy(this, dst, 0, 0, 0, 0, width, height);
    }

    byte[] copy() {
      return Arrays.copyOf(colormap, colormap.length);
    }
  }
}