import { sha256Blob } from './sha256.js';

export const IMPORT_SCHEMA = 2;
export const IMPORT_ROOT = 'q4-retail';
export const RUNTIME_ROOT = 'q4-runtime';
export const RUNTIME_MANIFEST_URL = '/content/baseoq4/manifest.json';
export const SERVER_RETAIL_MANIFEST_URL = '/retail/manifest.json';
export const IMPORT_CHUNK_SIZE = 8 * 1024 * 1024;
export const IMPORT_SAFETY_BYTES = 128 * 1024 * 1024;

export const REQUIRED_RETAIL_FILES = Object.freeze([
  ...Array.from({ length: 22 }, (_, index) => `pak${String(index + 1).padStart(3, '0')}.pk4`)
]);

const LANGUAGE_PACK_PATTERN = /^zpak_[a-z0-9_-]+\.pk4$/i;
const SERVER_RETAIL_POLICY = 'operator-authorized-retail-v1';
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const FNV_OFFSET = 0xcbf29ce484222325n;
const FNV_PRIME = 0x100000001b3n;
const FNV_MASK = 0xffffffffffffffffn;

export class RetailImportError extends Error {
  constructor(code, message, details = {}) {
    super(message);
    this.name = 'RetailImportError';
    this.code = code;
    this.details = details;
  }
}

function normalizedRelativePath(file) {
  return String(file.webkitRelativePath || file.name || '').replaceAll('\\', '/').replace(/^\/+/, '');
}

function selectedName(file) {
  const path = normalizedRelativePath(file);
  const parts = path.split('/').filter(Boolean);
  const q4baseIndex = parts.findIndex((part) => part.toLowerCase() === 'q4base');
  if (q4baseIndex >= 0) {
    const withinBase = parts.slice(q4baseIndex + 1);
    return withinBase.length === 1 ? withinBase[0] : '';
  }
  return parts.length === 1 ? parts[0] : parts.at(-1) || '';
}

export function normalizeRetailSelection(input) {
  const files = [];
  const rejected = [];
  const duplicates = [];
  const byName = new Map();

  for (const file of Array.from(input || [])) {
    const name = selectedName(file);
    if (!name || !/\.pk4$/i.test(name) || typeof file.slice !== 'function') {
      rejected.push(file);
      continue;
    }

    const key = name.toLowerCase();
    if (byName.has(key)) {
      duplicates.push({ name, first: byName.get(key), duplicate: file });
      continue;
    }

    const entry = {
      file,
      name,
      key,
      size: Number(file.size) || 0,
      lastModified: Number(file.lastModified) || 0
    };
    byName.set(key, entry);
    files.push(entry);
  }

  files.sort((left, right) => left.key.localeCompare(right.key, 'en'));
  return { files, rejected, duplicates };
}

export function validateRetailFiles(input) {
  const normalized = input && Array.isArray(input.files) ? input : normalizeRetailSelection(input);
  const names = new Set(normalized.files.map((entry) => entry.key));
  const missing = REQUIRED_RETAIL_FILES.filter((name) => !names.has(name));
  const invalid = normalized.files.filter((entry) => entry.size <= 0).map((entry) => entry.name);
  const hasLanguagePack = normalized.files.some((entry) => LANGUAGE_PACK_PATTERN.test(entry.name));
  if (!hasLanguagePack) missing.push('zpak_<język>.pk4');

  return {
    ...normalized,
    missing,
    invalid,
    hasLanguagePack,
    totalBytes: normalized.files.reduce((sum, entry) => sum + entry.size, 0),
    ok: missing.length === 0 && normalized.duplicates.length === 0 && invalid.length === 0
  };
}

export function validateServerRetailManifest(manifest) {
  if (manifest?.schema !== 1 || manifest?.policy !== SERVER_RETAIL_POLICY ||
      !Array.isArray(manifest.files) || !SHA256_PATTERN.test(String(manifest.fingerprint || ''))) {
    throw new RetailImportError('INVALID_SERVER_RETAIL_MANIFEST', 'Manifest serwerowych danych gry ma nieprawidłowy format.');
  }
  const expected = new Set([...REQUIRED_RETAIL_FILES, 'zpak_english.pk4']);
  const names = new Set();
  let totalBytes = 0;
  let invalid = manifest.files.length !== expected.size;
  const files = manifest.files.map((raw) => {
    const name = String(raw?.name || '').toLowerCase();
    const size = Number(raw?.size);
    const lastModified = Number(raw?.lastModified);
    const sha256 = String(raw?.sha256 || '').toLowerCase();
    const url = String(raw?.url || '');
    if (!expected.has(name) || names.has(name) || !Number.isSafeInteger(size) || size <= 0 ||
        !Number.isSafeInteger(lastModified) || lastModified < 0 || !SHA256_PATTERN.test(sha256) ||
        url !== `/retail/q4base/${sha256}-${encodeURIComponent(name)}`) {
      invalid = true;
    }
    names.add(name);
    totalBytes += Number.isSafeInteger(size) ? size : 0;
    return { name, key: name, size, lastModified, sha256, url };
  }).sort((left, right) => left.name.localeCompare(right.name, 'en'));

  if ([...expected].some((name) => !names.has(name)) || manifest.totalBytes !== totalBytes ||
      manifest.revision !== `q4retail-${manifest.fingerprint.slice(0, 20)}`) {
    invalid = true;
  }
  if (invalid) {
    throw new RetailImportError(
      'INVALID_SERVER_RETAIL_MANIFEST',
      'Manifest serwerowych danych gry nie opisuje zatwierdzonego angielskiego zestawu q4base.'
    );
  }
  return {
    schema: 1,
    policy: SERVER_RETAIL_POLICY,
    revision: manifest.revision,
    fingerprint: manifest.fingerprint,
    totalBytes,
    files
  };
}

