import { copyBytesToArrayBuffer } from './bytes';
import { projectFilePath, type ProjectFilePath } from './project-path';
import type { ProjectStorageProvider } from './types';

const LOCAL_FILE_HEADER = 0x04034b50;
const CENTRAL_DIRECTORY_HEADER = 0x02014b50;
const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
const UTF8_FLAG = 0x0800;
const STORE_METHOD = 0;
const DEFLATE_METHOD = 8;
const MAX_EOCD_SEARCH = 65_557;

const CRC32_TABLE = (() => {
  const table = new Uint32Array(256);
  for (let i = 0; i < table.length; i += 1) {
    let value = i;
    for (let bit = 0; bit < 8; bit += 1) {
      value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
    }
    table[i] = value >>> 0;
  }
  return table;
})();

export type ProjectArchiveLimits = {
  maxArchiveBytes: number;
  maxEntries: number;
  maxFileBytes: number;
  maxUncompressedBytes: number;
  maxCompressionRatio: number;
};

export const DEFAULT_PROJECT_ARCHIVE_LIMITS: ProjectArchiveLimits = Object.freeze({
  maxArchiveBytes: 500 * 1024 * 1024,
  maxEntries: 5_000,
  maxFileBytes: 250 * 1024 * 1024,
  maxUncompressedBytes: 500 * 1024 * 1024,
  maxCompressionRatio: 200,
});

export type ProjectArchiveFile = {
  path: ProjectFilePath;
  data: Uint8Array;
};

type CentralEntry = {
  path: ProjectFilePath;
  flags: number;
  method: number;
  crc32: number;
  compressedSize: number;
  uncompressedSize: number;
  localHeaderOffset: number;
};

function crc32(bytes: Uint8Array): number {
  let crc = 0xffffffff;
  for (const byte of bytes) {
    const tableIndex = (crc ^ byte) & 0xff;
    crc = (CRC32_TABLE[tableIndex] ?? 0) ^ (crc >>> 8);
  }
  return (crc ^ 0xffffffff) >>> 0;
}

function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
  const size = parts.reduce((total, part) => total + part.byteLength, 0);
  const result = new Uint8Array(size);
  let offset = 0;
  for (const part of parts) {
    result.set(part, offset);
    offset += part.byteLength;
  }
  return result;
}

function viewForSize(size: number): { bytes: Uint8Array; view: DataView } {
  const bytes = new Uint8Array(size);
  return { bytes, view: new DataView(bytes.buffer) };
}

function checkedSlice(bytes: Uint8Array, start: number, length: number): Uint8Array {
  const end = start + length;
  if (start < 0 || length < 0 || end > bytes.byteLength) {
    throw new Error('Invalid ZIP structure: entry points outside archive');
  }
  return bytes.slice(start, end);
}

function assertArchiveSizeWithinLimits(
  compressedSize: number,
  uncompressedSize: number,
  limits: ProjectArchiveLimits,
): void {
  if (uncompressedSize > limits.maxFileBytes) {
    throw new Error(`ZIP entry exceeds maximum file size (${uncompressedSize} bytes)`);
  }
  if (
    compressedSize > 0 &&
    uncompressedSize / compressedSize > limits.maxCompressionRatio
  ) {
    throw new Error('ZIP entry exceeds maximum compression ratio');
  }
}

async function inflateRaw(compressed: Uint8Array): Promise<Uint8Array> {
  if (typeof DecompressionStream === 'undefined') {
    throw new Error('Deflate-compressed ZIP files are not supported in this environment');
  }
  const stream = new Blob([copyBytesToArrayBuffer(compressed)])
    .stream()
    .pipeThrough(new DecompressionStream('deflate-raw'));
  return new Uint8Array(await new Response(stream).arrayBuffer());
}

function findEndOfCentralDirectory(bytes: Uint8Array): number {
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const minimum = Math.max(0, bytes.byteLength - MAX_EOCD_SEARCH);
  for (let offset = bytes.byteLength - 22; offset >= minimum; offset -= 1) {
    if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY) {
      return offset;
    }
  }
  throw new Error('Invalid ZIP archive: end-of-central-directory record not found');
}

