export const MAX_RAW_CSV_BYTES = 10_000_000;
export const MAX_DECODED_CODE_UNITS = 5_000_000;
export const MAX_CSV_RECORDS = 10_000;
export const MAX_CSV_COLUMNS = 256;
export const MAX_CSV_FIELD_CODE_UNITS = 65_536;

export type CsvEncoding = "auto" | "utf-8" | "utf-16le" | "utf-16be";

type SupportedEncoding = Exclude<CsvEncoding, "auto">;

const SUPPORTED_ENCODINGS = new Set<CsvEncoding>([
  "auto",
  "utf-8",
  "utf-16le",
  "utf-16be",
]);

const SUPPORTED_BOMS = Object.freeze([
  Object.freeze({
    encoding: "utf-8" as const,
    bytes: Object.freeze([0xef, 0xbb, 0xbf]),
  }),
  Object.freeze({
    encoding: "utf-16le" as const,
    bytes: Object.freeze([0xff, 0xfe]),
  }),
  Object.freeze({
    encoding: "utf-16be" as const,
    bytes: Object.freeze([0xfe, 0xff]),
  }),
]);

const UNSUPPORTED_BOMS = Object.freeze([
  Object.freeze([0xff, 0xfe, 0x00, 0x00]),
  Object.freeze([0x00, 0x00, 0xfe, 0xff]),
  Object.freeze([0x2b, 0x2f, 0x76, 0x38]),
  Object.freeze([0x2b, 0x2f, 0x76, 0x39]),
  Object.freeze([0x2b, 0x2f, 0x76, 0x2b]),
  Object.freeze([0x2b, 0x2f, 0x76, 0x2f]),
]);

export type CsvDecodeErrorCode =
  | "raw-size-limit"
  | "decoded-size-limit"
  | "unsupported-encoding"
  | "encoding-mismatch"
  | "malformed-bytes";

export class CsvDecodeError extends Error {
  readonly code: CsvDecodeErrorCode;

  constructor(code: CsvDecodeErrorCode, message: string) {
    super(message);
    this.name = "CsvDecodeError";
    this.code = code;
  }
}

export function decodeCsvBytes(
  input: Uint8Array,
  requestedEncoding: CsvEncoding,
  options: {
    readonly maxRawBytes?: number;
    readonly maxDecodedCodeUnits?: number;
  } = {},
): string {
  const maxRawBytes = options.maxRawBytes ?? MAX_RAW_CSV_BYTES;
  const maxDecodedCodeUnits =
    options.maxDecodedCodeUnits ?? MAX_DECODED_CODE_UNITS;
  if (!(input instanceof Uint8Array)
    || !Number.isSafeInteger(maxRawBytes)
    || maxRawBytes < 1
    || !Number.isSafeInteger(maxDecodedCodeUnits)
    || maxDecodedCodeUnits < 1) {
    throw new TypeError("CSV decoding bounds are invalid.");
  }
  if (!SUPPORTED_ENCODINGS.has(requestedEncoding)) {
    throw new CsvDecodeError(
      "unsupported-encoding",
      "The configured CSV encoding is unsupported.",
    );
  }
  if (input.byteLength > maxRawBytes) {
    throw new CsvDecodeError(
      "raw-size-limit",
      "The selected CSV exceeds the raw-byte limit.",
    );
  }
  if (UNSUPPORTED_BOMS.some(bom => startsWith(input, bom))) {
    throw new CsvDecodeError(
      "unsupported-encoding",
      "The CSV uses an unsupported byte-order mark.",
    );
  }

  const bom = SUPPORTED_BOMS.find(candidate => startsWith(input, candidate.bytes));
  let encoding: SupportedEncoding;
  if (requestedEncoding === "auto") {
    encoding = bom?.encoding ?? "utf-8";
  } else {
    encoding = requestedEncoding;
    if (bom && bom.encoding !== requestedEncoding) {
      throw new CsvDecodeError(
        "encoding-mismatch",
        `The CSV byte-order mark does not match ${requestedEncoding}.`,
      );
    }
  }

  const bytes = bom?.encoding === encoding
    ? input.subarray(bom.bytes.length)
    : input;
  let decoded: string;
  try {
    decoded = new TextDecoder(encoding, {
      fatal: true,
      ignoreBOM: true,
    }).decode(bytes);
  } catch {
    throw new CsvDecodeError(
      "malformed-bytes",
      `The CSV contains malformed ${encoding} bytes.`,
    );
  }
  if (decoded.length > maxDecodedCodeUnits) {
    throw new CsvDecodeError(
      "decoded-size-limit",
      "The decoded CSV exceeds the UTF-16 code-unit limit.",
    );
  }
  return decoded;
}

