import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
import {
  AudioStreamInfo,
  BitrateDistribution,
  TranscodeCommand,
  VideoCodecHWConfig,
  VideoCodecSWConfig,
  VideoFormat,
  VideoInterfaces,
  VideoStreamInfo,
} from 'src/types';

export class BaseConfig implements VideoCodecSWConfig {
  readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
  protected constructor(protected config: SystemConfigFFmpegDto) {}

  static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces): VideoCodecSWConfig {
    if (config.accel === TranscodeHardwareAcceleration.Disabled) {
      return this.getSWCodecConfig(config);
    }
    return this.getHWCodecConfig(config, interfaces);
  }

  private static getSWCodecConfig(config: SystemConfigFFmpegDto) {
    switch (config.targetVideoCodec) {
      case VideoCodec.H264: {
        return new H264Config(config);
      }
      case VideoCodec.Hevc: {
        return new HEVCConfig(config);
      }
      case VideoCodec.Vp9: {
        return new VP9Config(config);
      }
      case VideoCodec.Av1: {
        return new AV1Config(config);
      }
      default: {
        throw new Error(`Codec '${config.targetVideoCodec}' is unsupported`);
      }
    }
  }

  private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces) {
    let handler: VideoCodecHWConfig;
    switch (config.accel) {
      case TranscodeHardwareAcceleration.Nvenc: {
        handler = config.accelDecode
          ? new NvencHwDecodeConfig(config, interfaces)
          : new NvencSwDecodeConfig(config, interfaces);
        break;
      }
      case TranscodeHardwareAcceleration.Qsv: {
        handler = config.accelDecode
          ? new QsvHwDecodeConfig(config, interfaces)
          : new QsvSwDecodeConfig(config, interfaces);
        break;
      }
      case TranscodeHardwareAcceleration.Vaapi: {
        handler = config.accelDecode
          ? new VaapiHwDecodeConfig(config, interfaces)
          : new VaapiSwDecodeConfig(config, interfaces);
        break;
      }
      case TranscodeHardwareAcceleration.Rkmpp: {
        handler = config.accelDecode
          ? new RkmppHwDecodeConfig(config, interfaces)
          : new RkmppSwDecodeConfig(config, interfaces);
        break;
      }
      default: {
        throw new Error(`${config.accel.toUpperCase()} acceleration is unsupported`);
      }
    }
    if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) {
      throw new Error(
        `${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${handler.getSupportedCodecs()}`,
      );
    }

    return handler;
  }

  getCommand(
    target: TranscodeTarget,
    videoStream: VideoStreamInfo,
    audioStream?: AudioStreamInfo,
    format?: VideoFormat,
  ) {
    const options = {
      inputOptions: this.getBaseInputOptions(videoStream, format),
      outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
      twoPass: this.eligibleForTwoPass(),
      progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
    } as TranscodeCommand;
    if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) {
      const filters = this.getFilterOptions(videoStream);
      if (filters.length > 0) {
        options.outputOptions.push(`-vf ${filters.join(',')}`);
      }
    }

    options.outputOptions.push(
      ...this.getPresetOptions(),
      ...this.getOutputThreadOptions(),
      ...this.getBitrateOptions(),
    );

    return options;
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] {
    return this.getInputThreadOptions();
  }

  getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
    const videoCodec = [TranscodeTarget.All, TranscodeTarget.Video].includes(target) ? this.getVideoCodec() : 'copy';
    const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioCodec() : 'copy';

    const options = [
      `-c:v ${videoCodec}`,
      `-c:a ${audioCodec}`,
      // Makes a second pass moving the moov atom to the
      // beginning of the file for improved playback speed.
      '-movflags faststart',
      '-fps_mode passthrough',
      // explicitly selects the video stream instead of leaving it up to FFmpeg
      `-map 0:${videoStream.index}`,
      // Strip metadata like capture date, camera, and GPS
      '-map_metadata -1',
    ];

    if (audioStream) {
      options.push(`-map 0:${audioStream.index}`);
    }
    if (this.getBFrames() > -1) {
      options.push(`-bf ${this.getBFrames()}`);
    }
    if (this.getRefs() > 0) {
      options.push(`-refs ${this.getRefs()}`);
    }
    if (this.getGopSize() > 0) {
      options.push(`-g ${this.getGopSize()}`);
    }

    if (
      this.config.targetVideoCodec === VideoCodec.Hevc &&
      (videoCodec !== 'copy' || videoStream.codecName === 'hevc')
    ) {
      options.push('-tag:v hvc1');
    }

    return options;
  }

  getFilterOptions(videoStream: VideoStreamInfo) {
    const options = [];
    if (this.shouldScale(videoStream)) {
      options.push(`scale=${this.getScaling(videoStream)}`);
    }

    const tonemapOptions = this.getToneMapping(videoStream);
    if (tonemapOptions.length > 0) {
      options.push(...tonemapOptions);
    } else if (!videoStream.pixelFormat.endsWith('420p')) {
      options.push('format=yuv420p');
    }

    return options;
  }

  getPresetOptions() {
    return [`-preset ${this.config.preset}`];
  }

  getBitrateOptions() {
    const bitrates = this.getBitrateDistribution();
    if (this.eligibleForTwoPass()) {
      return [
        `-b:v ${bitrates.target}${bitrates.unit}`,
        `-minrate ${bitrates.min}${bitrates.unit}`,
        `-maxrate ${bitrates.max}${bitrates.unit}`,
      ];
    } else if (bitrates.max > 0) {
      // -bufsize is the peak possible bitrate at any moment, while -maxrate is the max rolling average bitrate
      return [
        `-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`,
        `-maxrate ${bitrates.max}${bitrates.unit}`,
        `-bufsize ${bitrates.max * 2}${bitrates.unit}`,
      ];
    } else {
      return [`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`];
    }
  }

  getInputThreadOptions(): Array<string> {
    return [];
  }

  getOutputThreadOptions(): Array<string> {
    if (this.config.threads <= 0) {
      return [];
    }
    return [`-threads ${this.config.threads}`];
  }

  eligibleForTwoPass() {
    if (!this.config.twoPass || this.config.accel !== TranscodeHardwareAcceleration.Disabled) {
      return false;
    }

    return this.isBitrateConstrained();
  }

  getBitrateDistribution() {
    const max = this.getMaxBitrateValue();
    const target = Math.ceil(max / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod
    const min = target / 2;
    const unit = this.getBitrateUnit();

    return { max, target, min, unit } as BitrateDistribution;
  }

  getTargetResolution(videoStream: VideoStreamInfo) {
    let target;
    target =
      this.config.targetResolution === 'original'
        ? Math.min(videoStream.height, videoStream.width)
        : Number.parseInt(this.config.targetResolution);

    if (target % 2 !== 0) {
      target -= 1;
    }

    return target;
  }

  shouldScale(videoStream: VideoStreamInfo) {
    const oddDimensions = videoStream.height % 2 !== 0 || videoStream.width % 2 !== 0;
    const largerThanTarget = Math.min(videoStream.height, videoStream.width) > this.getTargetResolution(videoStream);
    return oddDimensions || largerThanTarget;
  }

  shouldToneMap(videoStream: VideoStreamInfo) {
    return videoStream.isHDR && this.config.tonemap !== ToneMapping.Disabled;
  }

  getScaling(videoStream: VideoStreamInfo, mult = 2) {
    const targetResolution = this.getTargetResolution(videoStream);
    return this.isVideoVertical(videoStream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
  }

  getSize(videoStream: VideoStreamInfo) {
    const smaller = this.getTargetResolution(videoStream);
    const factor = Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width);
    let larger = Math.round(smaller * factor);
    if (larger % 2 !== 0) {
      larger -= 1;
    }
    return this.isVideoVertical(videoStream) ? { width: smaller, height: larger } : { width: larger, height: smaller };
  }

  isVideoRotated(videoStream: VideoStreamInfo) {
    return Math.abs(videoStream.rotation) === 90;
  }

  isVideoVertical(videoStream: VideoStreamInfo) {
    return videoStream.height > videoStream.width || this.isVideoRotated(videoStream);
  }

  isBitrateConstrained() {
    return this.getMaxBitrateValue() > 0;
  }

  getBitrateUnit() {
    const maxBitrate = this.getMaxBitrateValue();
    return this.config.maxBitrate.trim().slice(maxBitrate.toString().length) || 'k'; // use inputted unit if provided, else default to kbps
  }

  getMaxBitrateValue() {
    return Number.parseInt(this.config.maxBitrate) || 0;
  }

  getPresetIndex() {
    return this.presets.indexOf(this.config.preset);
  }

  getColors() {
    return {
      primaries: 'bt709',
      transfer: 'bt709',
      matrix: 'bt709',
    };
  }

  getToneMapping(videoStream: VideoStreamInfo) {
    if (!this.shouldToneMap(videoStream)) {
      return [];
    }

    const { primaries, transfer, matrix } = this.getColors();
    const options = `tonemapx=tonemap=${this.config.tonemap}:desat=0:p=${primaries}:t=${transfer}:m=${matrix}:r=pc:peak=100:format=yuv420p`;
    return [options];
  }

  getAudioCodec(): string {
    return this.config.targetAudioCodec;
  }

  getVideoCodec(): string {
    return this.config.targetVideoCodec;
  }

  getBFrames() {
    return this.config.bframes;
  }

  getRefs() {
    return this.config.refs;
  }

  getGopSize() {
    return this.config.gopSize;
  }

  useCQP() {
    return this.config.cqMode === CQMode.Cqp;
  }
}

