export const MAXIMUM_PROJECT_DOCUMENT_BYTES = 32 * 1024 * 1024;

class ProjectDocumentByteCounter {
  #bytes = 0;
  readonly #maximumBytes: number;

  constructor(maximumBytes: number) {
    if (
      !Number.isSafeInteger(maximumBytes) ||
      maximumBytes < 0 ||
      maximumBytes > MAXIMUM_PROJECT_DOCUMENT_BYTES
    ) {
      throw new RangeError(
        `Project document byte limit must be an integer from 0 to ${MAXIMUM_PROJECT_DOCUMENT_BYTES}.`,
      );
    }
    this.#maximumBytes = maximumBytes;
  }

  add(bytes: number): void {
    this.#bytes += bytes;
    if (this.#bytes > this.#maximumBytes) {
      throw new Error("Project document exceeds the 32 MiB limit.");
    }
  }

  get bytes(): number {
    return this.#bytes;
  }
}

function addRawUtf8Bytes(
  value: string,
  counter: ProjectDocumentByteCounter,
): void {
  for (let index = 0; index < value.length; index += 1) {
    const first = value.charCodeAt(index);
    if (first <= 0x7f) {
      counter.add(1);
    } else if (first <= 0x7ff) {
      counter.add(2);
    } else if (first >= 0xd800 && first <= 0xdbff) {
      const second = value.charCodeAt(index + 1);
      if (second >= 0xdc00 && second <= 0xdfff) {
        counter.add(4);
        index += 1;
      } else {
        counter.add(3);
      }
    } else {
      counter.add(3);
    }
  }
}

function addJsonStringBytes(
  value: string,
  counter: ProjectDocumentByteCounter,
): void {
  counter.add(2);
  for (let index = 0; index < value.length; index += 1) {
    const first = value.charCodeAt(index);
    if (
      first === 0x08 ||
      first === 0x09 ||
      first === 0x0a ||
      first === 0x0c ||
      first === 0x0d ||
      first === 0x22 ||
      first === 0x5c
    ) {
      counter.add(2);
    } else if (first <= 0x1f) {
      counter.add(6);
    } else if (first <= 0x7f) {
      counter.add(1);
    } else if (first <= 0x7ff) {
      counter.add(2);
    } else if (first >= 0xd800 && first <= 0xdbff) {
      const second = value.charCodeAt(index + 1);
      if (second >= 0xdc00 && second <= 0xdfff) {
        counter.add(4);
        index += 1;
      } else {
        counter.add(6);
      }
    } else if (first >= 0xdc00 && first <= 0xdfff) {
      counter.add(6);
    } else {
      counter.add(3);
    }
  }
}

function omittedByJsonStringify(value: unknown): boolean {
  return (
    value === undefined ||
    typeof value === "function" ||
    typeof value === "symbol"
  );
}

function addIndent(depth: number, counter: ProjectDocumentByteCounter): void {
  counter.add(depth * 2);
}

function addJsonValueBytes(
  value: unknown,
  depth: number,
  ancestors: Set<object>,
  counter: ProjectDocumentByteCounter,
): void {
  if (value === null) {
    counter.add(4);
    return;
  }
  switch (typeof value) {
    case "string":
      addJsonStringBytes(value, counter);
      return;
    case "boolean":
      counter.add(value ? 4 : 5);
      return;
    case "number":
      counter.add(
        Number.isFinite(value) ? (JSON.stringify(value)?.length ?? 4) : 4,
      );
      return;
    case "bigint":
      throw new TypeError("Project document cannot contain bigint values.");
    case "undefined":
    case "function":
    case "symbol":
      throw new TypeError("Project document root must be JSON data.");
  }

  if (ancestors.has(value)) {
    throw new TypeError("Project document must not contain circular data.");
  }
  ancestors.add(value);
  try {
    if (Array.isArray(value)) {
      counter.add(1);
      if (value.length > 0) {
        counter.add(1);
        for (let index = 0; index < value.length; index += 1) {
          addIndent(depth + 1, counter);
          const item = value[index];
          if (omittedByJsonStringify(item)) {
            counter.add(4);
          } else {
            addJsonValueBytes(item, depth + 1, ancestors, counter);
          }
          counter.add(index + 1 < value.length ? 2 : 1);
        }
        addIndent(depth, counter);
      }
      counter.add(1);
      return;
    }

    const prototype = Object.getPrototypeOf(value);
    if (prototype !== Object.prototype && prototype !== null) {
      throw new TypeError(
        "Project document must contain only plain JSON objects and arrays.",
      );
    }
    const entries = Object.keys(value)
      .map((key) => [key, (value as Record<string, unknown>)[key]] as const)
      .filter((entry) => !omittedByJsonStringify(entry[1]));
    counter.add(1);
    if (entries.length > 0) {
      counter.add(1);
      for (let index = 0; index < entries.length; index += 1) {
        const [key, entryValue] = entries[index]!;
        addIndent(depth + 1, counter);
        addJsonStringBytes(key, counter);
        counter.add(2);
        addJsonValueBytes(entryValue, depth + 1, ancestors, counter);
        counter.add(index + 1 < entries.length ? 2 : 1);
      }
      addIndent(depth, counter);
    }
    counter.add(1);
  } finally {
    ancestors.delete(value);
  }
}

export function projectDocumentUtf8ByteLength(
  value: unknown,
  maximumBytes = MAXIMUM_PROJECT_DOCUMENT_BYTES,
): number {
  const counter = new ProjectDocumentByteCounter(maximumBytes);
  addJsonValueBytes(value, 0, new Set<object>(), counter);
  return counter.bytes;
}

export function serializedProjectDocumentUtf8ByteLength(
  serialized: string,
  maximumBytes = MAXIMUM_PROJECT_DOCUMENT_BYTES,
): number {
  const counter = new ProjectDocumentByteCounter(maximumBytes);
  addRawUtf8Bytes(serialized, counter);
  return counter.bytes;
}

export function assertProjectDocumentWithinLimit(value: unknown): number {
  return projectDocumentUtf8ByteLength(value, MAXIMUM_PROJECT_DOCUMENT_BYTES);
}

export function assertSerializedProjectDocumentWithinLimit(
  serialized: string,
): number {
  return serializedProjectDocumentUtf8ByteLength(
    serialized,
    MAXIMUM_PROJECT_DOCUMENT_BYTES,
  );
}