function serverRetailFingerprintPayload(files) {
  return [...files]
    .sort((left, right) => left.name.localeCompare(right.name, 'en'))
    .map((file) => `${file.name}\0${file.size}\0${file.sha256}\n`)
    .join('');
}

export async function verifyServerRetailManifest(manifest, { signal } = {}) {
  const catalog = validateServerRetailManifest(manifest);
  const fingerprint = await sha256Blob(new Blob([serverRetailFingerprintPayload(catalog.files)]), { signal });
  if (fingerprint !== catalog.fingerprint) {
    throw new RetailImportError(
      'INVALID_SERVER_RETAIL_MANIFEST',
      'Odcisk manifestu serwerowych danych gry jest nieprawidłowy.'
    );
  }
  return catalog;
}

function updateHash(hash, bytes) {
  let value = hash;
  for (let index = 0; index < bytes.length; index += 1) {
    value ^= BigInt(bytes[index]);
    value = (value * FNV_PRIME) & FNV_MASK;
  }
  return value;
}

function hashText(value, seed = FNV_OFFSET) {
  return updateHash(seed, new TextEncoder().encode(value));
}

function hashHex(value) {
  return value.toString(16).padStart(16, '0');
}

export function makeRevisionId(input) {
  const normalized = input && Array.isArray(input.files) ? input : normalizeRetailSelection(input);
  let hash = FNV_OFFSET;
  let totalBytes = 0;
  for (const entry of normalized.files) {
    totalBytes += entry.size;
    hash = hashText(`${entry.key}\0${entry.size}\0${entry.lastModified}\0`, hash);
  }
  return `r2-${hashHex(hash)}-${normalized.files.length}-${totalBytes.toString(36)}`;
}

function fileStorageKey(name) {
  return name.toLowerCase().replace(/[^a-z0-9._-]/g, '_').slice(0, 120);
}

function isNotFound(error) {
  return error?.name === 'NotFoundError' || error?.code === 'ENOENT';
}

function abortError() {
  if (typeof DOMException === 'function') return new DOMException('Import anulowany', 'AbortError');
  const error = new Error('Import anulowany');
  error.name = 'AbortError';
  return error;
}

function throwIfAborted(signal) {
  if (signal?.aborted) throw signal.reason || abortError();
}

async function readJson(directory, name) {
  try {
    const handle = await directory.getFileHandle(name);
    const file = await handle.getFile();
    return JSON.parse(await file.text());
  } catch (error) {
    if (isNotFound(error) || error instanceof SyntaxError) return null;
    throw error;
  }
}

async function writeJson(directory, name, value) {
  const handle = await directory.getFileHandle(name, { create: true });
  const writable = await handle.createWritable();
  try {
    await writable.write(`${JSON.stringify(value, null, 2)}\n`);
    await writable.close();
  } catch (error) {
    if (typeof writable.abort === 'function') await writable.abort().catch(() => {});
    throw error;
  }
}

async function getDirectory(parent, name, create = false) {
  try {
    return await parent.getDirectoryHandle(name, { create });
  } catch (error) {
    if (!create && isNotFound(error)) return null;
    throw error;
  }
}

function completedBytes(manifest) {
  return manifest.files.reduce((total, file) => (
    total + file.chunks.reduce((fileTotal, chunk) => fileTotal + (chunk?.size || 0), 0)
  ), 0);
}

function publicManifest(manifest) {
  if (!manifest) return null;
  return typeof structuredClone === 'function'
    ? structuredClone(manifest)
    : JSON.parse(JSON.stringify(manifest));
}

async function existingFileSize(fileHandle) {
  try {
    return (await fileHandle.getFile()).size;
  } catch (error) {
    if (isNotFound(error)) return 0;
    throw error;
  }
}

async function verifyRuntimeFile(handle, expected, signal) {
  throwIfAborted(signal);
  const file = await handle.getFile();
  if (file.size !== expected.size) return false;
  return await sha256Blob(file, { signal }) === expected.sha256;
}

async function writeChunk(sourceFile, start, end, writable, signal) {
  const reader = sourceFile.slice(start, end).stream().getReader();
  let bytesWritten = 0;
  let hash = FNV_OFFSET;

  try {
    while (true) {
      throwIfAborted(signal);
      const { done, value } = await reader.read();
      if (done) break;
      const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
      await writable.write(bytes);
      bytesWritten += bytes.byteLength;
      hash = updateHash(hash, bytes);
    }
    throwIfAborted(signal);
    return { size: bytesWritten, hash: hashHex(hash) };
  } catch (error) {
    await reader.cancel(error).catch(() => {});
    throw error;
  }
}