export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
  protected device: string;
  protected interfaces: VideoInterfaces;

  constructor(
    protected config: SystemConfigFFmpegDto,
    interfaces: VideoInterfaces,
  ) {
    super(config);
    this.interfaces = interfaces;
    this.device = this.getDevice(interfaces);
  }

  getSupportedCodecs() {
    return [VideoCodec.H264, VideoCodec.Hevc];
  }

  validateDevices(devices: string[]) {
    if (devices.length === 0) {
      throw new Error('No /dev/dri devices found. If using Docker, make sure at least one /dev/dri device is mounted');
    }

    return devices.filter(function (device) {
      return device.startsWith('renderD') || device.startsWith('card');
    });
  }

  getDevice({ dri }: VideoInterfaces) {
    if (this.config.preferredHwDevice === 'auto') {
      // eslint-disable-next-line unicorn/no-array-reduce
      return `/dev/dri/${this.validateDevices(dri).reduce(function (a, b) {
        return a.localeCompare(b) < 0 ? b : a;
      })}`;
    }

    const deviceName = this.config.preferredHwDevice.replace('/dev/dri/', '');
    if (!dri.includes(deviceName)) {
      throw new Error(`Device '${deviceName}' does not exist. If using Docker, make sure this device is mounted`);
    }

    return `/dev/dri/${deviceName}`;
  }

  getVideoCodec(): string {
    return `${this.config.targetVideoCodec}_${this.config.accel}`;
  }

  getGopSize() {
    if (this.config.gopSize <= 0) {
      return 256;
    }
    return this.config.gopSize;
  }
}

