import {
  zip,
  type AsyncTerminable,
  type AsyncZippable,
  type AsyncZipOptions,
} from 'fflate';
import { ZIP_MIME_TYPE } from './mime';
import {
  isSafeArchiveLeafName,
  safeExportFileName,
  uniqueExportFileNames,
} from './output-name';
import type { ExportResult } from './result-collection';
import { MAX_RESULT_ZIP_ENTRIES } from '../limits';

const MEBIBYTE = 1024 * 1024;

export const DEFAULT_RESULT_ZIP_LIMITS = Object.freeze({
  maxEntries: MAX_RESULT_ZIP_ENTRIES,
  maxEntryBytes: 512 * MEBIBYTE,
  maxTotalInputBytes: 512 * MEBIBYTE,
  maxArchiveBytes: 550 * MEBIBYTE,
});

export interface ResultZipLimits {
  readonly maxEntries: number;
  readonly maxEntryBytes: number;
  readonly maxTotalInputBytes: number;
  readonly maxArchiveBytes: number;
}

export interface ResultZipEntry {
  readonly fileName: string;
  readonly blob: Blob;
}

export interface CreateResultZipOptions {
  readonly fileName?: string;
  readonly limits?: Partial<ResultZipLimits>;
  readonly signal?: AbortSignal;
  readonly compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
}

export interface ResultZip {
  readonly blob: Blob;
  readonly fileName: string;
  readonly entryNames: readonly string[];
  readonly totalInputBytes: number;
  readonly archiveBytes: number;
}

export type ResultZipLimit =
  'entry-count' | 'entry-bytes' | 'total-input-bytes' | 'archive-bytes';

export class ResultZipLimitError extends RangeError {
  readonly limit: ResultZipLimit;
  readonly actual: number;
  readonly maximum: number;

  constructor(limit: ResultZipLimit, actual: number, maximum: number) {
    super(`ZIP limit exceeded: ${limit} is ${actual}, max ${maximum}.`);
    this.name = 'ResultZipLimitError';
    this.limit = limit;
    this.actual = actual;
    this.maximum = maximum;
  }
}

/**
 * Creates an archive with sorted, safe paths, fixed local DOS timestamps,
 * normalized attributes and a fixed compression level. fflate's asynchronous
 * API moves compression away from the browser rendering path.
 */
export async function createResultZip(
  entries: readonly ResultZipEntry[],
  options: CreateResultZipOptions = {}
): Promise<ResultZip> {
  const limits = resolveZipLimits(options.limits);
  throwIfAborted(options.signal);
  if (entries.length < 1) {
    throw new RangeError('At least one result is required to create a ZIP.');
  }
  if (entries.length > limits.maxEntries) {
    throw new ResultZipLimitError(
      'entry-count',
      entries.length,
      limits.maxEntries
    );
  }

  const names = uniqueExportFileNames(
    entries.map((entry) => entry.fileName),
    'result'
  );
  const normalized = entries
    .map((entry, index) => ({
      blob: entry.blob,
      fileName: names[index] ?? `result-${index + 1}`,
    }))
    .sort((left, right) => comparePaths(left.fileName, right.fileName));

  let totalInputBytes = 0;
  for (const entry of normalized) {
    if (!isSafeArchiveLeafName(entry.fileName)) {
      throw new RangeError(`Unsafe ZIP entry name: "${entry.fileName}".`);
    }
    if (entry.blob.size > limits.maxEntryBytes) {
      throw new ResultZipLimitError(
        'entry-bytes',
        entry.blob.size,
        limits.maxEntryBytes
      );
    }
    totalInputBytes += entry.blob.size;
    if (
      !Number.isSafeInteger(totalInputBytes) ||
      totalInputBytes > limits.maxTotalInputBytes
    ) {
      throw new ResultZipLimitError(
        'total-input-bytes',
        totalInputBytes,
        limits.maxTotalInputBytes
      );
    }
  }

  const zippable: AsyncZippable = {};
  for (const entry of normalized) {
    throwIfAborted(options.signal);
    const bytes = new Uint8Array(await entry.blob.arrayBuffer());
    zippable[entry.fileName] = [
      bytes,
      {
        attrs: 0o644 << 16,
        mtime: fixedZipTimestamp(),
        os: 3,
      },
    ];
  }

  const bytes = await compressZip(zippable, {
    attrs: 0o644 << 16,
    level: options.compressionLevel ?? 6,
    mtime: fixedZipTimestamp(),
    os: 3,
    signal: options.signal,
  });
  if (bytes.byteLength > limits.maxArchiveBytes) {
    throw new ResultZipLimitError(
      'archive-bytes',
      bytes.byteLength,
      limits.maxArchiveBytes
    );
  }

  const blob = new Blob([Uint8Array.from(bytes)], {
    type: ZIP_MIME_TYPE,
  });
  return Object.freeze({
    blob,
    fileName: safeExportFileName(options.fileName ?? 'av-tools-results.zip', {
      extension: 'zip',
      fallback: 'av-tools-results',
    }),
    entryNames: Object.freeze(normalized.map((entry) => entry.fileName)),
    totalInputBytes,
    archiveBytes: blob.size,
  });
}