function createManifest(revision, selection, previous, now) {
  const previousFiles = new Map((previous?.files || []).map((file) => [file.name.toLowerCase(), file]));
  const files = selection.files.map((entry, index) => {
    const prior = previousFiles.get(entry.key);
    const reusable = prior && prior.size === entry.size && prior.lastModified === entry.lastModified;
    return {
      name: entry.name,
      storageKey: reusable ? prior.storageKey : fileStorageKey(entry.name),
      size: entry.size,
      lastModified: entry.lastModified,
      chunks: reusable ? prior.chunks || [] : []
    };
  });

  return {
    schema: IMPORT_SCHEMA,
    revision,
    state: 'importing',
    createdAt: previous?.createdAt || now,
    updatedAt: now,
    chunkSize: IMPORT_CHUNK_SIZE,
    totalBytes: selection.totalBytes,
    completedBytes: 0,
    contentFingerprint: previous?.contentFingerprint || null,
    error: null,
    files
  };
}

async function runtimeQ4Base(revisionDirectory, create) {
  const runtime = await getDirectory(revisionDirectory, 'runtime', create);
  if (!runtime) return null;
  return getDirectory(runtime, 'q4base', create);
}

function runtimeStorageKey(build) {
  const key = String(build || '').toLowerCase().replace(/[^a-z0-9._-]/g, '_').slice(0, 120);
  if (!key) throw new RetailImportError('INVALID_RUNTIME_MANIFEST', 'Manifest nie zawiera identyfikatora kompilacji OpenQ4.');
  return key;
}

function validateRuntimeManifest(manifest) {
  const required = new Map([
    ['mod.json', 'metadata'],
    ['pak0.pk4', 'runtime-pack'],
    ['pak1.pk4', 'runtime-pack']
  ]);
  const addonPattern = /^q4web_[a-z0-9_-]{1,80}-[a-f0-9]{12}\.pk4$/;
  if (manifest?.schema !== 2 || manifest?.policy !== 'free-only-v1' ||
      !Array.isArray(manifest.files) || !Array.isArray(manifest.revokedBuilds)) {
    throw new RetailImportError('INVALID_RUNTIME_MANIFEST', 'Manifest danych OpenQ4 ma nieprawidłowy format.');
  }
  const files = manifest.files.map((entry) => ({
    name: String(entry?.name || '').toLowerCase(),
    size: Number(entry?.size),
    sha256: String(entry?.sha256 || '').toLowerCase(),
    url: String(entry?.url || ''),
    kind: String(entry?.kind || ''),
    license: String(entry?.license || ''),
    source: String(entry?.source || '')
  }));
  const names = new Set();
  let addonCount = 0;
  let invalid = files.length < required.size + 1 || files.length > 128;
  for (const entry of files) {
    const core = required.get(entry.name) === entry.kind;
    const addon = addonPattern.test(entry.name) && entry.kind === 'addon';
    if (addon) addonCount += 1;
    if (names.has(entry.name) || (!core && !addon) ||
        !Number.isSafeInteger(entry.size) || entry.size <= 0 ||
        !/^[a-f0-9]{64}$/.test(entry.sha256) || entry.url !== `/content/baseoq4/${entry.name}` ||
        entry.license !== 'CC0-1.0' || entry.source !== 'self-authored') {
      invalid = true;
    }
    names.add(entry.name);
  }
  const revokedBuilds = manifest.revokedBuilds.map((entry) => String(entry || '').toLowerCase());
  if (new Set(revokedBuilds).size !== revokedBuilds.length ||
      revokedBuilds.some((entry) => !/^[a-z0-9._-]{1,120}$/.test(entry))) {
    invalid = true;
  }
  const build = runtimeStorageKey(manifest.build);
  if (revokedBuilds.includes(build)) invalid = true;
  if (invalid || addonCount < 1 || [...required.keys()].some((name) => !names.has(name))) {
    throw new RetailImportError('INVALID_RUNTIME_MANIFEST', 'Manifest danych OpenQ4 nie opisuje oczekiwanych plików runtime.');
  }
  return { ...manifest, build, revokedBuilds, files };
}

async function copyResponseToFile(response, handle, offset, expectedSize, signal, onBytes) {
  if (!response.ok || (offset > 0 && response.status !== 206) || !response.body) {
    throw new RetailImportError('RUNTIME_DOWNLOAD_FAILED', `Serwer zwrócił HTTP ${response.status} podczas pobierania danych OpenQ4.`);
  }
  const writable = await handle.createWritable({ keepExistingData: offset > 0 });
  const reader = response.body.getReader();
  let position = offset;
  try {
    if (typeof writable.truncate === 'function') await writable.truncate(offset);
    if (typeof writable.seek === 'function') await writable.seek(offset);
    else if (offset > 0) throw new RetailImportError('OPFS_SEEK_UNSUPPORTED', 'Przeglądarka nie obsługuje wznawiania zapisu plików OPFS.');
    while (true) {
      throwIfAborted(signal);
      const { done, value } = await reader.read();
      if (done) break;
      const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
      if (position + bytes.byteLength > expectedSize) {
        throw new RetailImportError('RUNTIME_SIZE_MISMATCH', 'Pobrany plik OpenQ4 jest większy niż wskazuje manifest.');
      }
      await writable.write(bytes);
      position += bytes.byteLength;
      onBytes(position);
    }
    if (position !== expectedSize) {
      throw new RetailImportError('RUNTIME_SIZE_MISMATCH', 'Pobrany plik OpenQ4 jest niekompletny.');
    }
    if (typeof writable.truncate === 'function') await writable.truncate(expectedSize);
    await writable.close();
  } catch (error) {
    await reader.cancel(error).catch(() => {});
    // Commit the complete response chunks already received so Range can resume.
    await writable.close().catch(() => {});
    throw error;
  }
}