export class ThumbnailConfig extends BaseConfig {
  static create(config: SystemConfigFFmpegDto): VideoCodecSWConfig {
    return new ThumbnailConfig(config);
  }

  getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] {
    // skip_frame nointra skips all frames for some MPEG-TS files. Look at ffmpeg tickets 7950 and 7895 for more details.
    const options =
      format?.formatName === 'mpegts'
        ? ['-sws_flags accurate_rnd+full_chroma_int']
        : ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];

    const metadataOverrides = [];
    if (videoStream.colorPrimaries === 'reserved') {
      metadataOverrides.push('colour_primaries=1');
    }

    if (videoStream.colorSpace === 'reserved') {
      metadataOverrides.push('matrix_coefficients=1');
    }

    if (videoStream.colorTransfer === 'reserved') {
      metadataOverrides.push('transfer_characteristics=1');
    }

    if (metadataOverrides.length > 0) {
      // workaround for https://fftrac-bg.ffmpeg.org/ticket/11020
      options.push(`-bsf:v ${videoStream.codecName}_metadata=${metadataOverrides.join(':')}`);
    }

    return options;
  }

  getBaseOutputOptions() {
    return ['-fps_mode vfr', '-frames:v 1', '-update 1'];
  }

  getFilterOptions(videoStream: VideoStreamInfo): string[] {
    return [
      'fps=12:start_time=0:eof_action=pass:round=down',
      'thumbnail=12',
      String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`,
      'trim=end_frame=2',
      'reverse',
      ...super.getFilterOptions(videoStream),
    ];
  }

  getPresetOptions() {
    return [];
  }

  getBitrateOptions() {
    return [];
  }

  eligibleForTwoPass() {
    return false;
  }

  getScaling(videoStream: VideoStreamInfo) {
    return super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc';
  }
}

export class H264Config extends BaseConfig {
  getOutputThreadOptions() {
    const options = super.getOutputThreadOptions();
    if (this.config.threads === 1) {
      options.push('-x264-params frame-threads=1:pools=none');
    }

    return options;
  }
}

export class HEVCConfig extends BaseConfig {
  getOutputThreadOptions() {
    const options = super.getOutputThreadOptions();
    if (this.config.threads === 1) {
      options.push('-x265-params frame-threads=1:pools=none');
    }

    return options;
  }
}

export class VP9Config extends BaseConfig {
  getPresetOptions() {
    const speed = Math.min(this.getPresetIndex(), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads
    if (speed >= 0) {
      return [`-cpu-used ${speed}`];
    }
    return [];
  }

  getBitrateOptions() {
    const bitrates = this.getBitrateDistribution();
    if (bitrates.max > 0 && this.eligibleForTwoPass()) {
      return [
        `-b:v ${bitrates.target}${bitrates.unit}`,
        `-minrate ${bitrates.min}${bitrates.unit}`,
        `-maxrate ${bitrates.max}${bitrates.unit}`,
      ];
    }

    return [`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`, `-b:v ${bitrates.max}${bitrates.unit}`];
  }

  getOutputThreadOptions() {
    return ['-row-mt 1', ...super.getOutputThreadOptions()];
  }

  eligibleForTwoPass() {
    return this.config.twoPass;
  }
}

export class AV1Config extends BaseConfig {
  getVideoCodec(): string {
    return 'libsvtav1';
  }

  getPresetOptions() {
    const speed = this.getPresetIndex() + 4; // Use 4 as slowest, giving us an effective range of 4-12 which is far more useful than 0-8
    if (speed >= 0) {
      return [`-preset ${speed}`];
    }
    return [];
  }

  getBitrateOptions() {
    const options = [`-crf ${this.config.crf}`];
    const bitrates = this.getBitrateDistribution();
    const svtparams = [];
    if (this.config.threads > 0) {
      svtparams.push(`lp=${this.config.threads}`);
    }
    if (bitrates.max > 0) {
      svtparams.push(`mbr=${bitrates.max}${bitrates.unit}`);
    }
    if (svtparams.length > 0) {
      options.push(`-svtav1-params ${svtparams.join(':')}`);
    }
    return options;
  }

  getOutputThreadOptions() {
    return []; // Already set above with svtav1-params
  }

  eligibleForTwoPass() {
    return this.config.twoPass;
  }
}

export class NvencSwDecodeConfig extends BaseHWConfig {
  getDevice() {
    return '0';
  }

  getSupportedCodecs() {
    return [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Av1];
  }

  getBaseInputOptions() {
    return [`-init_hw_device cuda=cuda:${this.device}`, '-filter_hw_device cuda'];
  }

  getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
    const options = [
      // below settings recommended from https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#command-line-for-latency-tolerant-high-quality-transcoding
      '-tune hq',
      '-qmin 0',
      '-rc-lookahead 20',
      '-i_qfactor 0.75',
      ...super.getBaseOutputOptions(target, videoStream, audioStream),
    ];
    if (this.getBFrames() > 0) {
      options.push('-b_ref_mode middle', '-b_qfactor 1.1');
    }
    if (this.config.temporalAQ) {
      options.push('-temporal-aq 1');
    }
    return options;
  }

  getFilterOptions(videoStream: VideoStreamInfo) {
    const options = this.getToneMapping(videoStream);
    options.push('hwupload_cuda');
    if (this.shouldScale(videoStream)) {
      options.push(`scale_cuda=${this.getScaling(videoStream)}:format=nv12`);
    }

    return options;
  }

  getPresetOptions() {
    let presetIndex = this.getPresetIndex();
    if (presetIndex < 0) {
      return [];
    }
    presetIndex = 7 - Math.min(6, presetIndex); // map to p1-p7; p7 is the highest quality, so reverse index
    return [`-preset p${presetIndex}`];
  }

  getBitrateOptions() {
    const bitrates = this.getBitrateDistribution();
    if (bitrates.max > 0 && this.config.twoPass) {
      return [
        `-b:v ${bitrates.target}${bitrates.unit}`,
        `-maxrate ${bitrates.max}${bitrates.unit}`,
        `-bufsize ${bitrates.target}${bitrates.unit}`,
        '-multipass 2',
      ];
    } else if (bitrates.max > 0) {
      return [
        `-cq:v ${this.config.crf}`,
        `-maxrate ${bitrates.max}${bitrates.unit}`,
        `-bufsize ${bitrates.target}${bitrates.unit}`,
      ];
    } else {
      return [`-cq:v ${this.config.crf}`];
    }
  }

  getThreadOptions() {
    return [];
  }

  getRefs() {
    const bframes = this.getBFrames();
    if (bframes > 0 && bframes < 3 && this.config.refs < 3) {
      return 0;
    }
    return this.config.refs;
  }
}

export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
  getBaseInputOptions() {
    return ['-hwaccel cuda', '-hwaccel_output_format cuda', '-noautorotate', ...this.getInputThreadOptions()];
  }

  getFilterOptions(videoStream: VideoStreamInfo) {
    const options = [];
    const tonemapOptions = this.getToneMapping(videoStream);
    if (this.shouldScale(videoStream) || (tonemapOptions.length === 0 && !videoStream.pixelFormat.endsWith('420p'))) {
      options.push(`scale_cuda=${this.getScaling(videoStream)}`);
    }
    options.push(...tonemapOptions);
    if (options.length > 0) {
      options[options.length - 1] += ':format=nv12';
    }
    return options;
  }

  getToneMapping(videoStream: VideoStreamInfo) {
    if (!this.shouldToneMap(videoStream)) {
      return [];
    }

    const { matrix, primaries, transfer } = this.getColors();
    const tonemapOptions = [
      'desat=0',
      `matrix=${matrix}`,
      `primaries=${primaries}`,
      'range=pc',
      `tonemap=${this.config.tonemap}`,
      'tonemap_mode=lum',
      `transfer=${transfer}`,
      'peak=100',
    ];

    return [`tonemap_cuda=${tonemapOptions.join(':')}`];
  }

  getInputThreadOptions() {
    return [`-threads 1`];
  }

  getOutputThreadOptions() {
    return [];
  }
}

export class QsvSwDecodeConfig extends BaseHWConfig {
  getBaseInputOptions() {
    return [`-init_hw_device qsv=hw,child_device=${this.device}`, '-filter_hw_device hw'];
  }

  getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
    const options = super.getBaseOutputOptions(target, videoStream, audioStream);
    // VP9 requires enabling low power mode https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/33583803e107b6d532def0f9d949364b01b6ad5a
    if (this.config.targetVideoCodec === VideoCodec.Vp9) {
      options.push('-low_power 1');
    }
    return options;
  }

  getFilterOptions(videoStream: VideoStreamInfo) {
    const options = this.getToneMapping(videoStream);
    options.push('hwupload=extra_hw_frames=64');
    if (this.shouldScale(videoStream)) {
      options.push(`scale_qsv=${this.getScaling(videoStream)}:mode=hq:format=nv12`);
    }
    return options;
  }

  getPresetOptions() {
    let presetIndex = this.getPresetIndex();
    if (presetIndex < 0) {
      return [];
    }
    presetIndex = Math.min(6, presetIndex) + 1; // 1 to 7
    return [`-preset ${presetIndex}`];
  }

  getBitrateOptions() {
    const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'} ${this.config.crf}`];
    const bitrates = this.getBitrateDistribution();
    if (bitrates.max > 0) {
      options.push(`-maxrate ${bitrates.max}${bitrates.unit}`, `-bufsize ${bitrates.max * 2}${bitrates.unit}`);
    }
    return options;
  }

  getSupportedCodecs() {
    return [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1];
  }

  // recommended from https://github.com/intel/media-delivery/blob/master/doc/benchmarks/intel-iris-xe-max-graphics/intel-iris-xe-max-graphics.md
  getBFrames() {
    if (this.config.bframes < 0) {
      return 7;
    }
    return this.config.bframes;
  }

  getRefs() {
    if (this.config.refs <= 0) {
      return 5;
    }
    return this.config.refs;
  }

  useCQP() {
    return this.config.cqMode === CQMode.Cqp || this.config.targetVideoCodec === VideoCodec.Vp9;
  }

  getScaling(videoStream: VideoStreamInfo): string {
    return super.getScaling(videoStream, 1);
  }
}