export function resultEntries(
  results: readonly ExportResult[]
): readonly ResultZipEntry[] {
  return Object.freeze(
    results.map((result) =>
      Object.freeze({
        fileName: result.fileName,
        blob: result.blob,
      })
    )
  );
}

interface CompressionOptions extends AsyncZipOptions {
  readonly signal?: AbortSignal;
}

function compressZip(
  zippable: AsyncZippable,
  options: CompressionOptions
): Promise<Uint8Array> {
  throwIfAborted(options.signal);
  return new Promise((resolve, reject) => {
    let settled = false;
    let terminate: AsyncTerminable = () => undefined;
    const onAbort = () => {
      if (settled) {
        return;
      }
      settled = true;
      terminate();
      reject(abortError());
    };
    options.signal?.addEventListener('abort', onAbort, { once: true });

    const zipOptions: AsyncZipOptions = {
      ...(options.attrs === undefined ? {} : { attrs: options.attrs }),
      ...(options.level === undefined ? {} : { level: options.level }),
      ...(options.mtime === undefined ? {} : { mtime: options.mtime }),
      ...(options.os === undefined ? {} : { os: options.os }),
    };
    terminate = zip(zippable, zipOptions, (error, bytes) => {
      if (settled) {
        return;
      }
      settled = true;
      options.signal?.removeEventListener('abort', onAbort);
      if (error) {
        reject(error);
        return;
      }
      resolve(bytes);
    });

    if (options.signal?.aborted) {
      onAbort();
    }
  });
}

function resolveZipLimits(
  overrides: Partial<ResultZipLimits> | undefined
): ResultZipLimits {
  const limits = {
    ...DEFAULT_RESULT_ZIP_LIMITS,
    ...overrides,
  };
  for (const [name, value] of Object.entries(limits)) {
    if (!Number.isSafeInteger(value) || value < 1) {
      throw new RangeError(`${name} must be a positive safe integer.`);
    }
  }
  if (limits.maxEntryBytes > limits.maxTotalInputBytes) {
    throw new RangeError('maxEntryBytes cannot exceed maxTotalInputBytes.');
  }
  return Object.freeze(limits);
}

function fixedZipTimestamp(): Date {
  // fflate writes DOS local-time fields. Constructing the Date in local time
  // makes those encoded fields identical across time zones.
  return new Date(1980, 0, 1, 0, 0, 0, 0);
}

function comparePaths(left: string, right: string): number {
  return left < right ? -1 : left > right ? 1 : 0;
}

function throwIfAborted(signal: AbortSignal | undefined): void {
  if (signal?.aborted) {
    throw abortError();
  }
}

function abortError(): DOMException {
  return new DOMException('ZIP creation was cancelled.', 'AbortError');
}