async function validateRetailRangeResponse(response, file, start, end) {
  const expectedRange = `bytes ${start}-${end}/${file.size}`;
  const expectedEtag = `"${file.sha256}"`;
  if (response.status === 206 && response.headers?.get('content-range') === expectedRange &&
      response.headers?.get('etag') === expectedEtag && response.body) {
    return;
  }
  await response.body?.cancel().catch(() => {});
  throw new RetailImportError(
    'SERVER_RETAIL_RANGE_MISMATCH',
    `Serwer zwrócił nieprawidłowy zakres pakietu ${file.name}.`,
    {
      status: response.status,
      expectedRange,
      contentRange: response.headers?.get('content-range') || null,
      expectedEtag,
      etag: response.headers?.get('etag') || null,
    }
  );
}

function completeChunkRecords(size, chunkSize = IMPORT_CHUNK_SIZE) {
  const chunks = [];
  for (let start = 0, index = 0; start < size; start += chunkSize, index += 1) {
    chunks.push({ index, size: Math.min(chunkSize, size - start) });
  }
  return chunks;
}

async function resetFile(handle) {
  const writable = await handle.createWritable();
  try {
    if (typeof writable.truncate === 'function') await writable.truncate(0);
    await writable.close();
  } catch (error) {
    await writable.abort?.().catch(() => {});
    throw error;
  }
}

function durableChunkCount(file, size) {
  let offset = 0;
  let count = 0;
  for (const chunk of file.chunks || []) {
    if (!chunk || chunk.index !== count || chunk.size <= 0 || offset + chunk.size > size) break;
    offset += chunk.size;
    count += 1;
  }
  return { count, bytes: offset };
}

function defaultIndex() {
  return { schema: IMPORT_SCHEMA, activeRevision: null, revisions: [] };
}

export function estimateRequiredBytes(selection, manifest = null) {
  const total = selection.totalBytes ?? selection.files.reduce((sum, entry) => sum + entry.size, 0);
  const durable = manifest ? completedBytes(manifest) : 0;
  const remaining = Math.max(0, total - durable);
  return {
    remaining,
    safety: remaining === 0 ? 0 : Math.max(IMPORT_SAFETY_BYTES, Math.ceil(remaining * 0.05)),
    get required() {
      return this.remaining + this.safety;
    }
  };
}

export function formatBytes(value) {
  if (!Number.isFinite(value)) return '—';
  if (value < 1024) return `${value} B`;
  const units = ['KiB', 'MiB', 'GiB', 'TiB'];
  let amount = value;
  let unit = -1;
  do {
    amount /= 1024;
    unit += 1;
  } while (amount >= 1024 && unit < units.length - 1);
  const digits = amount >= 100 ? 0 : amount >= 10 ? 1 : 2;
  return `${amount.toFixed(digits)} ${units[unit]}`;
}

export class RetailImporter {
  constructor({
    storage = globalThis.navigator?.storage,
    fetchImpl = globalThis.fetch?.bind(globalThis),
    now = () => new Date().toISOString()
  } = {}) {
    this.storage = storage;
    this.fetchImpl = fetchImpl;
    this.now = now;
    this.serverRetailPromise = null;
  }

  get supported() {
    return Boolean(this.storage?.getDirectory);
  }

  async storageRoot() {
    if (!this.supported) {
      throw new RetailImportError('OPFS_UNSUPPORTED', 'Ta przeglądarka nie udostępnia pamięci OPFS.');
    }
    return this.storage.getDirectory();
  }

  async dataRoot(create = true) {
    const root = await this.storageRoot();
    return getDirectory(root, IMPORT_ROOT, create);
  }

  async readIndex(create = true) {
    const root = await this.dataRoot(create);
    if (!root) return defaultIndex();
    const index = await readJson(root, 'manifest.json');
    return index?.schema === IMPORT_SCHEMA ? index : defaultIndex();
  }

  async writeIndex(root, index) {
    await writeJson(root, 'manifest.json', index);
  }

  async revisionDirectory(revision, create = false) {
    if (!revision) return null;
    const root = await this.dataRoot(create);
    if (!root) return null;
    const revisions = await getDirectory(root, 'revisions', create);
    if (!revisions) return null;
    return getDirectory(revisions, revision, create);
  }

  async getRevisionManifest(revision) {
    const directory = await this.revisionDirectory(revision, false);
    if (!directory) return null;
    return readJson(directory, 'manifest.json');
  }

  async getActiveImport() {
    const index = await this.readIndex(false);
    if (!index.activeRevision) return null;
    const manifest = await this.getRevisionManifest(index.activeRevision);
    return manifest?.state === 'ready' ? manifest : null;
  }

  async listImports() {
    const index = await this.readIndex(false);
    const manifests = [];
    for (const revision of index.revisions || []) {
      const manifest = await this.getRevisionManifest(revision);
      if (manifest) manifests.push(manifest);
    }
    manifests.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
    return { activeRevision: index.activeRevision, manifests };
  }

  async activate(revision) {
    const manifest = await this.getRevisionManifest(revision);
    if (!manifest || manifest.state !== 'ready') {
      throw new RetailImportError('REVISION_NOT_READY', 'Wybrana rewizja nie jest kompletna.', { revision });
    }
    const root = await this.dataRoot(true);
    const index = await this.readIndex(true);
    index.activeRevision = revision;
    if (!index.revisions.includes(revision)) index.revisions.push(revision);
    await this.writeIndex(root, index);
    return manifest;
  }