export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
  getBaseInputOptions() {
    return [
      '-hwaccel qsv',
      '-hwaccel_output_format qsv',
      '-async_depth 4',
      '-noautorotate',
      `-qsv_device ${this.device}`,
      ...this.getInputThreadOptions(),
    ];
  }

  getFilterOptions(videoStream: VideoStreamInfo) {
    const options = [];
    const tonemapOptions = this.getToneMapping(videoStream);
    if (tonemapOptions.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
      options.push(`scale_qsv=${this.getScaling(videoStream)}:async_depth=4:mode=hq:format=nv12`);
    } else if (this.shouldScale(videoStream)) {
      options.push(`scale_qsv=${this.getScaling(videoStream)}:async_depth=4:mode=hq`);
    }
    options.push(...tonemapOptions);
    return options;
  }

  getToneMapping(videoStream: VideoStreamInfo): string[] {
    if (!this.shouldToneMap(videoStream)) {
      return [];
    }

    const { matrix, primaries, transfer } = this.getColors();
    const tonemapOptions = [
      'desat=0',
      'format=nv12',
      `matrix=${matrix}`,
      `primaries=${primaries}`,
      `transfer=${transfer}`,
      'range=pc',
      `tonemap=${this.config.tonemap}`,
      'tonemap_mode=lum',
      'peak=100',
    ];

    return [
      'hwmap=derive_device=opencl',
      `tonemap_opencl=${tonemapOptions.join(':')}`,
      'hwmap=derive_device=qsv:reverse=1,format=qsv',
    ];
  }

  getInputThreadOptions() {
    return [`-threads 1`];
  }
}

