import { presetEncodingArguments } from '../presets/preset-registry';
import type { ExportPreset } from './convert';
import { metadataPolicyArguments, requirementsForUserPreset } from './convert';
import {
  assertMuxerMatchesExtension,
  createConcatListEntries,
  createPlannedInput,
  createPlannedOutput,
  diagnostic,
  mergeRequirements,
  normalizeSeconds,
  validateRange,
  workVirtualPath,
  type SourceReference,
} from './command-utils';
import {
  CommandPlanError,
  freezeCommandPlan,
  type CommandDiagnostic,
  type FFmpegCommandPlan,
  type PlannedTemporaryFile,
} from './command-plan';

export interface ConcatStreamDescription {
  readonly kind: 'video' | 'audio' | 'subtitle';
  readonly codec: string;
  readonly width?: number;
  readonly height?: number;
  readonly pixelFormat?: string;
  readonly timeBase?: string;
  readonly sampleRate?: number;
  readonly channelLayout?: string;
}

export interface ConcatSource extends SourceReference {
  /** Full probed source duration. */
  readonly durationSeconds: number;
  readonly sourceInSeconds?: number;
  readonly sourceOutSeconds?: number;
  readonly streams: readonly ConcatStreamDescription[];
}

export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject';
const MAX_CONCAT_SOURCES = 100;
export const MIN_CROSSFADE_SECONDS = 0.04;
export const MAX_CROSSFADE_SECONDS = 10;

export interface FastConcatOptions {
  readonly jobId: string;
  readonly sources: readonly ConcatSource[];
  readonly targetExtension: string;
  readonly targetMuxer: string;
}

export interface NormalizedConcatOptions extends FastConcatOptions {
  readonly preset: ExportPreset;
  readonly width: number;
  readonly height: number;
  readonly frameRate: number;
  readonly sampleRate?: number;
  readonly channelLayout?: 'mono' | 'stereo' | '5.1';
  readonly missingAudioPolicy: MissingAudioPolicy;
  readonly subtitlePolicy?: 'drop-all' | 'reject';
  /**
   * Uniform overlap at every edit. Zero/undefined keeps hard cuts. The command
   * layer bounds both the transition and the available material around edits.
   */
  readonly crossfadeSeconds?: number;
}

export function validateConcatCompatibility(
  sources: readonly ConcatSource[]
): readonly CommandDiagnostic[] {
  const diagnostics: CommandDiagnostic[] = [];
  if (sources.length < 2) {
    diagnostics.push(
      diagnostic(
        'concat-input-count',
        'error',
        'Concatenation requires at least two inputs.'
      )
    );
    return diagnostics;
  }
  if (sources.length > MAX_CONCAT_SOURCES) {
    diagnostics.push(
      diagnostic(
        'concat-input-limit',
        'error',
        `Concatenation is limited to ${MAX_CONCAT_SOURCES} browser inputs per operation.`
      )
    );
    return diagnostics;
  }
  sources.forEach((source, index) => {
    if (
      !Number.isFinite(source.durationSeconds) ||
      source.durationSeconds <= 0
    ) {
      diagnostics.push(
        diagnostic(
          'source-duration',
          'error',
          `Clip ${index + 1} must have a positive finite duration.`,
          `sources.${index}.durationSeconds`
        )
      );
    }
    try {
      selectedSourceRange(source);
    } catch (error) {
      diagnostics.push(
        diagnostic(
          'source-range',
          'error',
          error instanceof Error
            ? `Clip ${index + 1}: ${error.message}`
            : `Clip ${index + 1} has an invalid source range.`,
          `sources.${index}.sourceInSeconds`
        )
      );
    }
  });
  const baseline = sources[0]?.streams ?? [];
  sources.slice(1).forEach((source, sourceIndex) => {
    if (source.streams.length !== baseline.length) {
      diagnostics.push(
        diagnostic(
          'stream-arrangement',
          'error',
          `Clip ${sourceIndex + 2} has a different stream count.`
        )
      );
      return;
    }
    source.streams.forEach((stream, streamIndex) => {
      const expected = baseline[streamIndex];
      if (!expected || stream.kind !== expected.kind) {
        diagnostics.push(
          diagnostic(
            'stream-arrangement',
            'error',
            `Clip ${sourceIndex + 2}, stream ${streamIndex} has a different stream type.`
          )
        );
        return;
      }
      for (const key of comparableKeys(stream.kind)) {
        if (stream[key] !== expected[key]) {
          diagnostics.push(
            diagnostic(
              'stream-property',
              'error',
              `Clip ${sourceIndex + 2} has incompatible ${key}: ${String(
                stream[key]
              )} instead of ${String(expected[key])}.`,
              `sources.${sourceIndex + 1}.streams.${streamIndex}.${key}`
            )
          );
        }
      }
    });
  });
  return Object.freeze(diagnostics);
}

