import * as Serialization from "./binary-serialization.js";
import { PeerCommandType, PeerCommand } from "./types.js";

const FRAME_PART_LENGTH = 4;
const commandFrameStart = stringToUtf8CodesBuffer("cstr", FRAME_PART_LENGTH);
const commandFrameEnd = stringToUtf8CodesBuffer("cend", FRAME_PART_LENGTH);
const commandDivFrameStart = stringToUtf8CodesBuffer("dstr", FRAME_PART_LENGTH);
const commandDivFrameEnd = stringToUtf8CodesBuffer("dend", FRAME_PART_LENGTH);
const startFrames = [commandFrameStart, commandDivFrameStart];
const endFrames = [commandFrameEnd, commandDivFrameEnd];
const commandFramesLength = commandFrameStart.length + commandFrameEnd.length;

export function isCommandChunk(buffer: Uint8Array) {
  const { length } = commandFrameStart;
  const bufferEndingToCompare = buffer.slice(-length);
  return (
    startFrames.some((frame) =>
      areBuffersEqual(buffer, frame, FRAME_PART_LENGTH),
    ) &&
    endFrames.some((frame) =>
      areBuffersEqual(bufferEndingToCompare, frame, FRAME_PART_LENGTH),
    )
  );
}

function isFirstCommandChunk(buffer: Uint8Array) {
  return areBuffersEqual(buffer, commandFrameStart, FRAME_PART_LENGTH);
}

function isLastCommandChunk(buffer: Uint8Array) {
  return areBuffersEqual(
    buffer.slice(-FRAME_PART_LENGTH),
    commandFrameEnd,
    FRAME_PART_LENGTH,
  );
}

export class BinaryCommandJoiningError extends Error {
  constructor(readonly type: "incomplete-joining" | "no-first-chunk") {
    super();
  }
}

export class BinaryCommandChunksJoiner {
  private readonly chunks = new Serialization.ResizableUint8Array();
  private status: "joining" | "completed" = "joining";

  constructor(
    private readonly onComplete: (commandBuffer: Uint8Array) => void,
  ) {}

  addCommandChunk(chunk: Uint8Array) {
    if (this.status === "completed") return;

    const isFirstChunk = isFirstCommandChunk(chunk);
    if (!this.chunks.length && !isFirstChunk) {
      throw new BinaryCommandJoiningError("no-first-chunk");
    }
    if (this.chunks.length && isFirstChunk) {
      throw new BinaryCommandJoiningError("incomplete-joining");
    }
    this.chunks.push(this.unframeCommandChunk(chunk));

    if (!isLastCommandChunk(chunk)) return;
    this.status = "completed";
    this.onComplete(this.chunks.getBuffer());
  }

  private unframeCommandChunk(chunk: Uint8Array) {
    return chunk.slice(FRAME_PART_LENGTH, chunk.length - FRAME_PART_LENGTH);
  }
}

export class BinaryCommandCreator {
  private readonly bytes = new Serialization.ResizableUint8Array();
  private resultBuffers: Uint8Array[] = [];
  private status: "creating" | "completed" = "creating";

  constructor(
    commandType: PeerCommandType,
    private readonly maxChunkLength: number,
  ) {
    this.bytes.push(commandType);
  }

  addInteger(name: string, value: number) {
    this.bytes.push(name.charCodeAt(0));
    const bytes = Serialization.serializeInt(BigInt(value));
    this.bytes.push(bytes);
  }

  addSimilarIntArr(name: string, arr: number[]) {
    this.bytes.push(name.charCodeAt(0));
    const bytes = Serialization.serializeSimilarIntArray(
      arr.map((num) => BigInt(num)),
    );
    this.bytes.push(bytes);
  }

  addString(name: string, string: string) {
    this.bytes.push(name.charCodeAt(0));
    const bytes = Serialization.serializeString(string);
    this.bytes.push(bytes);
  }