function readCentralEntries(
  bytes: Uint8Array,
  limits: ProjectArchiveLimits,
): CentralEntry[] {
  const decoder = new TextDecoder();
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const eocdOffset = findEndOfCentralDirectory(bytes);
  const diskNumber = view.getUint16(eocdOffset + 4, true);
  const centralDiskNumber = view.getUint16(eocdOffset + 6, true);
  const entryCount = view.getUint16(eocdOffset + 10, true);
  const centralDirectorySize = view.getUint32(eocdOffset + 12, true);
  const centralDirectoryOffset = view.getUint32(eocdOffset + 16, true);

  if (diskNumber !== 0 || centralDiskNumber !== 0) {
    throw new Error('Multi-disk ZIP archives are not supported');
  }
  if (entryCount > limits.maxEntries) {
    throw new Error(`ZIP archive exceeds maximum entry count (${entryCount})`);
  }
  if (centralDirectoryOffset + centralDirectorySize > eocdOffset) {
    throw new Error('Invalid ZIP archive: central directory is out of bounds');
  }

  const entries: CentralEntry[] = [];
  const seenPaths = new Set<ProjectFilePath>();
  let totalUncompressed = 0;
  let cursor = centralDirectoryOffset;

  for (let index = 0; index < entryCount; index += 1) {
    if (cursor + 46 > bytes.byteLength) {
      throw new Error('Invalid ZIP archive: truncated central directory');
    }
    if (view.getUint32(cursor, true) !== CENTRAL_DIRECTORY_HEADER) {
      throw new Error('Invalid ZIP archive: central directory signature mismatch');
    }

    const flags = view.getUint16(cursor + 8, true);
    const method = view.getUint16(cursor + 10, true);
    const expectedCrc32 = view.getUint32(cursor + 16, true);
    const compressedSize = view.getUint32(cursor + 20, true);
    const uncompressedSize = view.getUint32(cursor + 24, true);
    const fileNameLength = view.getUint16(cursor + 28, true);
    const extraLength = view.getUint16(cursor + 30, true);
    const commentLength = view.getUint16(cursor + 32, true);
    const localHeaderOffset = view.getUint32(cursor + 42, true);
    const nameStart = cursor + 46;
    const nameBytes = checkedSlice(bytes, nameStart, fileNameLength);
    const rawName = decoder.decode(nameBytes);

    cursor = nameStart + fileNameLength + extraLength + commentLength;

    if (rawName.endsWith('/')) {
      continue;
    }
    if ((flags & 0x0001) !== 0) {
      throw new Error(`Encrypted ZIP entry is not supported: ${rawName}`);
    }
    if (method !== STORE_METHOD && method !== DEFLATE_METHOD) {
      throw new Error(`Unsupported ZIP compression method ${method}: ${rawName}`);
    }

    assertArchiveSizeWithinLimits(compressedSize, uncompressedSize, limits);
    totalUncompressed += uncompressedSize;
    if (totalUncompressed > limits.maxUncompressedBytes) {
      throw new Error('ZIP archive exceeds maximum uncompressed project size');
    }

    // The UTF-8 flag is advisory for our supported project archives. ASCII is
    // valid UTF-8, and modern LaTeX ZIP producers set the flag for non-ASCII
    // names. Treat the bytes as UTF-8 while still retaining the flag in case a
    // future decoder needs to distinguish legacy CP437 names.
    void (flags & UTF8_FLAG);

    const path = projectFilePath(rawName);
    if (seenPaths.has(path)) {
      throw new Error(`ZIP archive contains duplicate project path: ${path}`);
    }
    seenPaths.add(path);

    entries.push({
      path,
      flags,
      method,
      crc32: expectedCrc32,
      compressedSize,
      uncompressedSize,
      localHeaderOffset,
    });
  }

  return entries;
}