export function buildFastConcatPlan(
  options: FastConcatOptions
): FFmpegCommandPlan {
  assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
  const diagnostics = validateConcatCompatibility(options.sources);
  if (diagnostics.some((entry) => entry.severity === 'error')) {
    throw new CommandPlanError(
      'Inputs are not stream-copy compatible',
      diagnostics
    );
  }
  const inputs = options.sources.map((source) =>
    createPlannedInput(options.jobId, source)
  );
  const ranges = options.sources.map(selectedSourceRange);
  const concatPath = workVirtualPath(options.jobId, 'concat-list.txt');
  const temporaryFile: PlannedTemporaryFile = {
    path: concatPath,
    content: createConcatListEntries(
      inputs.map((input, index) => {
        const source = options.sources[index];
        const range = ranges[index];
        if (!source || !range) {
          throw new TypeError(`Missing concat source ${index + 1}`);
        }
        const hasExplicitRange =
          source.sourceInSeconds !== undefined ||
          source.sourceOutSeconds !== undefined;
        return {
          path: input.path,
          ...(hasExplicitRange
            ? {
                inPointSeconds: range.startSeconds,
                outPointSeconds: range.endSeconds,
              }
            : {}),
        };
      })
    ),
    purpose: 'concat-list',
  };
  const output = createPlannedOutput(options.jobId, {
    baseName: options.sources[0]?.fileName ?? 'sequence',
    operation: 'concat',
    extension: options.targetExtension,
  });
  const duration = ranges.reduce(
    (sum, range) => sum + range.durationSeconds,
    0
  );
  const hasTrimmedClips = options.sources.some(
    (source) =>
      source.sourceInSeconds !== undefined ||
      source.sourceOutSeconds !== undefined
  );
  return freezeCommandPlan({
    id: `${options.jobId}:concat`,
    operation: 'concat-fast',
    inputs,
    temporaryFiles: [temporaryFile],
    args: [
      '-f',
      'concat',
      '-safe',
      '0',
      '-i',
      concatPath,
      '-map',
      '0',
      '-c',
      'copy',
      '-progress',
      'pipe:1',
      '-nostats',
      output.path,
    ],
    outputs: [output],
    expectedDurationSeconds: duration,
    requiredCapabilities: { muxers: [options.targetMuxer] },
    diagnostics: [
      diagnostic(
        'generated-concat-list',
        'info',
        'The concat list contains only generated virtual paths; original filenames are not inserted.'
      ),
      ...(hasTrimmedClips
        ? [
            diagnostic(
              'concat-keyframe-boundaries',
              'warning' as const,
              'Fast concat in/out points use stream copy; packets around sparse keyframes can extend beyond the requested clip boundaries.'
            ),
          ]
        : []),
    ],
  });
}

