import { assert } from '../../common/util/util.js';

import { float16ToUint16, uint16ToFloat16 } from './conversion.js';
import { align } from './math.js';

/**
 * BinaryStream is a utility to efficiently encode and decode numbers to / from a Uint8Array.
 * BinaryStream uses a number of internal typed arrays to avoid small array allocations when reading
 * and writing.
 */
export default class BinaryStream {
  /**
   * Constructor
   * @param buffer the buffer to read from / write to. Array length must be a multiple of 8 bytes.
   */
  constructor(buffer: ArrayBufferLike) {
    this.offset = 0;
    this.view = new DataView(buffer);
  }

  /** buffer() returns the stream's buffer sliced to the 8-byte rounded read or write offset */
  buffer(): Uint8Array {
    return new Uint8Array(this.view.buffer, 0, align(this.offset, 8));
  }

  /** writeBool() writes a boolean as 255 or 0 to the buffer at the next byte offset */
  writeBool(value: boolean) {
    this.view.setUint8(this.offset++, value ? 255 : 0);
  }

  /** readBool() reads a boolean from the buffer at the next byte offset */
  readBool(): boolean {
    const val = this.view.getUint8(this.offset++);
    assert(val === 0 || val === 255);
    return val !== 0;
  }

  /** writeU8() writes a uint8 to the buffer at the next byte offset */
  writeU8(value: number) {
    this.view.setUint8(this.offset++, value);
  }

  /** readU8() reads a uint8 from the buffer at the next byte offset */
  readU8(): number {
    return this.view.getUint8(this.offset++);
  }

  /** writeU16() writes a uint16 to the buffer at the next 16-bit aligned offset */
  writeU16(value: number) {
    this.view.setUint16(this.alignedOffset(2), value, /* littleEndian */ true);
  }

  /** readU16() reads a uint16 from the buffer at the next 16-bit aligned offset */
  readU16(): number {
    return this.view.getUint16(this.alignedOffset(2), /* littleEndian */ true);
  }

  /** writeU32() writes a uint32 to the buffer at the next 32-bit aligned offset */
  writeU32(value: number) {
    this.view.setUint32(this.alignedOffset(4), value, /* littleEndian */ true);
  }

  /** readU32() reads a uint32 from the buffer at the next 32-bit aligned offset */
  readU32(): number {
    return this.view.getUint32(this.alignedOffset(4), /* littleEndian */ true);
  }

  /** writeI8() writes a int8 to the buffer at the next byte offset */
  writeI8(value: number) {
    this.view.setInt8(this.offset++, value);
  }

  /** readI8() reads a int8 from the buffer at the next byte offset */
  readI8(): number {
    return this.view.getInt8(this.offset++);
  }

  /** writeI16() writes a int16 to the buffer at the next 16-bit aligned offset */
  writeI16(value: number) {
    this.view.setInt16(this.alignedOffset(2), value, /* littleEndian */ true);
  }

  /** readI16() reads a int16 from the buffer at the next 16-bit aligned offset */
  readI16(): number {
    return this.view.getInt16(this.alignedOffset(2), /* littleEndian */ true);
  }

  /** writeI64() writes a bitint to the buffer at the next 64-bit aligned offset */
  writeI64(value: bigint) {
    this.view.setBigInt64(this.alignedOffset(8), value, /* littleEndian */ true);
  }

  /** readI64() reads a bigint from the buffer at the next 64-bit aligned offset */
  readI64(): bigint {
    return this.view.getBigInt64(this.alignedOffset(8), /* littleEndian */ true);
  }

  /** writeI32() writes a int32 to the buffer at the next 32-bit aligned offset */
  writeI32(value: number) {
    this.view.setInt32(this.alignedOffset(4), value, /* littleEndian */ true);
  }

  /** readI32() reads a int32 from the buffer at the next 32-bit aligned offset */
  readI32(): number {
    return this.view.getInt32(this.alignedOffset(4), /* littleEndian */ true);
  }

  /** writeF16() writes a float16 to the buffer at the next 16-bit aligned offset */
  writeF16(value: number) {
    this.writeU16(float16ToUint16(value));
  }

  /** readF16() reads a float16 from the buffer at the next 16-bit aligned offset */
  readF16(): number {
    return uint16ToFloat16(this.readU16());
  }

  /** writeF32() writes a float32 to the buffer at the next 32-bit aligned offset */
  writeF32(value: number) {
    this.view.setFloat32(this.alignedOffset(4), value, /* littleEndian */ true);
  }

  /** readF32() reads a float32 from the buffer at the next 32-bit aligned offset */
  readF32(): number {
    return this.view.getFloat32(this.alignedOffset(4), /* littleEndian */ true);
  }

  /** writeF64() writes a float64 to the buffer at the next 64-bit aligned offset */
  writeF64(value: number) {
    this.view.setFloat64(this.alignedOffset(8), value, /* littleEndian */ true);
  }

  /** readF64() reads a float64 from the buffer at the next 64-bit aligned offset */
  readF64(): number {
    return this.view.getFloat64(this.alignedOffset(8), /* littleEndian */ true);
  }

  /**
   * writeString() writes a length-prefixed UTF-16 string to the buffer at the next 32-bit aligned
   * offset
   */
  writeString(value: string) {
    this.writeU32(value.length);
    for (let i = 0; i < value.length; i++) {
      this.writeU16(value.charCodeAt(i));
    }
  }

  /**
   * readString() writes a length-prefixed UTF-16 string from the buffer at the next 32-bit aligned
   * offset
   */
  readString(): string {
    const len = this.readU32();
    const codes = new Array<number>(len);
    for (let i = 0; i < len; i++) {
      codes[i] = this.readU16();
    }
    return String.fromCharCode(...codes);
  }

  /**
   * writeArray() writes a length-prefixed array of T elements to the buffer at the next 32-bit
   * aligned offset, using the provided callback to write the individual elements
   */
  writeArray<T>(value: readonly T[], writeElement: (s: BinaryStream, element: T) => void) {
    this.writeU32(value.length);
    for (const element of value) {
      writeElement(this, element);
    }
  }

  /**
   * readArray() reads a length-prefixed array of T elements from the buffer at the next 32-bit
   * aligned offset, using the provided callback to read the individual elements
   */
  readArray<T>(readElement: (s: BinaryStream) => T): T[] {
    const len = this.readU32();
    const array = new Array<T>(len);
    for (let i = 0; i < len; i++) {
      array[i] = readElement(this);
    }
    return array;
  }

  /**
   * writeCond() writes the boolean condition `cond` to the buffer, then either calls if_true if
   * `cond` is true, otherwise if_false
   */
  writeCond<T, F>(cond: boolean, fns: { if_true: () => T; if_false: () => F }) {
    this.writeBool(cond);
    if (cond) {
      return fns.if_true();
    } else {
      return fns.if_false();
    }
  }

  /**
   * readCond() reads a boolean condition from the buffer, then either calls if_true if
   * the condition was is true, otherwise if_false
   */
  readCond<T, F>(fns: { if_true: () => T; if_false: () => F }) {
    if (this.readBool()) {
      return fns.if_true();
    } else {
      return fns.if_false();
    }
  }

  /**
   * alignedOffset() aligns this.offset to `bytes`, then increments this.offset by `bytes`.
   * @returns the old offset aligned to the next multiple of `bytes`.
   */
  private alignedOffset(bytes: number) {
    const aligned = align(this.offset, bytes);
    this.offset = aligned + bytes;
    return aligned;
  }

  private offset: number;
  private view: DataView;
}