async function readCentralEntryData(
  archive: Uint8Array,
  entry: CentralEntry,
): Promise<Uint8Array> {
  const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
  const offset = entry.localHeaderOffset;
  if (offset + 30 > archive.byteLength || view.getUint32(offset, true) !== LOCAL_FILE_HEADER) {
    throw new Error(`Invalid ZIP local header for ${entry.path}`);
  }
  const fileNameLength = view.getUint16(offset + 26, true);
  const extraLength = view.getUint16(offset + 28, true);
  const localName = new TextDecoder().decode(
    checkedSlice(archive, offset + 30, fileNameLength),
  );
  if (localName !== entry.path) {
    throw new Error(`ZIP filename mismatch for ${entry.path}`);
  }
  const dataStart = offset + 30 + fileNameLength + extraLength;
  const compressed = checkedSlice(archive, dataStart, entry.compressedSize);
  const data =
    entry.method === STORE_METHOD ? compressed : await inflateRaw(compressed);

  if (data.byteLength !== entry.uncompressedSize) {
    throw new Error(`ZIP size mismatch for ${entry.path}`);
  }
  if (crc32(data) !== entry.crc32) {
    throw new Error(`ZIP checksum mismatch for ${entry.path}`);
  }
  return data;
}


async function readBlobSlice(archive: Blob, start: number, length: number): Promise<Uint8Array> {
  const end = start + length;
  if (start < 0 || length < 0 || end > archive.size) {
    throw new Error('Invalid ZIP structure: entry points outside archive');
  }
  return new Uint8Array(await archive.slice(start, end).arrayBuffer());
}

async function readCentralEntriesFromBlob(
  archive: Blob,
  limits: ProjectArchiveLimits,
): Promise<CentralEntry[]> {
  if (archive.size > limits.maxArchiveBytes) {
    throw new Error(`ZIP archive exceeds maximum archive size (${archive.size} bytes)`);
  }
  const tailLength = Math.min(archive.size, MAX_EOCD_SEARCH);
  const tailStart = archive.size - tailLength;
  const tail = await readBlobSlice(archive, tailStart, tailLength);
  const relativeEocd = findEndOfCentralDirectory(tail);
  const eocd = new DataView(tail.buffer, tail.byteOffset + relativeEocd, tail.byteLength - relativeEocd);
  const diskNumber = eocd.getUint16(4, true);
  const centralDiskNumber = eocd.getUint16(6, true);
  const entryCount = eocd.getUint16(10, true);
  const centralDirectorySize = eocd.getUint32(12, true);
  const centralDirectoryOffset = eocd.getUint32(16, true);
  const absoluteEocd = tailStart + relativeEocd;

  if (diskNumber !== 0 || centralDiskNumber !== 0) {
    throw new Error('Multi-disk ZIP archives are not supported');
  }
  if (entryCount > limits.maxEntries) {
    throw new Error(`ZIP archive exceeds maximum entry count (${entryCount})`);
  }
  if (centralDirectoryOffset + centralDirectorySize > absoluteEocd) {
    throw new Error('Invalid ZIP archive: central directory is out of bounds');
  }

  const central = await readBlobSlice(archive, centralDirectoryOffset, centralDirectorySize);
  const view = new DataView(central.buffer, central.byteOffset, central.byteLength);
  const decoder = new TextDecoder();
  const entries: CentralEntry[] = [];
  const seenPaths = new Set<ProjectFilePath>();
  let totalUncompressed = 0;
  let cursor = 0;

  for (let index = 0; index < entryCount; index += 1) {
    if (cursor + 46 > central.byteLength || view.getUint32(cursor, true) !== CENTRAL_DIRECTORY_HEADER) {
      throw new Error('Invalid ZIP archive: truncated or invalid central directory');
    }
    const flags = view.getUint16(cursor + 8, true);
    const method = view.getUint16(cursor + 10, true);
    const expectedCrc32 = view.getUint32(cursor + 16, true);
    const compressedSize = view.getUint32(cursor + 20, true);
    const uncompressedSize = view.getUint32(cursor + 24, true);
    const fileNameLength = view.getUint16(cursor + 28, true);
    const extraLength = view.getUint16(cursor + 30, true);
    const commentLength = view.getUint16(cursor + 32, true);
    const localHeaderOffset = view.getUint32(cursor + 42, true);
    const nameStart = cursor + 46;
    const rawName = decoder.decode(checkedSlice(central, nameStart, fileNameLength));
    cursor = nameStart + fileNameLength + extraLength + commentLength;

    if (rawName.endsWith('/')) continue;
    if ((flags & 0x0001) !== 0) throw new Error(`Encrypted ZIP entry is not supported: ${rawName}`);
    if (method !== STORE_METHOD && method !== DEFLATE_METHOD) {
      throw new Error(`Unsupported ZIP compression method ${method}: ${rawName}`);
    }
    assertArchiveSizeWithinLimits(compressedSize, uncompressedSize, limits);
    totalUncompressed += uncompressedSize;
    if (totalUncompressed > limits.maxUncompressedBytes) {
      throw new Error('ZIP archive exceeds maximum uncompressed project size');
    }
    const path = projectFilePath(rawName);
    if (seenPaths.has(path)) throw new Error(`ZIP archive contains duplicate project path: ${path}`);
    seenPaths.add(path);
    entries.push({
      path, flags, method, crc32: expectedCrc32, compressedSize, uncompressedSize, localHeaderOffset,
    });
  }
  return entries;
}

