import * as Serialization from "./binary-serialization.js";
import {
  PeerCommandType,
  PeerCommand,
  PeerSegmentCommand,
  PeerRequestSegmentCommand,
  PeerSegmentAnnouncementCommand,
  PeerSendSegmentCommand,
} 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) {
  if (buffer.length < commandFramesLength) return false;
  const { length } = commandFrameStart;
  const bufferEndingToCompare = buffer.subarray(-length);
  return (
    startFrames.some((frame) =>
      areBuffersEqual(buffer, frame, FRAME_PART_LENGTH),
    ) &&
    endFrames.some((frame) =>
      areBuffersEqual(bufferEndingToCompare, frame, FRAME_PART_LENGTH),
    )
  );
}

function isFirstCommandChunk(buffer: Uint8Array) {
  if (buffer.length < commandFramesLength) return false;
  return areBuffersEqual(buffer, commandFrameStart, FRAME_PART_LENGTH);
}

function isLastCommandChunk(buffer: Uint8Array) {
  if (buffer.length < commandFramesLength) return false;
  return areBuffersEqual(
    buffer.subarray(-FRAME_PART_LENGTH),
    commandFrameEnd,
    FRAME_PART_LENGTH,
  );
}

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

export class BinaryCommandChunksJoiner {
  readonly #chunks = new Serialization.ResizableUint8Array();
  #status: "joining" | "completed" = "joining";
  readonly #onComplete: (commandBuffer: Uint8Array) => void;

  constructor(onComplete: (commandBuffer: Uint8Array) => void) {
    this.#onComplete = onComplete;
  }

  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());
  }

  #unframeCommandChunk(chunk: Uint8Array) {
    if (chunk.length < commandFramesLength) {
      throw new Error("Command chunk is too short to unframe");
    }
    return chunk.subarray(FRAME_PART_LENGTH, chunk.length - FRAME_PART_LENGTH);
  }
}

export class BinaryCommandCreator {
  readonly #bytes = new Serialization.ResizableUint8Array();
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments
  #resultBuffers: Uint8Array<ArrayBuffer>[] = [];
  #status: "creating" | "completed" = "creating";
  readonly #maxChunkLength: number;

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

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

  addUniqueSimilarIntArr(name: string, arr: number[]) {
    this.#bytes.push(name.charCodeAt(0));
    const bytes = Serialization.serializeUniqueSimilarIntArray(arr);
    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),
        );
      }
    }
  }

  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments
  getResultBuffers(): Uint8Array<ArrayBuffer>[] {
    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) {
    if (offset + 1 >= bytes.length) {
      throw new Error("Malformed command buffer: truncated name/type header");
    }
    const name = String.fromCharCode(bytes[offset]);
    offset++;
    const dataType = getDataTypeFromByte(bytes[offset]);

    switch (dataType) {
      case Serialization.SerializedItem.Int:
        {
          const { number, byteLength } = Serialization.deserializeInt(
            bytes.subarray(offset),
          );
          deserializedCommand[name] = number;
          offset += byteLength;
        }
        break;
      case Serialization.SerializedItem.SimilarIntArray:
        {
          const { numbers, byteLength } =
            Serialization.deserializeUniqueSimilarIntArray(
              bytes.subarray(offset),
            );
          deserializedCommand[name] = numbers;
          offset += byteLength;
        }
        break;
      case Serialization.SerializedItem.String:
        {
          const { string, byteLength } = Serialization.deserializeString(
            bytes.subarray(offset),
          );
          deserializedCommand[name] = string;
          offset += byteLength;
        }
        break;
    }
  }
  return validateCommand(deserializedCommand);
}

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.subarray(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;
}

function validateCommand(command: Record<string, unknown>): PeerCommand {
  switch (command.c) {
    case PeerCommandType.SegmentsAnnouncement:
      return command as unknown as PeerSegmentAnnouncementCommand;

    case PeerCommandType.SegmentRequest:
      assertNumberFields(command, "i", "r");
      return command as unknown as PeerRequestSegmentCommand;

    case PeerCommandType.SegmentData:
      assertNumberFields(command, "i", "r", "s");
      return command as unknown as PeerSendSegmentCommand;

    case PeerCommandType.SegmentAbsent:
    case PeerCommandType.CancelSegmentRequest:
    case PeerCommandType.SegmentDataSendingCompleted:
      assertNumberFields(command, "i", "r");
      return command as unknown as PeerSegmentCommand;

    default:
      throw new Error(`Unknown peer command type: ${String(command.c)}`);
  }
}

function assertNumberFields(
  obj: Record<string, unknown>,
  ...fields: string[]
): void {
  for (const field of fields) {
    if (typeof obj[field] !== "number") {
      throw new Error(
        `Expected number field "${field}", got ${typeof obj[field]}`,
      );
    }
  }
}
