import {
  buildFfmpegChunkArgs,
  buildFfmpegConcatArgs,
  buildFfmpegMuxArgs,
  exportChunkRanges,
} from './model.js';

export class ExportCancelledError extends Error {
  constructor() {
    super('Export cancelled');
    this.name = 'ExportCancelledError';
  }
}

function assertNotCancelled(isCancelled) {
  if (isCancelled?.()) throw new ExportCancelledError();
}

function assertFfmpegSuccess(result, operation) {
  if (result === 0 || result === true) return;
  throw new Error(`${operation} failed (FFmpeg exit code ${result})`);
}

function concatManifest(files) {
  return files.map(file => `file '${String(file).replaceAll("'", "'\\''")}'`).join('\n');
}

export async function encodeVideoInChunks({
  ffmpeg,
  totalFrames,
  framesPerChunk,
  fps,
  bitrate,
  duration,
  renderFrame,
  createAudioData = null,
  onProgress = () => {},
  isCancelled = () => false,
  prefix = `export_${Date.now().toString(36)}`,
  textEncoder = new TextEncoder(),
}) {
  if (!ffmpeg) throw new Error('FFmpeg is unavailable');
  if (typeof renderFrame !== 'function') throw new Error('Frame renderer is unavailable');

  const ranges = exportChunkRanges(totalFrames, framesPerChunk);
  if (ranges.length === 0) throw new Error('Export range contains no video frames');

  const trackedFiles = new Set();
  const chunkFiles = [];
  const safeDelete = async file => {
    if (!file || !trackedFiles.has(file)) return;
    try {
      await ffmpeg.deleteFile(file);
    } catch {}
    trackedFiles.delete(file);
  };
  const exec = async (args, operation) => {
    assertNotCancelled(isCancelled);
    const result = await ffmpeg.exec(args);
    assertFfmpegSuccess(result, operation);
    assertNotCancelled(isCancelled);
  };

  try {
    for (let chunkIndex = 0; chunkIndex < ranges.length; chunkIndex += 1) {
      const range = ranges[chunkIndex];
      const frameFiles = [];
      const framePattern = `${prefix}_c${String(chunkIndex).padStart(4, '0')}_f%06d.jpg`;
      const chunkFile = `${prefix}_chunk_${String(chunkIndex).padStart(4, '0')}.mp4`;

      try {
        for (let localIndex = 0; localIndex < range.count; localIndex += 1) {
          assertNotCancelled(isCancelled);
          const frameIndex = range.start + localIndex;
          const frameFile = framePattern.replace('%06d', String(localIndex).padStart(6, '0'));
          const frameData = await renderFrame(frameIndex, {
            chunkIndex,
            chunkCount: ranges.length,
            localIndex,
            localCount: range.count,
          });
          if (!(frameData instanceof Uint8Array) || frameData.byteLength === 0) {
            throw new Error(`Frame ${frameIndex + 1} could not be rendered`);
          }
          await ffmpeg.writeFile(frameFile, frameData);
          trackedFiles.add(frameFile);
          frameFiles.push(frameFile);
          onProgress({
            stage: 'render',
            frameIndex,
            totalFrames,
            chunkIndex,
            chunkCount: ranges.length,
            localIndex: localIndex + 1,
            localCount: range.count,
          });
        }

        onProgress({
          stage: 'encode-chunk',
          chunkIndex,
          chunkCount: ranges.length,
          frameCount: range.count,
        });
        await exec(buildFfmpegChunkArgs({
          fps,
          bitrate,
          inputPattern: framePattern,
          frameCount: range.count,
          output: chunkFile,
        }), `Video chunk ${chunkIndex + 1}`);
        trackedFiles.add(chunkFile);
        chunkFiles.push(chunkFile);
        onProgress({
          stage: 'chunk-complete',
          chunkIndex,
          chunkCount: ranges.length,
        });
      } finally {
        for (const frameFile of frameFiles) await safeDelete(frameFile);
      }
    }

    assertNotCancelled(isCancelled);
    const listFile = `${prefix}_concat.txt`;
    const hasAudio = typeof createAudioData === 'function';
    const videoFile = hasAudio ? `${prefix}_video.mp4` : `${prefix}_output.mp4`;
    await ffmpeg.writeFile(listFile, textEncoder.encode(concatManifest(chunkFiles)));
    trackedFiles.add(listFile);
    onProgress({ stage: 'concatenate', chunkCount: ranges.length });
    await exec(buildFfmpegConcatArgs({ listFile, output: videoFile }), 'Video concatenation');
    trackedFiles.add(videoFile);

    for (const chunkFile of chunkFiles) await safeDelete(chunkFile);
    await safeDelete(listFile);

    let outputFile = videoFile;
    if (hasAudio) {
      onProgress({ stage: 'mix-audio' });
      const audioData = await createAudioData();
      assertNotCancelled(isCancelled);
      if (audioData instanceof Uint8Array && audioData.byteLength > 0) {
        const audioFile = `${prefix}_mix.wav`;
        outputFile = `${prefix}_output.mp4`;
        await ffmpeg.writeFile(audioFile, audioData);
        trackedFiles.add(audioFile);
        onProgress({ stage: 'mux-audio' });
        await exec(buildFfmpegMuxArgs({
          videoFile,
          audioFile,
          duration,
          output: outputFile,
        }), 'Audio/video mux');
        trackedFiles.add(outputFile);
        await safeDelete(audioFile);
        await safeDelete(videoFile);
      }
    }

    onProgress({ stage: 'read-output' });
    const outputData = await ffmpeg.readFile(outputFile);
    if (!(outputData instanceof Uint8Array) || outputData.byteLength === 0) {
      throw new Error('FFmpeg produced an empty video file');
    }
    await safeDelete(outputFile);
    onProgress({ stage: 'complete', bytes: outputData.byteLength });
    return outputData;
  } finally {
    for (const file of [...trackedFiles]) await safeDelete(file);
  }
}