  complete() {
    if (!this.bytes.length) throw new Error("Buffer is empty");
    if (this.status === "completed") return;
    this.status = "completed";

    const unframedBuffer = this.bytes.getBuffer();
    if (unframedBuffer.length + commandFramesLength <= this.maxChunkLength) {
      this.resultBuffers.push(
        frameBuffer(unframedBuffer, commandFrameStart, commandFrameEnd),
      );
      return;
    }

    let chunksCount = Math.ceil(unframedBuffer.length / this.maxChunkLength);
    if (
      Math.ceil(unframedBuffer.length / chunksCount) + commandFramesLength >
      this.maxChunkLength
    ) {
      chunksCount++;
    }

    for (const [i, chunk] of splitBufferToEqualChunks(
      unframedBuffer,
      chunksCount,
    )) {
      if (i === 0) {
        this.resultBuffers.push(
          frameBuffer(chunk, commandFrameStart, commandDivFrameEnd),
        );
      } else if (i === chunksCount - 1) {
        this.resultBuffers.push(
          frameBuffer(chunk, commandDivFrameStart, commandFrameEnd),
        );
      } else {
        this.resultBuffers.push(
          frameBuffer(chunk, commandDivFrameStart, commandDivFrameEnd),
        );
      }
    }
  }

  getResultBuffers(): Uint8Array[] {
    if (this.status === "creating" || !this.resultBuffers.length) {
      throw new Error("Command is not complete.");
    }
    return this.resultBuffers;
  }
}

export function deserializeCommand(bytes: Uint8Array): PeerCommand {
  const [commandCode] = bytes;
  const deserializedCommand: Record<string, unknown> = {
    c: commandCode,
  };

  let offset = 1;
  while (offset < bytes.length) {
    const name = String.fromCharCode(bytes[offset]);
    offset++;
    const dataType = getDataTypeFromByte(bytes[offset]);

    switch (dataType) {
      case Serialization.SerializedItem.Int:
        {
          const { number, byteLength } = Serialization.deserializeInt(
            bytes.slice(offset),
          );
          deserializedCommand[name] = Number(number);
          offset += byteLength;
        }
        break;
      case Serialization.SerializedItem.SimilarIntArray:
        {
          const { numbers, byteLength } =
            Serialization.deserializeSimilarIntArray(bytes.slice(offset));
          deserializedCommand[name] = numbers.map((n) => Number(n));
          offset += byteLength;
        }
        break;
      case Serialization.SerializedItem.String:
        {
          const { string, byteLength } = Serialization.deserializeString(
            bytes.slice(offset),
          );
          deserializedCommand[name] = string;
          offset += byteLength;
        }
        break;
    }
  }
  return deserializedCommand as unknown as PeerCommand;
}

function getDataTypeFromByte(byte: number): Serialization.SerializedItem {
  const typeCode: Serialization.SerializedItem = byte >> 4;
  if (
    typeCode <= Serialization.SerializedItem.Min ||
    typeCode >= Serialization.SerializedItem.Max
  ) {
    throw new Error("Not existing type");
  }

  return typeCode;
}

function stringToUtf8CodesBuffer(string: string, length?: number): Uint8Array {
  if (length && string.length !== length) {
    throw new Error("Wrong string length");
  }
  const buffer = new Uint8Array(length ?? string.length);
  for (let i = 0; i < string.length; i++) buffer[i] = string.charCodeAt(i);
  return buffer;
}

function* splitBufferToEqualChunks(
  buffer: Uint8Array,
  chunksCount: number,
): Generator<[number, Uint8Array], void> {
  const chunkLength = Math.ceil(buffer.length / chunksCount);
  for (let i = 0; i < chunksCount; i++) {
    yield [i, buffer.slice(i * chunkLength, (i + 1) * chunkLength)];
  }
}

function frameBuffer(
  buffer: Uint8Array,
  frameStart: Uint8Array,
  frameEnd: Uint8Array,
) {
  const result = new Uint8Array(
    buffer.length + frameStart.length + frameEnd.length,
  );
  result.set(frameStart);
  result.set(buffer, frameStart.length);
  result.set(frameEnd, frameStart.length + buffer.length);

  return result;
}

function areBuffersEqual(
  buffer1: Uint8Array,
  buffer2: Uint8Array,
  length: number,
) {
  for (let i = 0; i < length; i++) {
    if (buffer1[i] !== buffer2[i]) return false;
  }
  return true;
}