  async remove(revision) {
    const root = await this.dataRoot(false);
    if (!root) return false;
    const revisions = await getDirectory(root, 'revisions', false);
    if (revisions) {
      try {
        await revisions.removeEntry(revision, { recursive: true });
      } catch (error) {
        if (!isNotFound(error)) throw error;
      }
    }

    const index = await this.readIndex(true);
    index.revisions = index.revisions.filter((entry) => entry !== revision);
    if (index.activeRevision === revision) index.activeRevision = null;
    await this.writeIndex(root, index);
    return true;
  }

  async quota(selection, manifest = null) {
    const estimate = estimateRequiredBytes(selection, manifest);
    let usage = 0;
    let quota = Number.POSITIVE_INFINITY;
    if (typeof this.storage?.estimate === 'function') {
      const reported = await this.storage.estimate();
      usage = Number(reported.usage) || 0;
      quota = Number.isFinite(Number(reported.quota)) ? Number(reported.quota) : quota;
    }
    const available = Math.max(0, quota - usage);
    return { ...estimate, usage, quota, available, ok: available >= estimate.required };
  }

  async ensureServerRetail(options = {}) {
    if (this.serverRetailPromise) return this.serverRetailPromise;
    this.serverRetailPromise = this.installServerRetail(options);
    try {
      return await this.serverRetailPromise;
    } finally {
      this.serverRetailPromise = null;
    }
  }

  async serverRetailCatalog({ signal } = {}) {
    if (typeof this.fetchImpl !== 'function') {
      throw new RetailImportError('SERVER_RETAIL_UNAVAILABLE', 'Przeglądarka nie może pobrać danych Quake 4.');
    }
    throwIfAborted(signal);
    const response = await this.fetchImpl(SERVER_RETAIL_MANIFEST_URL, { cache: 'no-store', signal });
    if (!response.ok) {
      throw new RetailImportError(
        'SERVER_RETAIL_MANIFEST_MISSING',
        `Nie udało się pobrać manifestu danych Quake 4 (HTTP ${response.status}).`
      );
    }
    return verifyServerRetailManifest(await response.json(), { signal });
  }