async function readBlobEntryData(archive: Blob, entry: CentralEntry): Promise<Uint8Array> {
  const header = await readBlobSlice(archive, entry.localHeaderOffset, 30);
  const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
  if (view.getUint32(0, true) !== LOCAL_FILE_HEADER) {
    throw new Error(`Invalid ZIP local header for ${entry.path}`);
  }
  const fileNameLength = view.getUint16(26, true);
  const extraLength = view.getUint16(28, true);
  const localName = new TextDecoder().decode(
    await readBlobSlice(archive, entry.localHeaderOffset + 30, fileNameLength),
  );
  if (localName !== entry.path) throw new Error(`ZIP filename mismatch for ${entry.path}`);
  const dataStart = entry.localHeaderOffset + 30 + fileNameLength + extraLength;
  const compressed = await readBlobSlice(archive, dataStart, entry.compressedSize);
  const data = entry.method === STORE_METHOD ? compressed : await inflateRaw(compressed);
  if (data.byteLength !== entry.uncompressedSize) throw new Error(`ZIP size mismatch for ${entry.path}`);
  if (crc32(data) !== entry.crc32) throw new Error(`ZIP checksum mismatch for ${entry.path}`);
  return data;
}

export async function decodeProjectZip(
  archive: Uint8Array,
  limits: ProjectArchiveLimits = DEFAULT_PROJECT_ARCHIVE_LIMITS,
  signal?: AbortSignal,
): Promise<ProjectArchiveFile[]> {
  signal?.throwIfAborted();
  if (archive.byteLength > limits.maxArchiveBytes) {
    throw new Error(
      `ZIP archive exceeds maximum archive size (${archive.byteLength} bytes)`,
    );
  }
  const entries = readCentralEntries(archive, limits);
  const result: ProjectArchiveFile[] = [];
  for (const entry of entries) {
    signal?.throwIfAborted();
    result.push({ path: entry.path, data: await readCentralEntryData(archive, entry) });
  }
  signal?.throwIfAborted();
  return result;
}

export function encodeProjectZip(files: readonly ProjectArchiveFile[]): Uint8Array {
  if (files.length > 0xffff) {
    throw new Error('ZIP archive contains too many files');
  }

  const encoder = new TextEncoder();
  const localParts: Uint8Array[] = [];
  const centralParts: Uint8Array[] = [];
  const seenPaths = new Set<ProjectFilePath>();
  let localOffset = 0;

  for (const file of files) {
    if (seenPaths.has(file.path)) {
      throw new Error(`Cannot create ZIP with duplicate project path: ${file.path}`);
    }
    seenPaths.add(file.path);
    const name = encoder.encode(file.path);
    if (name.byteLength > 0xffff) {
      throw new Error(`Project path is too long for ZIP: ${file.path}`);
    }
    const checksum = crc32(file.data);

    const local = viewForSize(30 + name.byteLength);
    local.view.setUint32(0, LOCAL_FILE_HEADER, true);
    local.view.setUint16(4, 20, true);
    local.view.setUint16(6, UTF8_FLAG, true);
    local.view.setUint16(8, STORE_METHOD, true);
    local.view.setUint32(14, checksum, true);
    local.view.setUint32(18, file.data.byteLength, true);
    local.view.setUint32(22, file.data.byteLength, true);
    local.view.setUint16(26, name.byteLength, true);
    local.bytes.set(name, 30);
    localParts.push(local.bytes, file.data);

    const central = viewForSize(46 + name.byteLength);
    central.view.setUint32(0, CENTRAL_DIRECTORY_HEADER, true);
    central.view.setUint16(4, 20, true);
    central.view.setUint16(6, 20, true);
    central.view.setUint16(8, UTF8_FLAG, true);
    central.view.setUint16(10, STORE_METHOD, true);
    central.view.setUint32(16, checksum, true);
    central.view.setUint32(20, file.data.byteLength, true);
    central.view.setUint32(24, file.data.byteLength, true);
    central.view.setUint16(28, name.byteLength, true);
    central.view.setUint32(42, localOffset, true);
    central.bytes.set(name, 46);
    centralParts.push(central.bytes);

    localOffset += local.bytes.byteLength + file.data.byteLength;
  }

  const centralDirectory = concatBytes(centralParts);
  const end = viewForSize(22);
  end.view.setUint32(0, END_OF_CENTRAL_DIRECTORY, true);
  end.view.setUint16(8, files.length, true);
  end.view.setUint16(10, files.length, true);
  end.view.setUint32(12, centralDirectory.byteLength, true);
  end.view.setUint32(16, localOffset, true);

  return concatBytes([...localParts, centralDirectory, end.bytes]);
}