export function buildNormalizedConcatPlan(
  options: NormalizedConcatOptions
): FFmpegCommandPlan {
  assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
  if (
    options.sources.length < 2 ||
    options.sources.length > MAX_CONCAT_SOURCES
  ) {
    throw new RangeError(
      `Concatenation requires 2–${MAX_CONCAT_SOURCES} inputs`
    );
  }
  if (!options.preset.video) {
    throw new TypeError('Normalized concatenation requires a video preset');
  }
  if (
    options.targetExtension.replace(/^\.+/u, '').toLowerCase() !==
    options.preset.fileExtension
  ) {
    throw new TypeError(
      `Normalized concat extension must match the preset (.${options.preset.fileExtension})`
    );
  }
  validateOutputGeometry(options.width, options.height, options.frameRate);
  if (
    options.sampleRate !== undefined &&
    (!Number.isSafeInteger(options.sampleRate) ||
      options.sampleRate < 8_000 ||
      options.sampleRate > 192_000)
  ) {
    throw new RangeError(
      'Normalized audio sample rate must be from 8000 to 192000 Hz'
    );
  }
  const ranges = options.sources.map(selectedSourceRange);
  for (const [index, source] of options.sources.entries()) {
    if (
      !Number.isFinite(source.durationSeconds) ||
      source.durationSeconds <= 0
    ) {
      throw new RangeError(`Clip ${index + 1} must have a positive duration`);
    }
    if (!source.streams.some((stream) => stream.kind === 'video')) {
      throw new TypeError(
        `Clip ${index + 1} has no video; synthetic video gaps are not implemented`
      );
    }
  }
  const hasAudio = options.sources.map((source) =>
    source.streams.some((stream) => stream.kind === 'audio')
  );
  const mixedAudioPresence =
    hasAudio.some(Boolean) && hasAudio.some((value) => !value);
  if (mixedAudioPresence && options.missingAudioPolicy === 'reject') {
    throw new CommandPlanError('Some clips have no audio stream', [
      diagnostic(
        'missing-audio',
        'error',
        'Choose insert silence or drop audio from every clip before concatenating.'
      ),
    ]);
  }
  const hasSubtitles = options.sources.some((source) =>
    source.streams.some((stream) => stream.kind === 'subtitle')
  );
  if (hasSubtitles && options.subtitlePolicy !== 'drop-all') {
    throw new CommandPlanError(
      'Normalized concatenation cannot retain subtitle streams implicitly',
      [
        diagnostic(
          'subtitle-policy-required',
          'error',
          'Choose the explicit drop-all subtitle policy, or remove subtitles before normalized concatenation.'
        ),
      ]
    );
  }
  const includeAudio =
    options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean);
  const crossfadeSeconds = options.crossfadeSeconds ?? 0;
  validateCrossfade(crossfadeSeconds, ranges);
  const inputs = options.sources.map((source) =>
    createPlannedInput(options.jobId, source)
  );
  const args: string[] = [];
  inputs.forEach((input) => args.push('-i', input.path));
  const graphParts: string[] = [];
  options.sources.forEach((_, index) => {
    const range = ranges[index];
    if (!range) {
      throw new TypeError(`Missing normalized concat range ${index + 1}`);
    }
    graphParts.push(
      `[${index}:v:0]trim=start=${normalizeSeconds(range.startSeconds)}:end=${normalizeSeconds(range.endSeconds)},setpts=PTS-STARTPTS,scale=${options.width}:${options.height}:force_original_aspect_ratio=decrease,pad=${options.width}:${options.height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${options.frameRate},format=yuv420p[v${index}]`
    );
    if (includeAudio) {
      if (hasAudio[index]) {
        graphParts.push(
          `[${index}:a:0]atrim=start=${normalizeSeconds(range.startSeconds)}:end=${normalizeSeconds(range.endSeconds)},asetpts=PTS-STARTPTS,aresample=${options.sampleRate ?? 48_000},aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}[a${index}]`
        );
      } else {
        graphParts.push(
          `anullsrc=r=${options.sampleRate ?? 48_000}:cl=${options.channelLayout ?? 'stereo'},atrim=duration=${normalizeSeconds(range.durationSeconds)},aformat=sample_fmts=fltp:channel_layouts=${options.channelLayout ?? 'stereo'}[a${index}]`
        );
      }
    }
  });
  if (crossfadeSeconds > 0) {
    let videoLabel = 'v0';
    let audioLabel = 'a0';
    let effectiveDuration = ranges[0]?.durationSeconds ?? 0;
    for (let index = 1; index < options.sources.length; index += 1) {
      const finalTransition = index === options.sources.length - 1;
      const nextVideoLabel = finalTransition ? 'vout' : `vxf${index}`;
      graphParts.push(
        `[${videoLabel}][v${index}]xfade=transition=fade:duration=${normalizeSeconds(crossfadeSeconds)}:offset=${normalizeSeconds(effectiveDuration - crossfadeSeconds)}[${nextVideoLabel}]`
      );
      videoLabel = nextVideoLabel;
      if (includeAudio) {
        const nextAudioLabel = finalTransition ? 'aout' : `axf${index}`;
        graphParts.push(
          `[${audioLabel}][a${index}]acrossfade=d=${normalizeSeconds(crossfadeSeconds)}:c1=tri:c2=tri[${nextAudioLabel}]`
        );
        audioLabel = nextAudioLabel;
      }
      effectiveDuration +=
        (ranges[index]?.durationSeconds ?? 0) - crossfadeSeconds;
    }
  } else {
    const labels = options.sources
      .map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
      .join('');
    graphParts.push(
      `${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
    );
  }
  args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]');
  if (includeAudio) {
    args.push('-map', '[aout]');
  }
  args.push(...presetEncodingArguments(options.preset));
  if (!includeAudio) {
    args.push('-an');
  }
  args.push(
    ...metadataPolicyArguments(options.preset),
    '-map_chapters',
    '-1',
    '-sn'
  );
  const output = createPlannedOutput(options.jobId, {
    baseName: options.sources[0]?.fileName ?? 'sequence',
    operation: 'concat',
    extension: options.targetExtension,
  });
  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}:concat`,
    operation: 'concat-normalized',
    inputs,
    temporaryFiles: [],
    args,
    outputs: [output],
    expectedDurationSeconds:
      ranges.reduce((sum, range) => sum + range.durationSeconds, 0) -
      crossfadeSeconds * (ranges.length - 1),
    requiredCapabilities: mergeRequirements(presetRequirements, {
      muxers: [options.targetMuxer],
      filters: [
        'trim',
        'setpts',
        'scale',
        'pad',
        'setsar',
        'fps',
        'format',
        ...(crossfadeSeconds > 0 ? ['xfade'] : ['concat']),
        ...(crossfadeSeconds > 0 && includeAudio ? ['acrossfade'] : []),
        ...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
        ...(mixedAudioPresence &&
        options.missingAudioPolicy === 'insert-silence'
          ? ['anullsrc', 'atrim']
          : []),
      ],
    }),
    diagnostics: [
      diagnostic(
        'normalized-concat',
        'info',
        crossfadeSeconds > 0
          ? `Clips are normalized and joined with a ${normalizeSeconds(crossfadeSeconds)} second audio/video crossfade at every edit.`
          : 'Clips are normalized to common video and audio properties, then re-encoded.'
      ),
      ...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence'
        ? [
            diagnostic(
              'inserted-silence',
              'warning' as const,
              'Silence is generated for clips without audio.'
            ),
          ]
        : []),
      ...(hasSubtitles
        ? [
            diagnostic(
              'dropped-subtitles',
              'warning' as const,
              'Subtitle streams are explicitly omitted from every normalized concat input.'
            ),
          ]
        : []),
    ],
  });
}

