import {
  DEFAULT_REGEX_LIMITS,
  utf8ByteLength,
} from "../execution/request-limits";
import type { CorpusDocument } from "./corpus.types";

const EXPLICIT_TEXT_MEDIA_TYPES = new Set([
  "application/csv",
  "application/json",
  "application/ld+json",
  "application/rtf",
  "application/sql",
  "application/x-httpd-php",
  "application/x-ndjson",
  "application/x-sh",
  "application/xhtml+xml",
  "application/xml",
  "application/yaml",
  "image/svg+xml",
]);

const TEXT_EXTENSIONS = new Set([
  "asc",
  "c",
  "cfg",
  "conf",
  "cpp",
  "css",
  "csv",
  "go",
  "h",
  "hpp",
  "htm",
  "html",
  "ini",
  "java",
  "js",
  "json",
  "jsonl",
  "jsx",
  "log",
  "md",
  "ndjson",
  "php",
  "properties",
  "py",
  "rb",
  "rs",
  "rtf",
  "sh",
  "sql",
  "svg",
  "text",
  "toml",
  "ts",
  "tsv",
  "tsx",
  "txt",
  "xml",
  "yaml",
  "yml",
]);

let fallbackDocumentId = 0;

export function corpusDocumentId(): string {
  return (
    globalThis.crypto?.randomUUID?.() ??
    `corpus-${Date.now()}-${(fallbackDocumentId += 1)}`
  );
}

function fileExtension(name: string): string {
  const match = /\.([^.]+)$/u.exec(name);
  return match?.[1]?.toLocaleLowerCase("en-US") ?? "";
}

function supportsTextImport(file: File): boolean {
  const mediaType = file.type.toLocaleLowerCase("en-US");
  return (
    mediaType.length === 0 ||
    mediaType.startsWith("text/") ||
    EXPLICIT_TEXT_MEDIA_TYPES.has(mediaType) ||
    TEXT_EXTENSIONS.has(fileExtension(file.name))
  );
}

function uniqueDocumentName(
  requested: string,
  existingNames: Set<string>,
): string {
  const normalized = Array.from(requested.normalize("NFC"))
    .map((character) => {
      const code = character.codePointAt(0) ?? 0;
      return code < 0x20 || code === 0x7f ? " " : character;
    })
    .join("")
    .replace(/\s+/gu, " ")
    .trim()
    .slice(0, 240);
  const base = normalized || "untitled.txt";
  if (!existingNames.has(base)) {
    existingNames.add(base);
    return base;
  }
  const dot = base.lastIndexOf(".");
  const stem = dot > 0 ? base.slice(0, dot) : base;
  const extension = dot > 0 ? base.slice(dot) : "";
  for (let suffix = 2; suffix <= 10_000; suffix += 1) {
    const candidate = `${stem.slice(0, 220)} (${suffix})${extension}`;
    if (!existingNames.has(candidate)) {
      existingNames.add(candidate);
      return candidate;
    }
  }
  throw new Error("Could not create a unique corpus document name.");
}

function assertDocumentCount(count: number): void {
  if (count > DEFAULT_REGEX_LIMITS.maximumCorpusDocuments) {
    throw new Error(
      `A corpus can contain at most ${DEFAULT_REGEX_LIMITS.maximumCorpusDocuments.toLocaleString()} documents.`,
    );
  }
}

function assertDocumentBytes(bytes: number, name: string): void {
  if (bytes > DEFAULT_REGEX_LIMITS.maximumCorpusDocumentBytes) {
    throw new Error(
      `${name} exceeds the ${(
        DEFAULT_REGEX_LIMITS.maximumCorpusDocumentBytes /
        1024 /
        1024
      ).toLocaleString()} MiB per-document limit.`,
    );
  }
}

function assertAggregateBytes(bytes: number): void {
  if (bytes > DEFAULT_REGEX_LIMITS.corpusHardBytes) {
    throw new Error(
      `The corpus exceeds the ${(
        DEFAULT_REGEX_LIMITS.corpusHardBytes /
        1024 /
        1024
      ).toLocaleString()} MiB aggregate limit.`,
    );
  }
}

function assertTextContent(text: string, name: string): void {
  for (const character of text) {
    const code = character.codePointAt(0) ?? 0;
    if (
      code <= 0x08 ||
      code === 0x0b ||
      (code >= 0x0e && code <= 0x1f) ||
      code === 0x7f
    ) {
      throw new Error(
        `${name} contains binary control bytes and does not appear to be text.`,
      );
    }
  }
}

export function createPastedCorpusDocument(
  requestedName: string,
  text: string,
  existing: readonly CorpusDocument[],
): CorpusDocument {
  assertDocumentCount(existing.length + 1);
  const bytes = utf8ByteLength(text);
  assertDocumentBytes(bytes, requestedName || "Pasted text");
  assertAggregateBytes(
    existing.reduce((sum, document) => sum + document.bytes, 0) + bytes,
  );
  assertTextContent(text, requestedName || "Pasted text");
  const names = new Set(existing.map((document) => document.name));
  return {
    id: corpusDocumentId(),
    name: uniqueDocumentName(requestedName, names),
    text,
    bytes,
    source: "pasted-text",
  };
}

export async function readCorpusFiles(
  files: readonly File[],
  existing: readonly CorpusDocument[],
  signal?: AbortSignal,
  onRead?: (completed: number, total: number, name: string) => void,
): Promise<readonly CorpusDocument[]> {
  assertDocumentCount(existing.length + files.length);
  const existingBytes = existing.reduce(
    (sum, document) => sum + document.bytes,
    0,
  );
  let declaredBytes = existingBytes;
  for (const file of files) {
    assertDocumentBytes(file.size, file.name || "Unnamed file");
    declaredBytes += file.size;
    assertAggregateBytes(declaredBytes);
    if (!supportsTextImport(file)) {
      throw new Error(
        `${file.name || "Unnamed file"} is not a recognized text file. Binary corpus input is rejected.`,
      );
    }
  }

  const names = new Set(existing.map((document) => document.name));
  const documents: CorpusDocument[] = [];
  for (const [index, file] of files.entries()) {
    if (signal?.aborted)
      throw new DOMException("Import cancelled.", "AbortError");
    let buffer: ArrayBuffer;
    try {
      buffer = await file.arrayBuffer();
    } catch (error) {
      throw new Error(`Could not read ${file.name || "the selected file"}.`, {
        cause: error,
      });
    }
    if (signal?.aborted)
      throw new DOMException("Import cancelled.", "AbortError");
    let text: string;
    try {
      // Keeping a UTF-8 BOM in the string lets a no-op apply round-trip it.
      text = new TextDecoder("utf-8", {
        fatal: true,
        ignoreBOM: true,
      }).decode(buffer);
    } catch (error) {
      throw new Error(
        `${file.name || "The selected file"} is not valid UTF-8 text.`,
        { cause: error },
      );
    }
    assertTextContent(text, file.name || "The selected file");
    documents.push({
      id: corpusDocumentId(),
      name: uniqueDocumentName(file.name, names),
      text,
      bytes: file.size,
      source: "file",
    });
    onRead?.(index + 1, files.length, file.name);
  }
  return documents;
}

export function corpusTotalBytes(documents: readonly CorpusDocument[]): number {
  return documents.reduce((sum, document) => sum + document.bytes, 0);
}