export type ExportProjectZipOptions = {
  includeGit?: boolean;
  signal?: AbortSignal;
  limits?: ProjectArchiveLimits;
};

export async function exportProjectZipStream(
  storage: ProjectStorageProvider,
  options: ExportProjectZipOptions = {},
): Promise<ReadableStream<Uint8Array>> {
  const entries = (await storage.listEntries(options.signal)).filter(
    (entry) => entry.kind === 'file' && (options.includeGit || !entry.path.startsWith('.git/')),
  );
  const limits = options.limits ?? DEFAULT_PROJECT_ARCHIVE_LIMITS;
  if (entries.length > Math.min(0xffff, limits.maxEntries)) {
    throw new Error('ZIP archive contains too many files');
  }
  const encoder = new TextEncoder();
  let totalUncompressed = 0;
  let expectedArchiveBytes = 22; // End-of-central-directory record.
  for (const entry of entries) {
    if (entry.kind !== 'file') continue;
    assertArchiveSizeWithinLimits(entry.size, entry.size, limits);
    const encodedName = encoder.encode(entry.path);
    if (encodedName.byteLength > 0xffff) {
      throw new Error(`Project path is too long for ZIP: ${entry.path}`);
    }
    totalUncompressed += entry.size;
    expectedArchiveBytes += entry.size + 76 + encodedName.byteLength * 2;
  }
  if (totalUncompressed > limits.maxUncompressedBytes) {
    throw new Error('ZIP archive exceeds maximum uncompressed project size');
  }
  if (expectedArchiveBytes > limits.maxArchiveBytes) {
    throw new Error(`ZIP archive exceeds maximum archive size (${expectedArchiveBytes} bytes)`);
  }

  return new ReadableStream<Uint8Array>({
    async start(controller) {
      try {
        const centralParts: Uint8Array[] = [];
        let localOffset = 0;
        for (const entry of entries) {
          options.signal?.throwIfAborted();
          if (entry.kind !== 'file') continue;
          const data = await storage.readFile(entry.path, options.signal);
          if (!data) throw new Error(`Project file disappeared during ZIP export: ${entry.path}`);
          if (data.byteLength !== entry.size) {
            throw new Error(`Project file changed size during ZIP export; retry the export: ${entry.path}`);
          }
          assertArchiveSizeWithinLimits(data.byteLength, data.byteLength, limits);
          const name = encoder.encode(entry.path);
          if (name.byteLength > 0xffff) throw new Error(`Project path is too long for ZIP: ${entry.path}`);
          const checksum = crc32(data);
          const local = viewForSize(30 + name.byteLength);
          local.view.setUint32(0, LOCAL_FILE_HEADER, true);
          local.view.setUint16(4, 20, true);
          local.view.setUint16(6, UTF8_FLAG, true);
          local.view.setUint16(8, STORE_METHOD, true);
          local.view.setUint32(14, checksum, true);
          local.view.setUint32(18, data.byteLength, true);
          local.view.setUint32(22, data.byteLength, true);
          local.view.setUint16(26, name.byteLength, true);
          local.bytes.set(name, 30);
          controller.enqueue(local.bytes);
          controller.enqueue(data);

          const central = viewForSize(46 + name.byteLength);
          central.view.setUint32(0, CENTRAL_DIRECTORY_HEADER, true);
          central.view.setUint16(4, 20, true);
          central.view.setUint16(6, 20, true);
          central.view.setUint16(8, UTF8_FLAG, true);
          central.view.setUint16(10, STORE_METHOD, true);
          central.view.setUint32(16, checksum, true);
          central.view.setUint32(20, data.byteLength, true);
          central.view.setUint32(24, data.byteLength, true);
          central.view.setUint16(28, name.byteLength, true);
          central.view.setUint32(42, localOffset, true);
          central.bytes.set(name, 46);
          centralParts.push(central.bytes);
          localOffset += local.bytes.byteLength + data.byteLength;
        }

        const centralDirectory = concatBytes(centralParts);
        controller.enqueue(centralDirectory);
        const end = viewForSize(22);
        end.view.setUint32(0, END_OF_CENTRAL_DIRECTORY, true);
        end.view.setUint16(8, entries.length, true);
        end.view.setUint16(10, entries.length, true);
        end.view.setUint32(12, centralDirectory.byteLength, true);
        end.view.setUint32(16, localOffset, true);
        controller.enqueue(end.bytes);
        controller.close();
      } catch (error) {
        controller.error(error);
      }
    },
    cancel() {
      // Storage reads are individually abortable through options.signal.
    },
  });
}

