import { HttpError, type JsonValue } from "./browser-types";

export interface JsonLimits {
  maxBytes: number;
  maxDepth: number;
  maxNodes: number;
  maxStringLength: number;
}

export const DEFAULT_JSON_LIMITS: JsonLimits = {
  maxBytes: 65_536,
  maxDepth: 32,
  maxNodes: 10_000,
  maxStringLength: 16_384,
};

export const PUBLIC_REQUEST_JSON_LIMITS: JsonLimits = {
  ...DEFAULT_JSON_LIMITS,
  maxBytes: 16_384,
};

const decoder = new TextDecoder("utf-8", { fatal: true });

class Parser {
  private offset = 0;
  private nodes = 0;

  constructor(
    private readonly source: string,
    private readonly limits: JsonLimits,
  ) {}

  parse(): JsonValue {
    this.skipWhitespace();
    const value = this.value(0);
    this.skipWhitespace();
    if (this.offset !== this.source.length) this.fail("trailing data");
    return value;
  }

  private value(depth: number): JsonValue {
    if (depth > this.limits.maxDepth) this.fail("maximum nesting depth exceeded");
    this.nodes += 1;
    if (this.nodes > this.limits.maxNodes) this.fail("maximum JSON node count exceeded");
    const character = this.source[this.offset];
    if (character === "{") return this.object(depth + 1);
    if (character === "[") return this.array(depth + 1);
    if (character === '"') return this.string();
    if (character === "t") return this.literal("true", true);
    if (character === "f") return this.literal("false", false);
    if (character === "n") return this.literal("null", null);
    if (character === "-" || (character !== undefined && character >= "0" && character <= "9")) {
      return this.number();
    }
    this.fail("expected a JSON value");
  }

  private object(depth: number): { [key: string]: JsonValue } {
    this.offset += 1;
    const result: { [key: string]: JsonValue } = Object.create(null) as {
      [key: string]: JsonValue;
    };
    const keys = new Set<string>();
    this.skipWhitespace();
    if (this.source[this.offset] === "}") {
      this.offset += 1;
      return result;
    }
    while (true) {
      if (this.source[this.offset] !== '"') this.fail("object member name must be a string");
      const key = this.string();
      if (keys.has(key)) this.fail("duplicate object member");
      keys.add(key);
      this.skipWhitespace();
      if (this.source[this.offset] !== ":") this.fail("expected ':' after object member");
      this.offset += 1;
      this.skipWhitespace();
      result[key] = this.value(depth);
      this.skipWhitespace();
      const delimiter = this.source[this.offset];
      if (delimiter === "}") {
        this.offset += 1;
        return result;
      }
      if (delimiter !== ",") this.fail("expected ',' or '}'");
      this.offset += 1;
      this.skipWhitespace();
    }
  }

  private array(depth: number): JsonValue[] {
    this.offset += 1;
    const result: JsonValue[] = [];
    this.skipWhitespace();
    if (this.source[this.offset] === "]") {
      this.offset += 1;
      return result;
    }
    while (true) {
      result.push(this.value(depth));
      this.skipWhitespace();
      const delimiter = this.source[this.offset];
      if (delimiter === "]") {
        this.offset += 1;
        return result;
      }
      if (delimiter !== ",") this.fail("expected ',' or ']'");
      this.offset += 1;
      this.skipWhitespace();
    }
  }

  private string(): string {
    const start = this.offset;
    this.offset += 1;
    let result = "";
    while (this.offset < this.source.length) {
      const character = this.source[this.offset];
      this.offset += 1;
      if (character === '"') {
        if (result.length > this.limits.maxStringLength) this.fail("maximum string length exceeded");
        assertNoLoneSurrogates(result);
        return result;
      }
      if (character === "\\") {
        const escape = this.source[this.offset];
        this.offset += 1;
        if (escape === '"' || escape === "\\" || escape === "/") result += escape;
        else if (escape === "b") result += "\b";
        else if (escape === "f") result += "\f";
        else if (escape === "n") result += "\n";
        else if (escape === "r") result += "\r";
        else if (escape === "t") result += "\t";
        else if (escape === "u") {
          const hexadecimal = this.source.slice(this.offset, this.offset + 4);
          if (!/^[0-9a-fA-F]{4}$/.test(hexadecimal)) this.fail("invalid Unicode escape");
          result += String.fromCharCode(Number.parseInt(hexadecimal, 16));
          this.offset += 4;
        } else this.fail("invalid string escape");
      } else {
        if (character === undefined || character.charCodeAt(0) < 0x20) {
          this.fail("unescaped control character");
        }
        result += character;
      }
      if (this.offset - start > this.limits.maxStringLength * 6 + 2) {
        this.fail("maximum encoded string length exceeded");
      }
    }
    this.fail("unterminated string");
  }

