import { strToU8, zip } from "fflate";
import { utf8ByteLength } from "../execution/request-limits";
import type { CorpusBatchResult, CorpusDocumentResult } from "./corpus.types";

export const CORPUS_REPORT_SCHEMA_VERSION = 1;
export const MAXIMUM_CORPUS_REPORT_BYTES = 4 * 1024 * 1024;
export const MAXIMUM_CORPUS_ZIP_BYTES = 68 * 1024 * 1024;
export type CorpusReportFormat = "json" | "csv" | "ndjson";

export function assertCorpusZipByteLength(byteLength: number): void {
  if (
    !Number.isSafeInteger(byteLength) ||
    byteLength < 0 ||
    byteLength > MAXIMUM_CORPUS_ZIP_BYTES
  ) {
    throw new Error("Applied-output ZIP exceeds the 68 MiB archive limit.");
  }
}

function safeFileName(name: string): string {
  const normalized = Array.from(name.normalize("NFKC").replace(/[/\\]+/gu, "-"))
    .filter((character) => {
      const code = character.codePointAt(0) ?? 0;
      return code >= 0x20 && code !== 0x7f;
    })
    .join("")
    .replace(/^[.\-\s]+/u, "")
    .trim()
    .slice(0, 180);
  return normalized || "document.txt";
}

function appliedFileName(name: string): string {
  const safe = safeFileName(name);
  const dot = safe.lastIndexOf(".");
  return dot > 0
    ? `${safe.slice(0, dot)}.replaced${safe.slice(dot)}`
    : `${safe}.replaced.txt`;
}

function uniqueAppliedNames(
  results: readonly CorpusDocumentResult[],
): ReadonlyMap<string, string> {
  const names = new Map<string, string>();
  const occupied = new Set<string>();
  for (const result of results) {
    const requested = appliedFileName(result.name);
    const dot = requested.lastIndexOf(".");
    const stem = dot > 0 ? requested.slice(0, dot) : requested;
    const extension = dot > 0 ? requested.slice(dot) : "";
    let candidate = requested;
    let suffix = 2;
    while (occupied.has(candidate.toLocaleLowerCase("en-US"))) {
      candidate = `${stem.slice(0, 160)}-${suffix}${extension}`;
      suffix += 1;
    }
    occupied.add(candidate.toLocaleLowerCase("en-US"));
    names.set(result.documentId, candidate);
  }
  return names;
}

export function corpusAppliedDownloadName(
  result: CorpusDocumentResult,
): string {
  return appliedFileName(result.name);
}

export function canExportAppliedCorpus(batch: CorpusBatchResult): boolean {
  return (
    batch.operation === "replace" &&
    batch.status === "complete" &&
    batch.documents.length > 0 &&
    batch.documents.every(
      (result) => result.status === "complete" && result.output !== undefined,
    )
  );
}

interface CorpusReportConfiguration {
  readonly flavour: string;
  readonly flavourVersion?: string;
  readonly pattern: string;
  readonly flags: readonly string[];
}

function captureSummaryForReport(result: CorpusDocumentResult) {
  return result.captureSummaries.map((capture) => ({
    groupNumber: capture.groupNumber,
    ...(capture.groupName === undefined
      ? {}
      : { groupName: capture.groupName }),
    participated: capture.participated,
    didNotParticipate: capture.didNotParticipate,
    matchedEmpty: capture.matchedEmpty,
    truncated: capture.truncated,
    unavailable: capture.unavailable,
    retainedSampleCount: capture.samples.length,
    samplesClipped: capture.samplesClipped,
  }));
}

function documentForReport(result: CorpusDocumentResult) {
  return {
    name: result.name,
    inputBytes: result.inputBytes,
    lineCount: result.lineCount,
    status: result.status,
    matchCount: result.matchCount,
    matchCountIsLowerBound: result.status === "partial",
    elapsedMs: result.elapsedMs,
    ...(result.outputBytes === undefined
      ? {}
      : { outputBytes: result.outputBytes }),
    ...(result.changed === undefined ? {} : { changed: result.changed }),
    ...(result.message === undefined ? {} : { message: result.message }),
    captureSummariesTruncated: result.captureSummariesTruncated,
    captureSummaries: captureSummaryForReport(result),
    diagnostics: result.diagnostics.map((diagnostic) => ({
      severity: diagnostic.severity,
      code: diagnostic.code,
      message: diagnostic.message,
      provenance: diagnostic.provenance,
    })),
  };
}