function startsWith(
  input: Uint8Array,
  prefix: readonly number[],
): boolean {
  return input.byteLength >= prefix.length
    && prefix.every((byte, index) => input[index] === byte);
}

export type CsvParseErrorCode =
  | "invalid-delimiter"
  | "unterminated-quote"
  | "unexpected-quote"
  | "characters-after-quote"
  | "field-size-limit"
  | "column-limit"
  | "record-limit";

export class CsvParseError extends Error {
  readonly code: CsvParseErrorCode;
  readonly record: number;
  readonly column: number;

  constructor(
    code: CsvParseErrorCode,
    message: string,
    record: number,
    column: number,
  ) {
    super(message);
    this.name = "CsvParseError";
    this.code = code;
    this.record = record;
    this.column = column;
  }
}

export interface CsvParseOptions {
  readonly delimiter: string;
  readonly maxRecords?: number;
  readonly maxColumns?: number;
  readonly maxFieldCodeUnits?: number;
}

export function parseCsv(
  input: string,
  options: CsvParseOptions,
): readonly (readonly string[])[] {
  if (typeof input !== "string") throw new TypeError("CSV input must be text.");
  const delimiter = options.delimiter;
  const maxRecords = options.maxRecords ?? MAX_CSV_RECORDS;
  const maxColumns = options.maxColumns ?? MAX_CSV_COLUMNS;
  const maxFieldCodeUnits =
    options.maxFieldCodeUnits ?? MAX_CSV_FIELD_CODE_UNITS;
  if (Array.from(delimiter).length !== 1
    || delimiter === "\""
    || delimiter === "\r"
    || delimiter === "\n") {
    throw new CsvParseError(
      "invalid-delimiter",
      "The CSV delimiter must be one non-quote character.",
      1,
      1,
    );
  }
  if (!Number.isSafeInteger(maxRecords) || maxRecords < 1
    || !Number.isSafeInteger(maxColumns) || maxColumns < 1
    || !Number.isSafeInteger(maxFieldCodeUnits) || maxFieldCodeUnits < 1) {
    throw new TypeError("CSV parser bounds are invalid.");
  }

  const records: string[][] = [];
  let record: string[] = [];
  let field = "";
  let inQuotes = false;
  let quoteClosed = false;
  let sawRecordInput = false;
  let endedWithRecordSeparator = false;

  const currentRecordNumber = () => records.length + 1;
  const currentColumnNumber = () => record.length + 1;
  const fail = (code: CsvParseErrorCode, message: string): never => {
    throw new CsvParseError(
      code,
      message,
      currentRecordNumber(),
      currentColumnNumber(),
    );
  };
  const append = (value: string) => {
    field += value;
    if (field.length > maxFieldCodeUnits) {
      fail("field-size-limit", "A CSV field exceeds the code-unit limit.");
    }
  };
  const finishField = () => {
    if (record.length >= maxColumns) {
      fail("column-limit", "A CSV record exceeds the column limit.");
    }
    record.push(field);
    field = "";
    quoteClosed = false;
  };
  const finishRecord = () => {
    finishField();
    if (records.length >= maxRecords) {
      fail("record-limit", "The CSV exceeds the record limit.");
    }
    records.push(record);
    record = [];
    sawRecordInput = false;
  };

  for (let index = 0; index < input.length; index += 1) {
    const character = input[index];
    endedWithRecordSeparator = false;
    if (inQuotes) {
      if (character !== "\"") {
        append(character);
        continue;
      }
      if (input[index + 1] === "\"") {
        append("\"");
        index += 1;
        continue;
      }
      inQuotes = false;
      quoteClosed = true;
      continue;
    }

    if (quoteClosed
      && character !== delimiter
      && character !== "\r"
      && character !== "\n") {
      fail(
        "characters-after-quote",
        "Unexpected characters follow a closing CSV quote.",
      );
    }
    if (character === "\"") {
      if (field.length !== 0 || quoteClosed) {
        fail("unexpected-quote", "A CSV quote must begin an empty field.");
      }
      inQuotes = true;
      sawRecordInput = true;
      continue;
    }
    if (character === delimiter) {
      finishField();
      sawRecordInput = true;
      continue;
    }
    if (character === "\r" || character === "\n") {
      if (character === "\r" && input[index + 1] === "\n") index += 1;
      finishRecord();
      endedWithRecordSeparator = true;
      continue;
    }
    append(character);
    sawRecordInput = true;
  }

  if (inQuotes) {
    fail("unterminated-quote", "The CSV ends inside a quoted field.");
  }
  if (!endedWithRecordSeparator
    && (sawRecordInput || field.length > 0 || record.length > 0 || quoteClosed)) {
    finishRecord();
  }
  return Object.freeze(
    records.map(row => Object.freeze([...row])),
  );
}