export class VaapiSwDecodeConfig extends BaseHWConfig {
  getBaseInputOptions() {
    return [`-init_hw_device vaapi=accel:${this.device}`, '-filter_hw_device accel'];
  }

  getFilterOptions(videoStream: VideoStreamInfo) {
    const options = this.getToneMapping(videoStream);
    options.push('hwupload=extra_hw_frames=64');
    if (this.shouldScale(videoStream)) {
      options.push(`scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc:format=nv12`);
    }

    return options;
  }

  getPresetOptions() {
    let presetIndex = this.getPresetIndex();
    if (presetIndex < 0) {
      return [];
    }
    presetIndex = Math.min(6, presetIndex) + 1; // 1 to 7
    return [`-compression_level ${presetIndex}`];
  }

  getBitrateOptions() {
    const bitrates = this.getBitrateDistribution();
    const options = [];

    if (this.config.targetVideoCodec === VideoCodec.Vp9) {
      options.push('-bsf:v vp9_raw_reorder,vp9_superframe');
    }

    // VAAPI doesn't allow setting both quality and max bitrate
    if (bitrates.max > 0) {
      options.push(
        `-b:v ${bitrates.target}${bitrates.unit}`,
        `-maxrate ${bitrates.max}${bitrates.unit}`,
        `-minrate ${bitrates.min}${bitrates.unit}`,
        '-rc_mode 3',
      ); // variable bitrate
    } else if (this.useCQP()) {
      options.push(`-qp:v ${this.config.crf}`, `-global_quality:v ${this.config.crf}`, '-rc_mode 1');
    } else {
      options.push(`-global_quality:v ${this.config.crf}`, '-rc_mode 4');
    }

    return options;
  }

