import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const SCRIPT_PATH = fileURLToPath(import.meta.url);
const REPOSITORY_ROOT = path.resolve(path.dirname(SCRIPT_PATH), '..');
const FIXTURE_RELATIVE_PATH = 'tests/fixtures/generated/tone.wav';
const FIXTURE_PATH = path.join(REPOSITORY_ROOT, FIXTURE_RELATIVE_PATH);
const EXPECTED_FIXTURE_BYTES = 32_102;
const EXPECTED_FIXTURE_SHA256 =
  '0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c';
const DEFAULT_MODE_TIMEOUT_MILLISECONDS = 60_000;
const RESULT_PREFIX = 'OPUS_RESULT ';
const EVENT_PREFIX = 'OPUS_EVENT ';
const MODES = Object.freeze(['single-thread', 'multithread']);

const argumentsSet = new Set(process.argv.slice(2));
const option = (name) =>
  process.argv
    .slice(2)
    .find((argument) => argument.startsWith(`--${name}=`))
    ?.slice(name.length + 3);

function boundedInteger(value, fallback, minimum, maximum) {
  const parsed = Number(value);
  return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum
    ? parsed
    : fallback;
}

function serializeError(error) {
  if (error instanceof Error) {
    return {
      name: error.name,
      message: error.message,
      ...(error.stack ? { stack: error.stack.split('\n').slice(0, 8) } : {}),
    };
  }
  return { name: 'Error', message: String(error) };
}

function emit(prefix, value) {
  process.stdout.write(`${prefix}${JSON.stringify(value)}\n`);
}

function parsePrefixedJsonLines(text, prefix) {
  return text
    .split(/\r?\n/u)
    .filter((line) => line.startsWith(prefix))
    .flatMap((line) => {
      try {
        return [JSON.parse(line.slice(prefix.length))];
      } catch {
        return [];
      }
    });
}

function appendBounded(current, chunk, maximumLength = 32_768) {
  return `${current}${chunk}`.slice(-maximumLength);
}

function terminateProcessGroup(child) {
  if (!child.pid) return;
  try {
    if (process.platform === 'win32') {
      child.kill('SIGKILL');
    } else {
      process.kill(-child.pid, 'SIGKILL');
    }
  } catch {
    child.kill('SIGKILL');
  }
}

async function runIsolatedMode(mode, origin, timeoutMilliseconds) {
  const startedAt = performance.now();
  const child = spawn(
    process.execPath,
    [SCRIPT_PATH, '--child', `--mode=${mode}`, `--origin=${origin}`],
    {
      cwd: REPOSITORY_ROOT,
      detached: process.platform !== 'win32',
      stdio: ['ignore', 'pipe', 'pipe'],
    }
  );
  let stdout = '';
  let stderr = '';
  let timedOut = false;

  child.stdout.setEncoding('utf8');
  child.stderr.setEncoding('utf8');
  child.stdout.on('data', (chunk) => {
    stdout = appendBounded(stdout, chunk);
    for (const event of parsePrefixedJsonLines(chunk, EVENT_PREFIX)) {
      if (event.stage && event.stage !== 'core-log') {
        process.stdout.write(`[opus:${mode}] ${event.stage}\n`);
      }
    }
  });
  child.stderr.on('data', (chunk) => {
    stderr = appendBounded(stderr, chunk);
  });

  const timeout = setTimeout(() => {
    timedOut = true;
    terminateProcessGroup(child);
  }, timeoutMilliseconds);

  const { code, signal } = await new Promise((resolve) => {
    child.once('close', (exitCode, exitSignal) => {
      resolve({ code: exitCode, signal: exitSignal });
    });
  });
  clearTimeout(timeout);

  const elapsedMilliseconds = Math.round(performance.now() - startedAt);
  const events = parsePrefixedJsonLines(stdout, EVENT_PREFIX);
  const reported = parsePrefixedJsonLines(stdout, RESULT_PREFIX).at(-1);
  if (reported) {
    return {
      ...reported,
      elapsedMilliseconds,
      process: { exitCode: code, signal },
    };
  }

  const coreReady = events.find((event) => event.stage === 'core-ready');
  const lastStage = [...events]
    .reverse()
    .find((event) => event.stage && event.stage !== 'core-log');
  const relevantLogs = events
    .filter((event) => event.stage === 'core-log')
    .slice(-16)
    .map((event) => event.message);
  return {
    requestedMode: mode,
    status: 'fail',
    failure: timedOut ? 'timeout' : 'child-exited-without-result',
    message: timedOut
      ? `The isolated browser did not finish within ${String(timeoutMilliseconds)} ms.`
      : 'The isolated browser exited without a structured result.',
    ...(coreReady?.actualMode ? { actualMode: coreReady.actualMode } : {}),
    ...(coreReady?.ffmpegVersion
      ? { ffmpegVersion: coreReady.ffmpegVersion }
      : {}),
    ...(coreReady?.capabilities
      ? { capabilities: coreReady.capabilities }
      : {}),
    ...(lastStage?.stage ? { lastStage: lastStage.stage } : {}),
    ...(relevantLogs.length > 0 ? { relevantLogs } : {}),
    ...(stderr.trim() ? { stderr: stderr.trim().split('\n').slice(-12) } : {}),
    elapsedMilliseconds,
    process: { exitCode: code, signal },
  };
}