  private number(): number {
    const rest = this.source.slice(this.offset);
    const match = /^-?(?:0|[1-9]\d*)/.exec(rest);
    if (!match) this.fail("invalid integer");
    const token = match[0];
    const next = rest[token.length];
    if (next === "." || next === "e" || next === "E") {
      this.fail("only integers are permitted");
    }
    this.offset += token.length;
    const value = Number(token);
    if (!Number.isSafeInteger(value)) this.fail("integer exceeds the portable safe range");
    return value;
  }

  private literal<T extends JsonValue>(token: string, value: T): T {
    if (this.source.slice(this.offset, this.offset + token.length) !== token) {
      this.fail(`expected ${token}`);
    }
    this.offset += token.length;
    return value;
  }

  private skipWhitespace(): void {
    while (
      this.source[this.offset] === " " ||
      this.source[this.offset] === "\n" ||
      this.source[this.offset] === "\r" ||
      this.source[this.offset] === "\t"
    ) {
      this.offset += 1;
    }
  }

  private fail(message: string): never {
    throw new HttpError(400, "MALFORMED_JSON", `${message} at byte ${this.offset}`);
  }
}

export function assertNoLoneSurrogates(value: string): void {
  for (let index = 0; index < value.length; index += 1) {
    const code = value.charCodeAt(index);
    if (code >= 0xd800 && code <= 0xdbff) {
      const next = value.charCodeAt(index + 1);
      if (!(next >= 0xdc00 && next <= 0xdfff)) {
        throw new HttpError(400, "MALFORMED_JSON", "lone high surrogate is not permitted");
      }
      index += 1;
    } else if (code >= 0xdc00 && code <= 0xdfff) {
      throw new HttpError(400, "MALFORMED_JSON", "lone low surrogate is not permitted");
    }
  }
}

export function parseStrictJsonBytes(
  bytes: Uint8Array,
  limits: JsonLimits = DEFAULT_JSON_LIMITS,
): JsonValue {
  if (bytes.byteLength > limits.maxBytes) {
    throw new HttpError(413, "BODY_TOO_LARGE", `request body exceeds ${limits.maxBytes} bytes`);
  }
  let source: string;
  try {
    source = decoder.decode(bytes);
  } catch {
    throw new HttpError(400, "MALFORMED_UTF8", "request body is not valid UTF-8");
  }
  return new Parser(source, limits).parse();
}

export async function parseStrictJsonRequest(
  request: Request,
  limits: JsonLimits = DEFAULT_JSON_LIMITS,
): Promise<JsonValue> {
  return (await parseStrictJsonRequestWithBytes(request, limits)).value;
}

export async function parseStrictJsonRequestWithBytes(
  request: Request,
  limits: JsonLimits = DEFAULT_JSON_LIMITS,
): Promise<{ value: JsonValue; bytes: Uint8Array }> {
  const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim();
  if (contentType !== "application/json") {
    throw new HttpError(415, "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json");
  }
  const declaredLength = request.headers.get("content-length");
  if (declaredLength !== null && Number(declaredLength) > limits.maxBytes) {
    throw new HttpError(413, "BODY_TOO_LARGE", `request body exceeds ${limits.maxBytes} bytes`);
  }
  if (request.body === null) {
    const bytes = new Uint8Array();
    return { value: parseStrictJsonBytes(bytes, limits), bytes };
  }
  const reader = request.body.getReader();
  const chunks: Uint8Array[] = [];
  let total = 0;
  while (true) {
    const result = await reader.read();
    if (result.done) break;
    total += result.value.byteLength;
    if (total > limits.maxBytes) {
      await reader.cancel("request body limit exceeded");
      throw new HttpError(413, "BODY_TOO_LARGE", `request body exceeds ${limits.maxBytes} bytes`);
    }
    chunks.push(result.value);
  }
  const bytes = new Uint8Array(total);
  let offset = 0;
  for (const chunk of chunks) {
    bytes.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return { value: parseStrictJsonBytes(bytes, limits), bytes };
}

export function requireClosedObject(
  value: JsonValue,
  allowed: readonly string[],
  required: readonly string[],
): { [key: string]: JsonValue } {
  if (value === null || Array.isArray(value) || typeof value !== "object") {
    throw new HttpError(400, "INVALID_REQUEST", "request body must be a JSON object");
  }
  for (const key of Object.keys(value)) {
    if (!allowed.includes(key)) {
      throw new HttpError(
        400,
        "UNKNOWN_FIELD",
        "request contains an unknown field",
      );
    }
  }
  for (const key of required) {
    if (!(key in value)) throw new HttpError(400, "MISSING_FIELD", `missing field ${JSON.stringify(key)}`);
  }
  return value;
}