export async function exportProjectZip(
  storage: ProjectStorageProvider,
  options: ExportProjectZipOptions = {},
): Promise<Uint8Array> {
  const stream = await exportProjectZipStream(storage, options);
  return new Uint8Array(await new Response(stream).arrayBuffer());
}

export type ImportProjectZipOptions = {
  limits?: ProjectArchiveLimits;
  signal?: AbortSignal;
};

export async function importProjectZipBlob(
  archive: Blob,
  storage: ProjectStorageProvider,
  options: ImportProjectZipOptions = {},
): Promise<ProjectFilePath[]> {
  options.signal?.throwIfAborted();
  const limits = options.limits ?? DEFAULT_PROJECT_ARCHIVE_LIMITS;
  const entries = await readCentralEntriesFromBlob(archive, limits);
  for (const entry of entries) {
    options.signal?.throwIfAborted();
    if (await storage.readFile(entry.path, options.signal)) {
      throw new Error(`Cannot import ZIP over existing project file: ${entry.path}`);
    }
  }

  const created: ProjectFilePath[] = [];
  try {
    for (const entry of entries) {
      options.signal?.throwIfAborted();
      const data = await readBlobEntryData(archive, entry);
      await storage.writeFile(entry.path, data, options.signal);
      created.push(entry.path);
    }
  } catch (error) {
    for (const path of [...created].reverse()) {
      try { await storage.deleteFile(path); } catch { /* Preserve original failure. */ }
    }
    throw error;
  }
  return created;
}

export async function importProjectZip(
  archive: Uint8Array,
  storage: ProjectStorageProvider,
  options: ImportProjectZipOptions = {},
): Promise<ProjectFilePath[]> {
  const files = await decodeProjectZip(
    archive,
    options.limits ?? DEFAULT_PROJECT_ARCHIVE_LIMITS,
    options.signal,
  );

  for (const file of files) {
    options.signal?.throwIfAborted();
    if (await storage.readFile(file.path, options.signal)) {
      throw new Error(`Cannot import ZIP over existing project file: ${file.path}`);
    }
  }

  const created: ProjectFilePath[] = [];
  try {
    for (const file of files) {
      options.signal?.throwIfAborted();
      await storage.writeFile(file.path, file.data, options.signal);
      created.push(file.path);
    }
  } catch (error) {
    for (const path of [...created].reverse()) {
      try {
        await storage.deleteFile(path);
      } catch {
        // Preserve the original import failure. A storage implementation that
        // also fails rollback remains in a recoverable partial-import state.
      }
    }
    throw error;
  }
  return created;
}
