import {
  escapeFilterValue,
  mergeRequirements,
  normalizeSeconds,
} from './command-utils';
import type { PresetRequirements } from './command-plan';

export type VideoFilterStage =
  | 'trim'
  | 'crop'
  | 'rotate'
  | 'resize'
  | 'frame-rate'
  | 'format'
  | 'subtitles'
  | 'fade';

export type AudioFilterStage =
  'trim' | 'format' | 'gain' | 'normalization' | 'fade' | 'concat';

export const FADE_CURVES = ['tri', 'qsin', 'exp'] as const;
export type FadeCurve = (typeof FADE_CURVES)[number];

export interface FilterNode<TStage extends string> {
  readonly id: string;
  readonly stage: TStage;
  readonly expression: string;
  readonly requiredCapabilities?: PresetRequirements;
}

const VIDEO_ORDER: readonly VideoFilterStage[] = [
  'trim',
  'crop',
  'rotate',
  'resize',
  'frame-rate',
  'format',
  'subtitles',
  'fade',
];

const AUDIO_ORDER: readonly AudioFilterStage[] = [
  'trim',
  'format',
  'gain',
  'normalization',
  'fade',
  'concat',
];

export class FiltergraphBuilder<TStage extends string> {
  readonly #order: readonly TStage[];
  readonly #nodes: FilterNode<TStage>[] = [];

  constructor(order: readonly TStage[]) {
    this.#order = order;
  }

  add(node: FilterNode<TStage>): this {
    if (!node.id.trim() || !node.expression.trim()) {
      throw new TypeError('Filter node id and expression are required');
    }
    if (!this.#order.includes(node.stage)) {
      throw new TypeError(`Unknown filter stage: ${node.stage}`);
    }
    if (this.#nodes.some((existing) => existing.id === node.id)) {
      throw new TypeError(`Duplicate filter node id: ${node.id}`);
    }
    this.#nodes.push(Object.freeze({ ...node }));
    return this;
  }

  build(): {
    readonly graph?: string;
    readonly requirements: PresetRequirements;
  } {
    const order = new Map(this.#order.map((stage, index) => [stage, index]));
    const nodes = [...this.#nodes].sort(
      (left, right) =>
        (order.get(left.stage) ?? Number.MAX_SAFE_INTEGER) -
          (order.get(right.stage) ?? Number.MAX_SAFE_INTEGER) ||
        left.id.localeCompare(right.id)
    );
    const graph = nodes.map((node) => node.expression).join(',');
    return {
      ...(graph ? { graph } : {}),
      requirements: mergeRequirements(
        ...nodes.map((node) => node.requiredCapabilities ?? {})
      ),
    };
  }
}

export function createVideoFiltergraph(): FiltergraphBuilder<VideoFilterStage> {
  return new FiltergraphBuilder(VIDEO_ORDER);
}

export function createAudioFiltergraph(): FiltergraphBuilder<AudioFilterStage> {
  return new FiltergraphBuilder(AUDIO_ORDER);
}

export function subtitleFilter(path: string): FilterNode<VideoFilterStage> {
  return {
    id: 'subtitles',
    stage: 'subtitles',
    expression: `subtitles=filename='${escapeFilterValue(path)}'`,
    requiredCapabilities: { filters: ['subtitles'] },
  };
}

export function audioFadeFilters(
  durationSeconds: number,
  fadeInSeconds?: number,
  fadeOutSeconds?: number,
  curve: FadeCurve = 'tri'
): readonly FilterNode<AudioFilterStage>[] {
  assertFadeCurve(curve);
  const nodes: FilterNode<AudioFilterStage>[] = [];
  if (fadeInSeconds !== undefined && fadeInSeconds > 0) {
    if (fadeInSeconds > durationSeconds) {
      throw new RangeError('Audio fade-in exceeds clip duration');
    }
    nodes.push({
      id: 'audio-fade-in',
      stage: 'fade',
      expression: `afade=t=in:st=0:d=${normalizeSeconds(fadeInSeconds)}:curve=${curve}`,
      requiredCapabilities: { filters: ['afade'] },
    });
  }
  if (fadeOutSeconds !== undefined && fadeOutSeconds > 0) {
    if (fadeOutSeconds > durationSeconds) {
      throw new RangeError('Audio fade-out exceeds clip duration');
    }
    nodes.push({
      id: 'audio-fade-out',
      stage: 'fade',
      expression: `afade=t=out:st=${normalizeSeconds(
        durationSeconds - fadeOutSeconds
      )}:d=${normalizeSeconds(fadeOutSeconds)}:curve=${curve}`,
      requiredCapabilities: { filters: ['afade'] },
    });
  }
  return Object.freeze(nodes);
}

export function videoFadeFilters(
  durationSeconds: number,
  fadeInSeconds?: number,
  fadeOutSeconds?: number,
  curve: FadeCurve = 'tri'
): readonly FilterNode<VideoFilterStage>[] {
  assertFadeCurve(curve);
  const nodes: FilterNode<VideoFilterStage>[] = [];
  if (fadeInSeconds !== undefined && fadeInSeconds > 0) {
    if (fadeInSeconds > durationSeconds) {
      throw new RangeError('Video fade-in exceeds clip duration');
    }
    nodes.push({
      id: 'video-fade-in',
      stage: 'fade',
      expression:
        curve === 'tri'
          ? `fade=t=in:st=0:d=${normalizeSeconds(fadeInSeconds)}:color=black`
          : curvedVideoFadeExpression(
              durationSeconds,
              fadeInSeconds,
              'in',
              curve
            ),
      requiredCapabilities: { filters: [curve === 'tri' ? 'fade' : 'geq'] },
    });
  }
  if (fadeOutSeconds !== undefined && fadeOutSeconds > 0) {
    if (fadeOutSeconds > durationSeconds) {
      throw new RangeError('Video fade-out exceeds clip duration');
    }
    nodes.push({
      id: 'video-fade-out',
      stage: 'fade',
      expression:
        curve === 'tri'
          ? `fade=t=out:st=${normalizeSeconds(
              durationSeconds - fadeOutSeconds
            )}:d=${normalizeSeconds(fadeOutSeconds)}:color=black`
          : curvedVideoFadeExpression(
              durationSeconds,
              fadeOutSeconds,
              'out',
              curve
            ),
      requiredCapabilities: { filters: [curve === 'tri' ? 'fade' : 'geq'] },
    });
  }
  return Object.freeze(nodes);
}

function assertFadeCurve(value: unknown): asserts value is FadeCurve {
  if (!FADE_CURVES.includes(value as FadeCurve)) {
    throw new TypeError('Fade curve must be tri, qsin, or exp');
  }
}

function curvedVideoFadeExpression(
  durationSeconds: number,
  fadeSeconds: number,
  direction: 'in' | 'out',
  curve: Exclude<FadeCurve, 'tri'>
): string {
  const duration = normalizeSeconds(durationSeconds);
  const fade = normalizeSeconds(fadeSeconds);
  const progress =
    direction === 'in'
      ? `clip(T/${fade},0,1)`
      : `clip((${duration}-T)/${fade},0,1)`;
  const gain =
    curve === 'qsin'
      ? `sin(PI/2*${progress})`
      : `((exp(5*${progress})-1)/(exp(5)-1))`;
  return [
    `geq=lum_expr='lum(X,Y)*${gain}'`,
    `cb_expr='128+(cb(X,Y)-128)*${gain}'`,
    `cr_expr='128+(cr(X,Y)-128)*${gain}'`,
  ].join(':');
}