  getSupportedCodecs() {
    return [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1];
  }

  useCQP() {
    return this.config.cqMode !== CQMode.Icq || this.config.targetVideoCodec === VideoCodec.Vp9;
  }
}

export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
  getBaseInputOptions() {
    return [
      '-hwaccel vaapi',
      '-hwaccel_output_format vaapi',
      '-noautorotate',
      `-hwaccel_device ${this.device}`,
      ...this.getInputThreadOptions(),
    ];
  }

  getFilterOptions(videoStream: VideoStreamInfo) {
    const options = [];
    const tonemapOptions = this.getToneMapping(videoStream);
    if (tonemapOptions.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
      options.push(`scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc:format=nv12`);
    } else if (this.shouldScale(videoStream)) {
      options.push(`scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc`);
    }
    options.push(...tonemapOptions);
    return options;
  }

  getToneMapping(videoStream: VideoStreamInfo): string[] {
    if (!this.shouldToneMap(videoStream)) {
      return [];
    }

    const { matrix, primaries, transfer } = this.getColors();
    const tonemapOptions = [
      'desat=0',
      'format=nv12',
      `matrix=${matrix}`,
      `primaries=${primaries}`,
      `transfer=${transfer}`,
      'range=pc',
      `tonemap=${this.config.tonemap}`,
      'tonemap_mode=lum',
      'peak=100',
    ];

    return [
      'hwmap=derive_device=opencl',
      `tonemap_opencl=${tonemapOptions.join(':')}`,
      'hwmap=derive_device=vaapi:reverse=1,format=vaapi',
    ];
  }

  getInputThreadOptions() {
    return [`-threads 1`];
  }
}