function validateCrossfade(
  crossfadeSeconds: number,
  ranges: readonly { readonly durationSeconds: number }[]
): void {
  if (!Number.isFinite(crossfadeSeconds) || crossfadeSeconds < 0) {
    throw new RangeError('Crossfade duration must be finite and nonnegative');
  }
  if (crossfadeSeconds === 0) {
    return;
  }
  if (
    crossfadeSeconds < MIN_CROSSFADE_SECONDS ||
    crossfadeSeconds > MAX_CROSSFADE_SECONDS
  ) {
    throw new RangeError(
      `Crossfade duration must be ${MIN_CROSSFADE_SECONDS}–${MAX_CROSSFADE_SECONDS} seconds`
    );
  }
  for (const [index, range] of ranges.entries()) {
    const required = index === 0 || index === ranges.length - 1 ? 1 : 2;
    if (range.durationSeconds < crossfadeSeconds * required) {
      throw new RangeError(
        `Clip ${index + 1} needs at least ${normalizeSeconds(crossfadeSeconds * required)} seconds for the requested crossfade material`
      );
    }
  }
}

function comparableKeys(
  kind: ConcatStreamDescription['kind']
): readonly (keyof ConcatStreamDescription)[] {
  if (kind === 'video') {
    return ['kind', 'codec', 'width', 'height', 'pixelFormat', 'timeBase'];
  }
  if (kind === 'audio') {
    return ['kind', 'codec', 'sampleRate', 'channelLayout', 'timeBase'];
  }
  return ['kind', 'codec', 'timeBase'];
}

function validateOutputGeometry(
  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(
      'Output dimensions must be even integer values from 2 to 8192'
    );
  }
  if (!Number.isFinite(frameRate) || frameRate <= 0 || frameRate > 240) {
    throw new RangeError('frameRate must be between 0 and 240');
  }
}

function selectedSourceRange(source: ConcatSource): {
  readonly startSeconds: number;
  readonly endSeconds: number;
  readonly durationSeconds: number;
} {
  const startSeconds = source.sourceInSeconds ?? 0;
  const endSeconds = source.sourceOutSeconds ?? source.durationSeconds;
  validateRange(startSeconds, endSeconds, source.durationSeconds);
  return Object.freeze({
    startSeconds,
    endSeconds,
    durationSeconds: endSeconds - startSeconds,
  });
}