async function fixtureIdentity() {
  const [bytes, fixtureStat] = await Promise.all([
    readFile(FIXTURE_PATH),
    stat(FIXTURE_PATH),
  ]);
  const sha256 = createHash('sha256').update(bytes).digest('hex');
  if (
    fixtureStat.size !== EXPECTED_FIXTURE_BYTES ||
    sha256 !== EXPECTED_FIXTURE_SHA256
  ) {
    throw new Error(
      `Opus fixture identity mismatch: expected ${String(EXPECTED_FIXTURE_BYTES)} bytes/${EXPECTED_FIXTURE_SHA256}, received ${String(fixtureStat.size)} bytes/${sha256}.`
    );
  }
  return {
    path: FIXTURE_RELATIVE_PATH,
    bytes: fixtureStat.size,
    sha256,
  };
}

async function runMain() {
  const timeoutMilliseconds = boundedInteger(
    option('timeout-ms') ?? process.env.AV_OPUS_REGRESSION_TIMEOUT_MS,
    DEFAULT_MODE_TIMEOUT_MILLISECONDS,
    10_000,
    10 * 60_000
  );
  const [fixture, packageText, coreLockText] = await Promise.all([
    fixtureIdentity(),
    readFile(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
    readFile(
      path.join(REPOSITORY_ROOT, 'scripts', 'reviewed-ffmpeg-core-lock.json'),
      'utf8'
    ),
  ]);
  const packageManifest = JSON.parse(packageText);
  const coreLock = JSON.parse(coreLockText);
  const { createServer } = await import('vite');
  const server = await createServer({
    configFile: path.join(REPOSITORY_ROOT, 'vite.config.ts'),
    logLevel: 'warn',
    server: {
      host: '127.0.0.1',
      port: 0,
      strictPort: false,
    },
  });

  try {
    await server.listen();
    const address = server.httpServer?.address();
    if (!address || typeof address === 'string') {
      throw new Error('Vite did not expose a local TCP address.');
    }
    const origin = `http://127.0.0.1:${String(address.port)}`;
    const modes = [];
    for (const mode of MODES) {
      modes.push(await runIsolatedMode(mode, origin, timeoutMilliseconds));
    }
    const report = {
      schemaVersion: 2,
      recordedAt: new Date().toISOString(),
      browser: 'chromium',
      wrapperVersion: packageManifest.dependencies['@ffmpeg/ffmpeg'],
      coreVersion: coreLock.coreVersion,
      coreBuildId: coreLock.buildId,
      coreProfile: coreLock.profile,
      fixture,
      perModeTimeoutMilliseconds: timeoutMilliseconds,
      modes,
      safe: modes.every((result) => result.status === 'pass'),
    };
    process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
    if (argumentsSet.has('--require-pass') && !report.safe) {
      process.exitCode = 1;
    }
  } finally {
    await server.close();
  }
}

async function runBrowserMode(mode, origin) {
  const { chromium } = await import('@playwright/test');
  const browser = await chromium.launch({ headless: true });
  try {
    const page = await browser.newPage();
    await page.exposeFunction('__reportOpusEvent', (event) => {
      emit(EVENT_PREFIX, event);
    });
    await page.goto(origin, {
      waitUntil: 'domcontentloaded',
      timeout: 30_000,
    });
    return await page.evaluate(
      async ({ fixtureUrl, requestedMode }) => {
        const report = async (event) => {
          await globalThis.__reportOpusEvent(event);
        };
        const replacePaths = (plan, context) => {
          const replacements = new Map();
          plan.inputs.forEach((input, index) => {
            const actual = context.inputPaths[index];
            if (actual) replacements.set(input.path, actual);
          });
          plan.outputs.forEach((output, index) => {
            const actual = context.outputPaths[index];
            if (actual) replacements.set(output.path, actual);
          });
          plan.temporaryFiles.forEach((temporary, index) => {
            const actual = context.temporaryPaths[index];
            if (actual) replacements.set(temporary.path, actual);
          });
          return plan.args.map((argument) =>
            [...replacements].reduce(
              (resolved, [virtualPath, actualPath]) =>
                resolved.replaceAll(virtualPath, actualPath),
              argument
            )
          );
        };
        const errorShape = (error) =>
          error instanceof Error
            ? {
                name: error.name,
                message: error.message,
                ...(error.stack
                  ? { stack: error.stack.split('\n').slice(0, 8) }
                  : {}),
              }
            : { name: 'Error', message: String(error) };
        let manager;
        const relevantLogs = [];
        try {
          await report({ stage: 'module-import' });
          const [{ EngineManager }, { buildConvertPlan }, { AUDIO_PRESETS }] =
            await Promise.all([
              import('/src/ffmpeg/EngineManager.ts'),
              import('/src/commands/convert.ts'),
              import('/src/presets/audio-presets.ts'),
            ]);
          manager = new EngineManager();
          manager.setPreference(
            requestedMode === 'single-thread'
              ? 'force-single-thread'
              : 'prefer-multithread'
          );
          manager.onLog((event) => {
            for (const line of event.message.split(/\r?\n/u)) {
              if (
                relevantLogs.length < 32 &&
                /libopus|Output #|Stream mapping|Aborted\(\)|(?:^|\s)(?:size|progress)=|error|failed/iu.test(
                  line
                )
              ) {
                const message = line.slice(0, 600);
                relevantLogs.push(message);
                void report({ stage: 'core-log', message });
              }
            }
          });

          await report({ stage: 'fixture-fetch' });
          const fixtureResponse = await fetch(fixtureUrl);
          if (!fixtureResponse.ok) {
            throw new Error(
              `Fixture request failed with HTTP ${String(fixtureResponse.status)}.`
            );
          }
          const fixtureBytes = new Uint8Array(
            await fixtureResponse.arrayBuffer()
          );
          const sourceFile = new File([fixtureBytes], 'tone.wav', {
            type: 'audio/wav',
          });

          await report({ stage: 'core-initializing' });
          await manager.initialize();
          const ready = manager.state;
          if (ready.status !== 'ready') {
            throw new Error('The FFmpeg engine did not reach ready state.');
          }
          const capabilities = {
            opusMuxer: ready.capabilities.muxers.has('opus'),
            libopusEncoder: ready.capabilities.encoders.has('libopus'),
          };
          await report({
            stage: 'core-ready',
            requestedMode,
            actualMode: ready.mode,
            ffmpegVersion: ready.capabilities.versionText,
            capabilities,
          });
          if (ready.mode !== requestedMode) {
            throw new Error(
              `Requested ${requestedMode}, but the engine selected ${ready.mode}.`
            );
          }
          if (!capabilities.opusMuxer || !capabilities.libopusEncoder) {
            throw new Error(
              'The pinned core does not advertise the Opus muxer and libopus encoder.'
            );
          }

          const preset = AUDIO_PRESETS.find(
            (candidate) => candidate.id === 'audio-opus'
          );
          if (!preset) {
            throw new Error('The built-in audio-opus preset is missing.');
          }
          const plan = buildConvertPlan({
            jobId: `opus-regression-${requestedMode}`,
            source: {
              id: 'tone',
              sourceIndex: 0,
              fileName: 'tone.wav',
              extension: 'wav',
            },
            preset,
            streamSelection: { audio: [0] },
            expectedDurationSeconds: 1,
            timeoutMs: 30_000,
          });
          await report({
            stage: 'encode-started',
            presetId: preset.id,
            requirements: plan.requiredCapabilities,
          });
          let commandArguments = [];
          const completed = await manager.runJob({
            id: `opus-regression-${requestedMode}`,
            operation: 'Opus regression',
            coreWorkload: plan.operation,
            inputs: [sourceFile],
            outputs: plan.outputs.map((output) => ({
              name: output.fileName,
              mimeType: output.mediaType,
            })),
            expectedDurationSeconds: plan.expectedDurationSeconds,
            timeoutMilliseconds: plan.timeoutMs,
            buildArguments: (context) => {
              commandArguments = replacePaths(plan, context);
              void report({
                stage: 'encode-command',
                arguments: commandArguments,
              });
              return commandArguments;
            },
          });
          const output = completed.outputs[0];
          if (!output || output.bytes.byteLength === 0) {
            throw new Error(
              'The Opus encode did not produce non-empty output.'
            );
          }
          await report({
            stage: 'encode-finished',
            outputBytes: output.bytes.byteLength,
          });

          const outputFile = new File([output.bytes], output.name, {
            type: output.mimeType,
          });
          await report({ stage: 'reprobe-started' });
          const probe = await manager.probe(outputFile, 15_000);
          const probeJson = probe.json;
          const streams = Array.isArray(probeJson?.streams)
            ? probeJson.streams
            : [];
          const opusStream = streams.find(
            (stream) =>
              stream?.codec_type === 'audio' && stream?.codec_name === 'opus'
          );
          const durationSeconds = Number(
            probeJson?.format?.duration ?? opusStream?.duration
          );
          if (!opusStream || !(durationSeconds > 0)) {
            throw new Error(
              'The generated file did not re-probe as positive-duration Opus audio.'
            );
          }
          const digest = new Uint8Array(
            await crypto.subtle.digest('SHA-256', output.bytes)
          );
          const outputSha256 = [...digest]
            .map((value) => value.toString(16).padStart(2, '0'))
            .join('');
          await report({ stage: 'reprobe-finished', durationSeconds });
          return {
            requestedMode,
            actualMode: ready.mode,
            status: 'pass',
            ffmpegVersion: ready.capabilities.versionText,
            capabilities,
            presetId: preset.id,
            commandArguments,
            output: {
              name: output.name,
              mimeType: output.mimeType,
              bytes: output.bytes.byteLength,
              sha256: outputSha256,
              codecName: opusStream.codec_name,
              codecType: opusStream.codec_type,
              durationSeconds,
            },
            relevantLogs,
          };
        } catch (error) {
          const state = manager?.state;
          return {
            requestedMode,
            ...(state?.mode ? { actualMode: state.mode } : {}),
            status: 'fail',
            failure: 'browser-operation-failed',
            error: errorShape(error),
            relevantLogs,
          };
        } finally {
          manager?.dispose();
        }
      },
      {
        fixtureUrl: `${origin}/${FIXTURE_RELATIVE_PATH}`,
        requestedMode: mode,
      }
    );
  } finally {
    await browser.close();
  }
}

async function runChild() {
  const mode = option('mode');
  const origin = option('origin');
  if (!MODES.includes(mode) || !origin) {
    throw new Error('Child mode requires valid --mode and --origin options.');
  }
  emit(EVENT_PREFIX, { stage: 'browser-launching', requestedMode: mode });
  try {
    const result = await runBrowserMode(mode, origin);
    emit(RESULT_PREFIX, result);
  } catch (error) {
    emit(RESULT_PREFIX, {
      requestedMode: mode,
      status: 'fail',
      failure: 'browser-process-failed',
      error: serializeError(error),
    });
  }
}

try {
  if (argumentsSet.has('--child')) {
    await runChild();
  } else {
    await runMain();
  }
} catch (error) {
  process.stderr.write(
    `Opus regression harness failed: ${JSON.stringify(serializeError(error))}\n`
  );
  process.exitCode = 2;
}