export class RkmppSwDecodeConfig extends BaseHWConfig {
  eligibleForTwoPass(): boolean {
    return false;
  }

  getBaseInputOptions(): string[] {
    return [];
  }

  getPresetOptions() {
    switch (this.config.targetVideoCodec) {
      case VideoCodec.H264: {
        // from ffmpeg_mpp help, commonly referred to as H264 level 5.1
        return ['-level 51'];
      }
      case VideoCodec.Hevc: {
        // from ffmpeg_mpp help, commonly referred to as HEVC level 5.1
        return ['-level 153'];
      }
      default: {
        throw new Error(`Incompatible video codec for RKMPP: ${this.config.targetVideoCodec}`);
      }
    }
  }

  getBitrateOptions() {
    const bitrate = this.getMaxBitrateValue();
    if (bitrate > 0) {
      // -b:v specifies max bitrate, average bitrate is derived automatically...
      return ['-rc_mode AVBR', `-b:v ${bitrate}${this.getBitrateUnit()}`];
    }
    // use CRF value as QP value
    return ['-rc_mode CQP', `-qp_init ${this.config.crf}`];
  }

  getSupportedCodecs() {
    return [VideoCodec.H264, VideoCodec.Hevc];
  }

  getVideoCodec(): string {
    return `${this.config.targetVideoCodec}_rkmpp`;
  }
}

export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
  getBaseInputOptions() {
    return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate'];
  }

  getFilterOptions(videoStream: VideoStreamInfo) {
    if (this.shouldToneMap(videoStream)) {
      const { primaries, transfer, matrix } = this.getColors();
      if (this.interfaces.mali) {
        return [
          // use RKMPP for scaling, OpenCL for tone mapping
          `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1:async_depth=4`,
          'hwmap=derive_device=opencl:mode=read',
          `tonemap_opencl=format=nv12:r=pc:p=${primaries}:t=${transfer}:m=${matrix}:tonemap=${this.config.tonemap}:desat=0:tonemap_mode=lum:peak=100`,
          'hwmap=derive_device=rkmpp:mode=write:reverse=1',
          'format=drm_prime',
        ];
      }
      return [
        // use RKMPP for scaling, CPU for tone mapping (only works on RK3588, which supports 10-bit output)
        `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1:async_depth=4`,
        'hwdownload',
        'format=p010',
        `tonemapx=tonemap=${this.config.tonemap}:desat=0:p=${primaries}:t=${transfer}:m=${matrix}:r=pc:peak=100:format=yuv420p`,
        'hwupload',
      ];
    } else if (this.shouldScale(videoStream)) {
      return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1:async_depth=4`];
    }
    return [];
  }
}
