import { presetEncodingArguments } from '../presets/preset-registry';
import type { ExportPreset } from './convert';
import { requirementsForUserPreset } from './convert';
import {
  createPlannedInput,
  createPlannedOutput,
  diagnostic,
  mergeRequirements,
  normalizeSeconds,
  validateRange,
  type SourceReference,
} from './command-utils';
import { audioFadeFilters, videoFadeFilters } from './filtergraph-builder';
import type { FadeCurve } from './filtergraph-builder';
import type {
  LoudnessMeasurement,
  LoudnessTargets,
  PeakMeasurement,
} from './loudness';
import {
  buildPeakNormalizationFilter,
  validateLoudnessMeasurement,
} from './loudness';
import { buildTransformFiltergraph, type TransformSettings } from './transform';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';

export type TimelineAudioNormalization =
  | {
      readonly mode: 'peak';
      readonly targetPeakDb: number;
      readonly measurement: PeakMeasurement;
    }
  | {
      readonly mode: 'loudness';
      readonly targets: LoudnessTargets;
      readonly measurement: LoudnessMeasurement;
    };

export interface TimelineExportClip {
  readonly id: string;
  readonly source: SourceReference;
  readonly sourceInSeconds: number;
  readonly sourceOutSeconds: number;
  readonly sourceDurationSeconds?: number;
  readonly hasVideo: boolean;
  readonly hasAudio: boolean;
  readonly transform?: Omit<
    TransformSettings,
    'frameRate' | 'pixelFormat' | 'fadeNodes' | 'subtitleFilterExpression'
  >;
  readonly audioGainDb?: number;
  readonly audioNormalization?: TimelineAudioNormalization;
  readonly audioFadeInSeconds?: number;
  readonly audioFadeOutSeconds?: number;
  readonly audioFadeCurve?: FadeCurve;
  readonly videoFadeInSeconds?: number;
  readonly videoFadeOutSeconds?: number;
  readonly videoFadeCurve?: FadeCurve;
}

export interface TimelineExportOptions {
  readonly jobId: string;
  readonly clips: readonly TimelineExportClip[];
  readonly preset: ExportPreset;
  readonly outputWidth: number;
  readonly outputHeight: number;
  readonly frameRate: number;
  readonly sampleRate?: number;
  readonly channelLayout?: 'mono' | 'stereo' | '5.1';
  readonly missingAudioPolicy: 'insert-silence' | 'drop-all' | 'reject';
  readonly outputFileName?: string;
}

/**
 * Creates one normalized sequential export command. It composes per-clip
 * transforms into a deterministic filter_complex graph and emits one output.
 * Loudness clips require a measurement produced by the first-pass builder.
 */