  async installServerRetail({ signal, onProgress = () => {} } = {}) {
    const catalog = await this.serverRetailCatalog({ signal });
    if (typeof this.storage?.persist === 'function') await this.storage.persist().catch(() => false);

    const root = await this.dataRoot(true);
    const index = await this.readIndex(true);
    const revisionDirectory = await this.revisionDirectory(catalog.revision, true);
    const previous = await readJson(revisionDirectory, 'manifest.json');
    const previousFiles = new Map((previous?.files || []).map((file) => [file.name.toLowerCase(), file]));
    const reusable = previous?.state === 'ready' && previous?.source === 'operator-server' &&
      previous?.serverFingerprint === catalog.fingerprint && previousFiles.size === catalog.files.length &&
      catalog.files.every((file) => {
        const prior = previousFiles.get(file.name);
        return prior?.size === file.size && prior?.verifiedSha256 === file.sha256 &&
          prior?.storageKey === fileStorageKey(file.name);
      });
    if (reusable) {
      const existingQ4Base = await runtimeQ4Base(revisionDirectory, false);
      let complete = Boolean(existingQ4Base);
      if (complete) {
        for (const file of catalog.files) {
          try {
            const handle = await existingQ4Base.getFileHandle(fileStorageKey(file.name));
            if (await existingFileSize(handle) !== file.size) complete = false;
          } catch (_error) {
            complete = false;
          }
          if (!complete) break;
        }
      }
      if (complete) {
        if (index.activeRevision !== catalog.revision || !index.revisions.includes(catalog.revision)) {
          index.activeRevision = catalog.revision;
          if (!index.revisions.includes(catalog.revision)) index.revisions.push(catalog.revision);
          await this.writeIndex(root, index);
        }
        onProgress({
          state: 'ready',
          revision: catalog.revision,
          completedBytes: catalog.totalBytes,
          totalBytes: catalog.totalBytes,
        });
        return publicManifest(previous);
      }
    }

    const q4base = await runtimeQ4Base(revisionDirectory, true);
    const resumableFiles = new Map((previous?.serverFingerprint === catalog.fingerprint ? previous.files : [])
      .map((file) => [file.name.toLowerCase(), file]));
    const manifest = {
      schema: IMPORT_SCHEMA,
      revision: catalog.revision,
      state: 'importing',
      source: 'operator-server',
      serverFingerprint: catalog.fingerprint,
      createdAt: previous?.createdAt || this.now(),
      updatedAt: this.now(),
      chunkSize: IMPORT_CHUNK_SIZE,
      totalBytes: catalog.totalBytes,
      completedBytes: 0,
      contentFingerprint: catalog.fingerprint,
      error: null,
      files: catalog.files.map((file) => {
        const prior = resumableFiles.get(file.name);
        return {
          ...file,
          storageKey: fileStorageKey(file.name),
          chunks: prior?.verifiedSha256 === file.sha256 ? prior.chunks || [] : [],
          verifiedSha256: prior?.verifiedSha256 === file.sha256 ? file.sha256 : null,
        };
      })
    };

    let completed = 0;
    const sizes = new Map();
    for (const file of manifest.files) {
      const handle = await q4base.getFileHandle(file.storageKey, { create: true });
      let size = await existingFileSize(handle);
      if (size > file.size) {
        await resetFile(handle);
        size = 0;
        file.chunks = [];
        file.verifiedSha256 = null;
      }
      file.chunks = completeChunkRecords(size);
      sizes.set(file.name, size);
      completed += size;
    }
    manifest.completedBytes = completed;
    onProgress({
      state: 'retail',
      revision: catalog.revision,
      completedBytes: completed,
      totalBytes: catalog.totalBytes,
    });

    const quota = await this.quota({ files: [], totalBytes: Math.max(0, catalog.totalBytes - completed) });
    if (!quota.ok) {
      throw new RetailImportError(
        'QUOTA_EXCEEDED',
        `Za mało miejsca. Potrzeba ${formatBytes(quota.required)}, dostępne ${formatBytes(quota.available)}.`,
        quota
      );
    }
    if (!index.revisions.includes(catalog.revision)) index.revisions.push(catalog.revision);
    await this.writeIndex(root, index);
    await writeJson(revisionDirectory, 'manifest.json', manifest);

    try {
      for (const file of manifest.files) {
        throwIfAborted(signal);
        const handle = await q4base.getFileHandle(file.storageKey, { create: true });
        let offset = sizes.get(file.name) || 0;
        let verified = offset === file.size && file.verifiedSha256 === file.sha256;
        for (let attempt = 0; attempt < 2 && !verified; attempt += 1) {
          while (offset < file.size) {
            const end = Math.min(file.size, offset + IMPORT_CHUNK_SIZE) - 1;
            const headers = {
              Range: `bytes=${offset}-${end}`,
              'If-Range': `"${file.sha256}"`,
            };
            const download = await this.fetchImpl(file.url, { headers, signal, cache: 'no-store' });
            await validateRetailRangeResponse(download, file, offset, end);
            const beforeFile = completed - offset;
            await copyResponseToFile(download, handle, offset, end + 1, signal, (position) => {
              completed = beforeFile + position;
              manifest.completedBytes = completed;
              onProgress({
                state: 'retail',
                revision: catalog.revision,
                file: file.name,
                completedBytes: completed,
                totalBytes: catalog.totalBytes,
              });
            });
            completed = beforeFile + end + 1;
            offset = end + 1;
            file.chunks = completeChunkRecords(offset);
            manifest.completedBytes = completed;
            manifest.updatedAt = this.now();
            await writeJson(revisionDirectory, 'manifest.json', manifest);
          }

          onProgress({
            state: 'verify-retail',
            revision: catalog.revision,
            file: file.name,
            completedBytes: completed,
            totalBytes: catalog.totalBytes,
          });
          verified = await verifyRuntimeFile(handle, file, signal);
          if (!verified) {
            completed -= offset;
            offset = 0;
            await resetFile(handle);
            file.chunks = [];
            file.verifiedSha256 = null;
          }
        }
        if (!verified) {
          throw new RetailImportError('SERVER_RETAIL_HASH_MISMATCH', `Pakiet ${file.name} nie zgadza się z manifestem serwera.`);
        }
        file.verifiedSha256 = file.sha256;
        file.chunks = completeChunkRecords(file.size);
        manifest.completedBytes = completed;
        manifest.updatedAt = this.now();
        await writeJson(revisionDirectory, 'manifest.json', manifest);
      }

      manifest.state = 'ready';
      manifest.completedBytes = manifest.totalBytes;
      manifest.error = null;
      manifest.updatedAt = this.now();
      await writeJson(revisionDirectory, 'manifest.json', manifest);
      index.activeRevision = catalog.revision;
      await this.writeIndex(root, index);
      onProgress({
        state: 'ready',
        revision: catalog.revision,
        completedBytes: manifest.totalBytes,
        totalBytes: manifest.totalBytes,
      });
      return publicManifest(manifest);
    } catch (error) {
      manifest.state = error?.name === 'AbortError' || signal?.aborted ? 'cancelled' : 'error';
      manifest.error = manifest.state === 'error' ? String(error?.message || error) : null;
      manifest.completedBytes = completed;
      manifest.updatedAt = this.now();
      await writeJson(revisionDirectory, 'manifest.json', manifest).catch(() => {});
      throw error;
    }
  }

