const fs = require('fs');
const path = require('path');
const yauzl = require('yauzl');
const config = require('./resource-bundles.config.cjs');

const root = path.join(__dirname, '..');
const portableBundleRoot = path.join(root, 'dist-release-final', 'win-unpacked', 'resources', 'resources-bundles');
const manifestPath = path.join(root, 'dist-release-final', 'resource-manifest.json');
const retryableFsCodes = new Set(['EBUSY', 'EACCES', 'EPERM', 'ENOTEMPTY']);

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function isRetryableFsError(error) {
  return error && retryableFsCodes.has(error.code);
}

async function retryFsOperation(label, operation, attempts = 6) {
  let lastError = null;
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      return await operation(attempt);
    } catch (error) {
      lastError = error;
      if (!isRetryableFsError(error) || attempt === attempts) {
        throw error;
      }
      const delayMs = 350 * attempt;
      console.warn(`[hydrate-oem-portable] ${label} failed with ${error.code}; retry ${attempt}/${attempts - 1} after ${delayMs}ms`);
      await sleep(delayMs);
    }
  }
  throw lastError;
}

function removeIfExists(target) {
  if (fs.existsSync(target)) {
    fs.rmSync(target, { recursive: true, force: true });
  }
}

function uniqueTempPath(target) {
  return `${target}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

async function replaceWithRetry(source, destination) {
  await retryFsOperation(`replace ${destination}`, async () => {
    removeIfExists(destination);
    fs.renameSync(source, destination);
  });
}

function readJson(file, fallback) {
  if (!fs.existsSync(file)) return fallback;
  try {
    return JSON.parse(fs.readFileSync(file, 'utf8'));
  } catch {
    return fallback;
  }
}

function extractZipOnce(zipPath, destination) {
  return new Promise((resolve, reject) => {
    const finalDestinationRoot = path.resolve(destination);
    const stagingDestination = uniqueTempPath(destination);
    removeIfExists(stagingDestination);
    fs.mkdirSync(stagingDestination, { recursive: true });
    const destinationRoot = path.resolve(stagingDestination);

    yauzl.open(zipPath, { lazyEntries: true }, (openError, zipFile) => {
      if (openError || !zipFile) {
        reject(openError || new Error(`Cannot open zip: ${zipPath}`));
        return;
      }

      let settled = false;
      const finish = (error) => {
        if (settled) return;
        settled = true;
        try {
          zipFile.close();
        } catch {
          // Ignore close errors after the original extraction failure.
        }
        if (error) {
          removeIfExists(stagingDestination);
          reject(error);
          return;
        }

        replaceWithRetry(stagingDestination, finalDestinationRoot)
          .then(() => resolve())
          .catch(replaceError => {
            removeIfExists(stagingDestination);
            reject(replaceError);
          });
      };

      zipFile.readEntry();
      zipFile.on('entry', (entry) => {
        if (settled) return;
        const normalizedName = entry.fileName.replace(/\\/g, '/');
        const destPath = path.resolve(destinationRoot, normalizedName);
        if (!destPath.startsWith(destinationRoot + path.sep) && destPath !== destinationRoot) {
          finish(new Error(`Unsafe zip entry: ${entry.fileName}`));
          return;
        }

        try {
          if (/\/$/.test(normalizedName)) {
            fs.mkdirSync(destPath, { recursive: true });
            zipFile.readEntry();
            return;
          }

          fs.mkdirSync(path.dirname(destPath), { recursive: true });
        } catch (error) {
          finish(error);
          return;
        }

        zipFile.openReadStream(entry, (streamError, readStream) => {
          if (streamError || !readStream) {
            finish(streamError || new Error(`Cannot read zip entry: ${entry.fileName}`));
            return;
          }

          const writeStream = fs.createWriteStream(destPath);
          readStream.pipe(writeStream);
          writeStream.on('finish', () => {
            if (!settled) zipFile.readEntry();
          });
          writeStream.on('error', finish);
          readStream.on('error', finish);
        });
      });

      zipFile.on('end', () => finish());
      zipFile.on('error', finish);
    });
  });
}

function extractZip(zipPath, destination) {
  return retryFsOperation(
    `extract ${path.basename(zipPath)} to ${destination}`,
    () => extractZipOnce(zipPath, destination)
  );
}

async function copyDirectory(source, destination) {
  if (!fs.existsSync(source)) return false;
  await retryFsOperation(`copy ${source} to ${destination}`, async () => {
    fs.mkdirSync(path.dirname(destination), { recursive: true });
    removeIfExists(destination);
    fs.cpSync(source, destination, { recursive: true, force: true });
  });
  return true;
}

function pruneWindowsOnlyBundles() {
  const chromiumRoot = path.join(portableBundleRoot, 'playwright', 'chromium-1200');
  const macChromium = path.join(chromiumRoot, 'chrome-mac');
  const winChromium = path.join(chromiumRoot, 'chrome-win64');
  if (process.platform === 'win32' && fs.existsSync(winChromium)) {
    removeIfExists(macChromium);
  }
}

function directoryHasFiles(dir) {
  if (!fs.existsSync(dir)) return false;
  const walk = (current) => {
    for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
      const full = path.join(current, entry.name);
      if (entry.isFile()) return true;
      if (entry.isDirectory() && walk(full)) return true;
    }
    return false;
  };
  return walk(dir);
}

async function main() {
  const unpackedDir = path.join(root, 'dist-release-final', 'win-unpacked');
  if (!fs.existsSync(unpackedDir)) {
    throw new Error(`win-unpacked does not exist: ${unpackedDir}`);
  }

  const manifest = readJson(manifestPath, { bundles: {} });
  fs.mkdirSync(portableBundleRoot, { recursive: true });
  const hydrated = [];

  for (const bundle of config.bundles) {
    const destination = path.join(portableBundleRoot, bundle.name);
    const archiveName = manifest.bundles?.[bundle.name]?.archive;
    const archivePath = archiveName ? path.join(config.outputDir, archiveName) : '';

    if (archivePath && fs.existsSync(archivePath)) {
      await extractZip(archivePath, destination);
      hydrated.push(bundle.name);
    } else if (await copyDirectory(bundle.source, destination)) {
      hydrated.push(bundle.name);
    } else if (bundle.required) {
      throw new Error(`Required resource bundle is missing: ${bundle.name}. Expected archive ${archivePath || '(none)'} or source ${bundle.source}`);
    }

    if (bundle.required && !directoryHasFiles(destination)) {
      throw new Error(`Required resource bundle was not hydrated: ${bundle.name} (${destination})`);
    }
  }

  pruneWindowsOnlyBundles();
  console.log(`[hydrate-oem-portable] hydrated bundles: ${hydrated.join(', ') || 'none'}`);
}

main().catch((error) => {
  console.error('[hydrate-oem-portable] failed:', error);
  process.exit(1);
});