function reportMetadata(
  batch: CorpusBatchResult,
  configuration: CorpusReportConfiguration,
) {
  return {
    schemaVersion: CORPUS_REPORT_SCHEMA_VERSION,
    application: "de.add-ideas.regex-tools",
    operation: batch.operation,
    subjectMode: batch.subjectMode,
    status: batch.status,
    engine: {
      flavour: configuration.flavour,
      ...(configuration.flavourVersion === undefined
        ? {}
        : { version: configuration.flavourVersion }),
      pattern: configuration.pattern,
      flags: configuration.flags,
      scanAll: true,
    },
    summary: {
      processedDocuments: batch.processedDocuments,
      totalDocuments: batch.totalDocuments,
      totalMatches: batch.totalMatches,
      totalElapsedMs: batch.totalElapsedMs,
      retainedOutputBytes: batch.retainedOutputBytes,
      message: batch.message,
    },
  };
}

function assertReportSize(serialized: string): string {
  if (utf8ByteLength(serialized) > MAXIMUM_CORPUS_REPORT_BYTES) {
    throw new Error("Corpus report exceeds the 4 MiB export limit.");
  }
  return serialized;
}

function csvCell(value: unknown): string {
  const raw =
    typeof value === "string"
      ? value
      : value === undefined
        ? ""
        : JSON.stringify(value);
  const protectedValue = /^[=+\-@]/u.test(raw.trimStart()) ? `'${raw}` : raw;
  return `"${protectedValue.replaceAll('"', '""')}"`;
}

export function serializeCorpusReport(
  batch: CorpusBatchResult,
  configuration: CorpusReportConfiguration,
  format: CorpusReportFormat = "json",
): string {
  const metadata = reportMetadata(batch, configuration);
  const documents = batch.documents.map(documentForReport);
  if (format === "json") {
    return assertReportSize(
      `${JSON.stringify({ ...metadata, documents }, null, 2)}\n`,
    );
  }
  if (format === "ndjson") {
    return assertReportSize(
      [
        JSON.stringify({ type: "metadata", ...metadata }),
        ...documents.map((document) =>
          JSON.stringify({ type: "document", ...document }),
        ),
      ].join("\n") + "\n",
    );
  }

  const headers = [
    "record_type",
    "operation",
    "subject_mode",
    "batch_status",
    "flavour",
    "flavour_version",
    "pattern",
    "flags",
    "name",
    "status",
    "input_bytes",
    "line_count",
    "match_count",
    "match_count_is_lower_bound",
    "output_bytes",
    "changed",
    "elapsed_ms",
    "capture_summaries_truncated",
    "capture_summaries",
    "diagnostics",
    "message",
  ] as const;
  const metadataValues: readonly unknown[] = [
    "metadata",
    batch.operation,
    batch.subjectMode,
    batch.status,
    configuration.flavour,
    configuration.flavourVersion,
    configuration.pattern,
    configuration.flags.join(""),
    "",
    "",
    "",
    "",
    "",
    "",
    "",
    "",
    "",
    "",
    "",
    "",
    "",
  ];
  const rows = documents.map((document) => {
    const values: readonly unknown[] = [
      "document",
      "",
      "",
      "",
      "",
      "",
      "",
      "",
      document.name,
      document.status,
      document.inputBytes,
      document.lineCount,
      document.matchCount,
      document.matchCountIsLowerBound,
      document.outputBytes,
      document.changed,
      document.elapsedMs,
      document.captureSummariesTruncated,
      document.captureSummaries,
      document.diagnostics,
      document.message,
    ];
    return values.map(csvCell).join(",");
  });
  return assertReportSize(
    `${headers.map(csvCell).join(",")}\r\n${metadataValues.map(csvCell).join(",")}\r\n${rows.join("\r\n")}\r\n`,
  );
}

export function createAppliedCorpusZip(batch: CorpusBatchResult): {
  readonly promise: Promise<Uint8Array>;
  readonly cancel: () => void;
} {
  if (!canExportAppliedCorpus(batch)) {
    throw new Error(
      "Applied output can be exported only when every document completed exactly.",
    );
  }
  const names = uniqueAppliedNames(batch.documents);
  const files: Record<string, Uint8Array> = {};
  for (const result of batch.documents) {
    const name = names.get(result.documentId);
    if (!name || result.output === undefined) {
      throw new Error("An applied corpus output is unavailable.");
    }
    files[name] = strToU8(result.output);
  }
  let terminate: (() => void) | undefined;
  const promise = new Promise<Uint8Array>((resolve, reject) => {
    terminate = zip(files, { level: 6 }, (error, data) => {
      if (error) {
        reject(error);
      } else {
        try {
          assertCorpusZipByteLength(data.byteLength);
          resolve(data);
        } catch (sizeError) {
          reject(sizeError as Error);
        }
      }
    });
  });
  return {
    promise,
    cancel: () => terminate?.(),
  };
}