  async ensureRuntimeAssets({ signal, onProgress = () => {} } = {}) {
    if (typeof this.fetchImpl !== 'function') {
      throw new RetailImportError('RUNTIME_DOWNLOAD_UNAVAILABLE', 'Przeglądarka nie może pobrać danych OpenQ4.');
    }
    throwIfAborted(signal);
    const response = await this.fetchImpl(RUNTIME_MANIFEST_URL, { cache: 'no-store', signal });
    if (!response.ok) {
      throw new RetailImportError('RUNTIME_MANIFEST_MISSING', `Nie udało się pobrać manifestu OpenQ4 (HTTP ${response.status}).`);
    }
    const manifest = validateRuntimeManifest(await response.json());
    const storageRoot = await this.storageRoot();
    const runtimes = await getDirectory(storageRoot, RUNTIME_ROOT, true);
    for (const revoked of manifest.revokedBuilds) {
      await runtimes.removeEntry(revoked, { recursive: true }).catch((error) => {
        if (!isNotFound(error)) throw error;
      });
    }
    const runtime = await getDirectory(runtimes, manifest.build, true);
    const baseoq4 = await getDirectory(runtime, 'baseoq4', true);
    const previousManifest = await readJson(runtime, 'manifest.json');
    const currentNames = new Set(manifest.files.map((file) => file.name));
    const totalBytes = manifest.files.reduce((sum, file) => sum + file.size, 0);
    let completedBytes = 0;

    const sizes = new Map();
    for (const file of manifest.files) {
      const handle = await baseoq4.getFileHandle(file.name, { create: true });
      const size = await existingFileSize(handle);
      const durableSize = size <= file.size ? size : 0;
      sizes.set(file.name, durableSize);
      completedBytes += durableSize;
    }
    onProgress({ state: 'runtime', build: manifest.build, completedBytes, totalBytes });

    for (const file of manifest.files) {
      throwIfAborted(signal);
      const handle = await baseoq4.getFileHandle(file.name, { create: true });
      let offset = sizes.get(file.name) || 0;
      let verified = false;
      for (let attempt = 0; attempt < 2 && !verified; attempt += 1) {
        if (offset < file.size) {
          const headers = offset > 0 ? { Range: `bytes=${offset}-` } : {};
          let download = await this.fetchImpl(file.url, { headers, signal, cache: 'no-store' });
          if (offset > 0 && download.status === 200) {
            await download.body?.cancel().catch(() => {});
            completedBytes -= offset;
            offset = 0;
            download = await this.fetchImpl(file.url, { signal, cache: 'no-store' });
          }
          const beforeFile = completedBytes - offset;
          await copyResponseToFile(download, handle, offset, file.size, signal, (position) => {
            completedBytes = beforeFile + position;
            onProgress({ state: 'runtime', build: manifest.build, file: file.name, completedBytes, totalBytes });
          });
          completedBytes = beforeFile + file.size;
          offset = file.size;
        }

        onProgress({ state: 'verify-runtime', build: manifest.build, file: file.name, completedBytes, totalBytes });
        verified = await verifyRuntimeFile(handle, file, signal);
        if (!verified) {
          completedBytes -= offset;
          offset = 0;
          sizes.set(file.name, 0);
        }
      }
      if (!verified) {
        throw new RetailImportError('RUNTIME_HASH_MISMATCH', `Plik ${file.name} nie zgadza się z sumą SHA-256 manifestu.`);
      }
      sizes.set(file.name, file.size);
    }

    for (const previous of previousManifest?.files || []) {
      if (/^[a-z0-9._-]{1,160}$/i.test(previous.name) && !currentNames.has(previous.name)) {
        await baseoq4.removeEntry(previous.name).catch((error) => {
          if (!isNotFound(error)) throw error;
        });
      }
    }

    await writeJson(runtime, 'manifest.json', { ...manifest, installedAt: this.now() });
    onProgress({ state: 'ready', build: manifest.build, completedBytes: totalBytes, totalBytes });
    return {
      build: manifest.build,
      basePath: `/opfs/${RUNTIME_ROOT}/${manifest.build}`,
      manifest: publicManifest(manifest)
    };
  }

  async verifyRevision(directory, manifest) {
    if (manifest.schema !== IMPORT_SCHEMA) return false;
    const q4base = await runtimeQ4Base(directory, false);
    if (!q4base) return false;
    for (const file of manifest.files) {
      let handle;
      try {
        handle = await q4base.getFileHandle(file.storageKey);
      } catch (error) {
        if (isNotFound(error)) return false;
        throw error;
      }
      if (await existingFileSize(handle) !== file.size) return false;
      const expectedCount = Math.ceil(file.size / manifest.chunkSize);
      if (file.chunks.filter(Boolean).length !== expectedCount) return false;
      if (durableChunkCount(file, file.size).count !== expectedCount) return false;
    }
    return true;
  }