export function buildTimelineExportPlan(
  options: TimelineExportOptions
): FFmpegCommandPlan {
  if (options.clips.length === 0 || options.clips.length > 100) {
    throw new RangeError('Timeline export requires 1–100 clips');
  }
  if (!options.preset.video) {
    throw new TypeError('Timeline export requires a video preset');
  }
  validateOutput(options.outputWidth, options.outputHeight, options.frameRate);
  if (
    options.sampleRate !== undefined &&
    (!Number.isSafeInteger(options.sampleRate) ||
      options.sampleRate < 8_000 ||
      options.sampleRate > 192_000)
  ) {
    throw new RangeError(
      'Timeline audio sample rate must be from 8000 to 192000 Hz'
    );
  }
  const ids = new Set<string>();
  for (const clip of options.clips) {
    if (!clip.id || ids.has(clip.id)) {
      throw new TypeError('Timeline clip IDs must be nonempty and unique');
    }
    ids.add(clip.id);
    if (!clip.hasVideo) {
      throw new TypeError(
        'Timeline export currently requires video in every clip'
      );
    }
    validateRange(
      clip.sourceInSeconds,
      clip.sourceOutSeconds,
      clip.sourceDurationSeconds
    );
  }
  const audioPresence = options.clips.map((clip) => clip.hasAudio);
  const mixedAudio =
    audioPresence.some(Boolean) && audioPresence.some((value) => !value);
  if (mixedAudio && options.missingAudioPolicy === 'reject') {
    throw new TypeError(
      'Some clips have no audio; choose insert-silence or drop-all explicitly'
    );
  }
  const includeAudio =
    options.missingAudioPolicy !== 'drop-all' && audioPresence.some(Boolean);
  if (includeAudio && !options.preset.audio) {
    throw new TypeError('Selected preset does not include an audio encoder');
  }
  const inputs = options.clips.map((clip) =>
    createPlannedInput(options.jobId, clip.source)
  );
  const args: string[] = [];
  inputs.forEach((input) => args.push('-i', input.path));
  const graph: string[] = [];
  const requiredFilters = new Set<string>([
    'trim',
    'setpts',
    'scale',
    'pad',
    'setsar',
    'fps',
    'format',
    'concat',
  ]);
  if (includeAudio) {
    ['atrim', 'asetpts', 'aresample', 'aformat'].forEach((filter) =>
      requiredFilters.add(filter)
    );
  }
  options.clips.forEach((clip, index) => {
    const duration = clip.sourceOutSeconds - clip.sourceInSeconds;
    validateFadePair(
      duration,
      clip.videoFadeInSeconds,
      clip.videoFadeOutSeconds,
      'Video'
    );
    validateFadePair(
      duration,
      clip.audioFadeInSeconds,
      clip.audioFadeOutSeconds,
      'Audio'
    );
    const transform = clip.transform
      ? buildTransformFiltergraph(clip.transform)
      : { graph: undefined, requirements: {} };
    for (const filter of transform.requirements?.filters ?? []) {
      requiredFilters.add(filter);
    }
    const videoChain = [
      `trim=start=${normalizeSeconds(clip.sourceInSeconds)}:end=${normalizeSeconds(clip.sourceOutSeconds)}`,
      'setpts=PTS-STARTPTS',
      ...(transform.graph ? [transform.graph] : []),
      `scale=${options.outputWidth}:${options.outputHeight}:force_original_aspect_ratio=decrease`,
      `pad=${options.outputWidth}:${options.outputHeight}:(ow-iw)/2:(oh-ih)/2`,
      'setsar=1',
      `fps=${options.frameRate}`,
      'format=yuv420p',
      ...videoFadeFilters(
        duration,
        clip.videoFadeInSeconds,
        clip.videoFadeOutSeconds,
        clip.videoFadeCurve
      ).map((node) => {
        for (const filter of node.requiredCapabilities?.filters ?? []) {
          requiredFilters.add(filter);
        }
        return node.expression;
      }),
    ];
    graph.push(`[${index}:v:0]${videoChain.join(',')}[v${index}]`);

    if (includeAudio) {
      if (clip.hasAudio) {
        const audioChain = [
          `atrim=start=${normalizeSeconds(clip.sourceInSeconds)}:end=${normalizeSeconds(clip.sourceOutSeconds)}`,
          'asetpts=PTS-STARTPTS',
          `aresample=${options.sampleRate ?? 48_000}`,
          `aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}`,
        ];
        if (clip.audioGainDb !== undefined) {
          if (
            !Number.isFinite(clip.audioGainDb) ||
            Math.abs(clip.audioGainDb) > 60
          ) {
            throw new RangeError('Audio gain must be between -60 and 60 dB');
          }
          audioChain.push(`volume=${signed(clip.audioGainDb)}dB`);
          requiredFilters.add('volume');
        }
        if (clip.audioNormalization) {
          audioChain.push(normalizationFilter(clip.audioNormalization));
          requiredFilters.add(
            clip.audioNormalization.mode === 'peak' ? 'volume' : 'loudnorm'
          );
        }
        audioChain.push(
          ...audioFadeFilters(
            duration,
            clip.audioFadeInSeconds,
            clip.audioFadeOutSeconds,
            clip.audioFadeCurve
          ).map((node) => {
            requiredFilters.add('afade');
            return node.expression;
          })
        );
        graph.push(`[${index}:a:0]${audioChain.join(',')}[a${index}]`);
      } else {
        requiredFilters.add('anullsrc');
        graph.push(
          `anullsrc=r=${options.sampleRate ?? 48_000}:cl=${options.channelLayout ?? 'stereo'},atrim=duration=${normalizeSeconds(duration)},aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}[a${index}]`
        );
      }
    }
  });
  const labels = options.clips
    .map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
    .join('');
  graph.push(
    `${labels}concat=n=${options.clips.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
  );
  args.push('-filter_complex', graph.join(';'), '-map', '[vout]');
  if (includeAudio) {
    args.push('-map', '[aout]');
  }
  args.push(...presetEncodingArguments(options.preset));
  if (!includeAudio) {
    args.push('-an');
  }
  args.push(
    '-map_metadata',
    options.preset.metadataPolicy === 'remove' ? '-1' : '0',
    '-map_chapters',
    '-1',
    '-sn'
  );
  const output = createPlannedOutput(options.jobId, {
    baseName: options.clips[0]?.source.fileName ?? 'timeline',
    operation: 'timeline',
    extension: options.preset.fileExtension,
    ...(options.outputFileName ? { fileName: options.outputFileName } : {}),
  });
  args.push('-progress', 'pipe:1', '-nostats', output.path);
  const presetRequirements =
    'builtIn' in options.preset
      ? options.preset.requirements
      : requirementsForUserPreset(options.preset);
  return freezeCommandPlan({
    id: `${options.jobId}:timeline-export`,
    operation: 'timeline-export',
    inputs,
    temporaryFiles: [],
    args,
    outputs: [output],
    expectedDurationSeconds: options.clips.reduce(
      (sum, clip) => sum + clip.sourceOutSeconds - clip.sourceInSeconds,
      0
    ),
    requiredCapabilities: mergeRequirements(presetRequirements, {
      filters: [...requiredFilters],
    }),
    diagnostics: [
      diagnostic(
        'timeline-reencode',
        'info',
        'Sequential timeline clips are trimmed, normalized, concatenated, and re-encoded in one command.'
      ),
      ...(options.preset.chapterPolicy !== 'remove'
        ? [
            diagnostic(
              'timeline-chapters-omitted',
              'warning' as const,
              'Source chapters are omitted because their times do not automatically match the edited timeline; mux validated project chapters explicitly.'
            ),
          ]
        : []),
      ...(options.preset.subtitlePolicy !== 'none'
        ? [
            diagnostic(
              'timeline-subtitles-omitted',
              'warning' as const,
              'Subtitle streams are omitted from this filtered timeline plan; use an explicit compatible mux or burn-in plan.'
            ),
          ]
        : []),
      ...(mixedAudio && options.missingAudioPolicy === 'insert-silence'
        ? [
            diagnostic(
              'inserted-silence',
              'warning' as const,
              'Silence is inserted for timeline clips without audio.'
            ),
          ]
        : []),
    ],
  });
}

function normalizationFilter(
  normalization: TimelineAudioNormalization
): string {
  if (normalization.mode === 'peak') {
    return buildPeakNormalizationFilter(
      normalization.targetPeakDb,
      normalization.measurement
    );
  }
  validateLoudnessMeasurement(normalization.measurement);
  const { targets, measurement } = normalization;
  if (
    !Number.isFinite(targets.integratedLufs) ||
    targets.integratedLufs < -70 ||
    targets.integratedLufs > -5 ||
    !Number.isFinite(targets.truePeakDbtp) ||
    targets.truePeakDbtp < -9 ||
    targets.truePeakDbtp > 0 ||
    !Number.isFinite(targets.loudnessRangeLu) ||
    targets.loudnessRangeLu < 1 ||
    targets.loudnessRangeLu > 50
  ) {
    throw new RangeError('Loudness targets are outside supported ranges');
  }
  return [
    `loudnorm=I=${signed(targets.integratedLufs)}`,
    `TP=${signed(targets.truePeakDbtp)}`,
    `LRA=${normalizeSeconds(targets.loudnessRangeLu)}`,
    `measured_I=${signed(measurement.inputIntegratedLufs)}`,
    `measured_TP=${signed(measurement.inputTruePeakDbtp)}`,
    `measured_LRA=${normalizeSeconds(measurement.inputLoudnessRangeLu)}`,
    `measured_thresh=${signed(measurement.inputThresholdLufs)}`,
    `offset=${signed(measurement.targetOffsetLu ?? 0)}`,
    'linear=true',
  ].join(':');
}

function validateOutput(
  width: number,
  height: number,
  frameRate: number
): void {
  if (
    !Number.isSafeInteger(width) ||
    !Number.isSafeInteger(height) ||
    width < 2 ||
    height < 2 ||
    width > 8192 ||
    height > 8192 ||
    width % 2 !== 0 ||
    height % 2 !== 0
  ) {
    throw new RangeError(
      'Timeline dimensions must be even integers from 2 to 8192'
    );
  }
  if (!Number.isFinite(frameRate) || frameRate <= 0 || frameRate > 240) {
    throw new RangeError('Timeline frame rate must be between 0 and 240');
  }
}

function signed(value: number): string {
  if (!Number.isFinite(value)) {
    throw new RangeError('Filter value must be finite');
  }
  return value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '');
}

function validateFadePair(
  durationSeconds: number,
  fadeInSeconds: number | undefined,
  fadeOutSeconds: number | undefined,
  label: string
): void {
  const fadeIn = fadeInSeconds ?? 0;
  const fadeOut = fadeOutSeconds ?? 0;
  if (
    !Number.isFinite(fadeIn) ||
    !Number.isFinite(fadeOut) ||
    fadeIn < 0 ||
    fadeOut < 0 ||
    fadeIn + fadeOut > durationSeconds
  ) {
    throw new RangeError(
      `${label} fade durations must be nonnegative and cannot overlap`
    );
  }
}