  async import(files, { signal, onProgress = () => {} } = {}) {
    const selection = validateRetailFiles(files);
    if (!selection.ok) {
      const message = selection.duplicates.length
        ? 'Wybór zawiera powtarzające się nazwy pakietów.'
        : selection.missing.length
          ? `Brakuje wymaganych pakietów: ${selection.missing.join(', ')}`
          : `Puste pakiety nie są prawidłowymi danymi gry: ${selection.invalid.join(', ')}`;
      throw new RetailImportError('INVALID_RETAIL_FILES', message, {
        missing: selection.missing,
        invalid: selection.invalid,
        duplicates: selection.duplicates.map((entry) => entry.name)
      });
    }
    throwIfAborted(signal);

    if (typeof this.storage?.persist === 'function') await this.storage.persist().catch(() => false);
    const root = await this.dataRoot(true);
    const revisions = await getDirectory(root, 'revisions', true);
    const revision = makeRevisionId(selection);
    const revisionDirectory = await getDirectory(revisions, revision, true);
    const previous = await readJson(revisionDirectory, 'manifest.json');

    if (previous?.state === 'ready' && await this.verifyRevision(revisionDirectory, previous)) {
      const index = await this.readIndex(true);
      index.activeRevision = revision;
      if (!index.revisions.includes(revision)) index.revisions.push(revision);
      await this.writeIndex(root, index);
      onProgress({ state: 'ready', duplicate: true, revision, completedBytes: previous.totalBytes, totalBytes: previous.totalBytes });
      return { revision, manifest: publicManifest(previous), duplicate: true };
    }

    const manifest = createManifest(revision, selection, previous, this.now());
    const q4base = await runtimeQ4Base(revisionDirectory, true);
    for (const file of manifest.files) {
      const handle = await q4base.getFileHandle(file.storageKey, { create: true });
      const durable = durableChunkCount(file, await existingFileSize(handle));
      file.chunks = file.chunks.slice(0, durable.count);
    }
    manifest.completedBytes = completedBytes(manifest);
    const quota = await this.quota(selection, manifest);
    if (!quota.ok) {
      throw new RetailImportError(
        'QUOTA_EXCEEDED',
        `Za mało miejsca. Potrzeba ${formatBytes(quota.required)}, dostępne ${formatBytes(quota.available)}.`,
        quota
      );
    }

    const index = await this.readIndex(true);
    if (!index.revisions.includes(revision)) index.revisions.push(revision);
    await this.writeIndex(root, index);
    await writeJson(revisionDirectory, 'manifest.json', manifest);

    const report = (extra = {}) => onProgress({
      state: manifest.state,
      revision,
      completedBytes: manifest.completedBytes,
      totalBytes: manifest.totalBytes,
      ...extra
    });
    report({ resumed: Boolean(previous) });

    try {
      for (let fileIndex = 0; fileIndex < selection.files.length; fileIndex += 1) {
        const source = selection.files[fileIndex];
        const target = manifest.files[fileIndex];
        const chunkCount = Math.ceil(source.size / manifest.chunkSize);
        const targetHandle = await q4base.getFileHandle(target.storageKey, { create: true });
        const durable = durableChunkCount(target, await existingFileSize(targetHandle));
        target.chunks = target.chunks.slice(0, durable.count);
        const writable = await targetHandle.createWritable({ keepExistingData: durable.bytes > 0 });

        try {
          if (typeof writable.truncate === 'function') await writable.truncate(durable.bytes);
          if (typeof writable.seek === 'function') await writable.seek(durable.bytes);
          else if (durable.bytes > 0) {
            throw new RetailImportError('OPFS_SEEK_UNSUPPORTED', 'Przeglądarka nie obsługuje wznawiania zapisu plików OPFS.');
          }

          for (let chunkIndex = durable.count; chunkIndex < chunkCount; chunkIndex += 1) {
            throwIfAborted(signal);
            const start = chunkIndex * manifest.chunkSize;
            const end = Math.min(source.size, start + manifest.chunkSize);
            const chunk = await writeChunk(source.file, start, end, writable, signal);
            target.chunks[chunkIndex] = { index: chunkIndex, ...chunk };
            manifest.completedBytes = completedBytes(manifest);
            manifest.updatedAt = this.now();
            report({ file: source.name, fileIndex, fileCount: selection.files.length, chunkIndex, chunkCount });
          }
          if (typeof writable.truncate === 'function') await writable.truncate(source.size);
          await writable.close();
          await writeJson(revisionDirectory, 'manifest.json', manifest);
        } catch (error) {
          // Closing commits complete chunks, so a cancelled import can resume in-place.
          await writable.close().catch(() => {});
          throw error;
        }
      }

      const chunkHashes = manifest.files.flatMap((file) => file.chunks.map((chunk) => chunk.hash)).join('\0');
      manifest.contentFingerprint = hashHex(hashText(chunkHashes));
      manifest.completedBytes = manifest.totalBytes;
      manifest.state = 'ready';
      manifest.error = null;
      manifest.updatedAt = this.now();
      await writeJson(revisionDirectory, 'manifest.json', manifest);
      index.activeRevision = revision;
      await this.writeIndex(root, index);
      report();
      return { revision, manifest: publicManifest(manifest), duplicate: false };
    } catch (error) {
      manifest.state = error?.name === 'AbortError' || signal?.aborted ? 'cancelled' : 'error';
      manifest.error = manifest.state === 'error' ? String(error?.message || error) : null;
      manifest.completedBytes = completedBytes(manifest);
      manifest.updatedAt = this.now();
      await writeJson(revisionDirectory, 'manifest.json', manifest).catch(() => {});
      report({ error: manifest.error });
      throw error;
    }
  }

  async *openFileChunks(revision, name) {
    const manifest = await this.getRevisionManifest(revision);
    if (!manifest || manifest.state !== 'ready') {
      throw new RetailImportError('REVISION_NOT_READY', 'Dane gry nie są gotowe.', { revision });
    }
    const target = manifest.files.find((file) => file.name.toLowerCase() === String(name).toLowerCase());
    if (!target) throw new RetailImportError('FILE_NOT_IMPORTED', `Brak pakietu ${name}.`, { revision, name });

    const root = await this.dataRoot(false);
    const revisions = await getDirectory(root, 'revisions', false);
    const revisionDirectory = await getDirectory(revisions, revision, false);
    const q4base = await runtimeQ4Base(revisionDirectory, false);
    const handle = await q4base.getFileHandle(target.storageKey);
    const file = await handle.getFile();

    for (const chunk of target.chunks) {
      const start = chunk.index * manifest.chunkSize;
      const reader = file.slice(start, start + chunk.size).stream().getReader();
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        yield value instanceof Uint8Array ? value : new Uint8Array(value);
      }
    }
  }

  async launchDescriptor(revision = null) {
    const manifest = revision ? await this.getRevisionManifest(revision) : await this.getActiveImport();
    if (!manifest || manifest.state !== 'ready') return null;
    return {
      schema: IMPORT_SCHEMA,
      revision: manifest.revision,
      basePath: `/opfs/${IMPORT_ROOT}/revisions/${manifest.revision}/runtime`,
      q4basePath: `/opfs/${IMPORT_ROOT}/revisions/${manifest.revision}/runtime/q4base`,
      manifest: publicManifest(manifest),
      openFileChunks: (name) => this.openFileChunks(manifest.revision, name)
    };
  }
}
