// Build the FFmpeg `-filter_complex` graph and final `-map`/codec args
// from `clips` + `tracks` + `assets` + `settings`. No side-effects — pure
// functions so the export pipeline stays easy to test.

import type {
  Clip,
  ColorGrading,
  ExportSettings,
  MediaAsset,
  TextOverlay,
  Track,
  VideoPosition,
} from '@/types';
import { buildVolumeExpression } from './volume-keyframes';

import { DEFAULT_COLOR, COLOR_PRESETS } from '@/types';

/** Build an eq/ccolor filter for a clip's color grading. */
function buildColorFilter(c: ColorGrading | undefined): string {
  if (!c) return '';
  // Skip if zero + not on a preset that's actually different from default.
  const def: ColorGrading = COLOR_PRESETS[c.preset ?? 'original'];
  if (
    !c.brightness && !c.contrast && !c.saturation && !c.hue && !c.sepia &&
    (!c.preset || c.preset === 'original')
  ) return '';
  // map -1..1 contrast → 0.5..1.5
  const cVal = (1 + (c.contrast ?? 0) * 0.5).toFixed(3);
  // map -1..1 saturation → 0..2
  const sVal = (1 + (c.saturation ?? 0)).toFixed(3);
  let f = `eq=brightness=${(c.brightness ?? 0).toFixed(3)}:contrast=${cVal}:saturation=${sVal}:gamma=1`;
  if (c.hue) f += `:hue=${(c.hue ?? 0).toFixed(0)}`;
  if (c.sepia && c.sepia > 0) f += `,colorchannelmixer=.393:.769:.189:.349:.686:.168:.272:.534:.131`;
  return `,${f}`;
}

/** Compute the FFmpeg overlay x,y + scale for a given VideoPosition. */
function overlayPlacement(
  pos: VideoPosition,
  scale: number,
  W: number,
  H: number,
): { xExpr: string; yExpr: string; scaleFactor: number } {
  const s = Math.max(0.05, Math.min(1, scale));
  if (pos === 'full') return { xExpr: '0', yExpr: '0', scaleFactor: 1 };
  // PIPs: scale to s * canvas; place with 4% margin from edge.
  const margin = Math.round(Math.min(W, H) * 0.03);
  const pipW = Math.round(W * s);
  const pipH = Math.round(H * s);
  const x = (canvasW: number) => canvasW - pipW - margin;
  const y = (canvasH: number) => canvasH - pipH - margin;
  switch (pos) {
    case 'pip-tl':
      return { xExpr: String(margin), yExpr: String(margin), scaleFactor: s };
    case 'pip-tr':
      return { xExpr: `main_w-overlay_w-${margin}`, yExpr: String(margin), scaleFactor: s };
    case 'pip-bl':
      return { xExpr: String(margin), yExpr: `main_h-overlay_h-${margin}`, scaleFactor: s };
    case 'pip-br':
      return { xExpr: `main_w-overlay_w-${margin}`, yExpr: `main_h-overlay_h-${margin}`, scaleFactor: s };
    case 'pip-c':
      return { xExpr: '(main_w-overlay_w)/2', yExpr: '(main_h-overlay_h)/2', scaleFactor: s };
    default:
      return { xExpr: '0', yExpr: '0', scaleFactor: 1 };
  }
}

interface BuiltGraph {
  inputArgs: string[];
  filterComplex: string[];
  finalVideoLabel?: string;
  finalAudioLabel?: string;
}

interface BuiltArgs {
  args: string[];
  finalVideoLabel?: string;
  finalAudioLabel?: string;
}

/**
 * Produce the ordered asset list (asset + clip pairs) from the timeline.
 */
export function collectAssetPaths(
  clips: Clip[],
  assets: MediaAsset[],
): { asset: MediaAsset; clip: Clip }[] {
  const byId = new Map(assets.map((a) => [a.id, a]));
  const ordered: { asset: MediaAsset; clip: Clip }[] = [];
  for (const c of clips) {
    const a = byId.get(c.assetId);
    if (a) ordered.push({ asset: a, clip: c });
  }
  return ordered;
}

/**
 * Build the entire filter-complex graph and the matching `-map` args.
 *
 * Strategy:
 *  1. For each video clip → trim+setpts+scale+pad → labelled segment
 *  2. Concat segments within each video track
 *  3. Overlay video tracks (top-down so the last track sits on top)
 *  4. For each audio clip → atrim+asetpts+volume+adelay → labelled segment
 *  5. Concat within each audio track, then `amix` across tracks
 */
export function buildFilterGraph(
  clips: Clip[],
  assets: MediaAsset[],
  tracks: Track[],
  settings: ExportSettings,
  subtitles?: { file: string },
  textOverlays: TextOverlay[] = [],
): BuiltGraph {
  const used = collectAssetPaths(clips, assets);

  // Index every distinct asset once into the input list.
  const inputArgs: string[] = [];
  const seenAssets = new Set<string>();
  for (const { asset } of used) {
    if (seenAssets.has(asset.id)) continue;
    seenAssets.add(asset.id);
    // Image assets need -loop 1 + duration; videos need stream-copy.
    if (asset.kind === 'image') {
      inputArgs.push('-loop', '1', '-i', `${asset.id}.bin`);
    } else {
      inputArgs.push('-i', `${asset.id}.bin`);
    }
  }

  const assetInputIdx = new Map<string, number>();
  used.forEach((u, i) => {
    if (!assetInputIdx.has(u.asset.id)) assetInputIdx.set(u.asset.id, i);
  });

  // Sort tracks: video first then audio, preserving declared order.
  const sortedTracks = [...tracks].sort((a, b) => {
    if (a.kind !== b.kind) return a.kind === 'video' ? -1 : 1;
    return 0;
  });

  const filterComplex: string[] = [];
  const videoTrackSegments = new Map<string, string[]>();
  const audioTrackSegments = new Map<string, string[]>();

  let segCounter = 0;

  for (const track of sortedTracks) {
    const trackClips = clips.filter((c) => c.trackId === track.id);
    if (trackClips.length === 0) continue;
    trackClips.sort((a, b) => a.start - b.start);

    if (track.kind === 'video') {
      const labels: string[] = [];
      for (let i = 0; i < trackClips.length; i++) {
        const clip = trackClips[i];
        const idx = assetInputIdx.get(clip.assetId)!;
        const start = clip.sourceIn;
        const dur = clip.duration;
        const assetMeta = assets.find((a) => a.id === clip.assetId);
        const bgColor = track.background?.color ?? 'black';
        const speed = clip.speed ?? 1;
        const colorFilter = buildColorFilter(clip.colorGrading);
        // fadeIn is encoded by extending the source trim and applying fade.
        const fadeIn = clip.transitionIn?.type === 'fadeIn'
          ? `,fade=t=in:st=${clip.start}:d=${(clip.transitionIn.durationMs / 1000).toFixed(3)}`
          : '';
        // fadeOut is applied at end of clip.
        const fadeOut = clip.transitionIn?.type === 'fadeOut'
          ? `,fade=t=out:st=${(clip.start + clip.duration - clip.transitionIn.durationMs / 1000).toFixed(3)}:d=${(clip.transitionIn.durationMs / 1000).toFixed(3)}`
          : '';
        // Speed ramp: PTS/SPEED changes playback rate. sourceIn is in source-time
        // so we keep trim+setpts+PTS but reset PTS to start (already happens).
        const speedFilter = speed !== 1 ? `,setpts=PTS/${speed.toFixed(3)}` : '';
        const label = `v${segCounter++}`;
        filterComplex.push(
          `[${idx}:v]trim=start=${start}:duration=${dur},setpts=PTS-STARTPTS${speedFilter},scale=${settings.width}:${settings.height}:force_original_aspect_ratio=decrease,pad=${settings.width}:${settings.height}:(ow-iw)/2:(oh-ih)/2:color=${bgColor}${colorFilter}${fadeIn}${fadeOut},setpts=PTS+${clip.start}/TB[${label}]`,
        );
        labels.push(`[${label}]`);

        // crossfade / dipToBlack are pairwise → emit xfade filter between this clip and previous one.
        if (i > 0) {
          const prevClip = trackClips[i - 1];
          const transition = prevClip.transitionIn; // transition declared on the previous clip's exit
          if (transition && (transition.type === 'crossfade' || transition.type === 'dipToBlack')) {
            const prevLabel = labels[labels.length - 2].replace(/[\[\]]/g, '');
            const xfadeOffset = Math.max(0, clip.start - transition.durationMs / 1000);
            const xfadeDur = Math.min(transition.durationMs / 1000, prevClip.duration, clip.duration);
            if (xfadeDur > 0.05) {
              const xLabel = `xv${segCounter++}`;
              if (transition.type === 'crossfade') {
                filterComplex.push(
                  `[${prevLabel}][${label}]xfade=transition=fade:duration=${xfadeDur.toFixed(3)}:offset=${xfadeOffset.toFixed(3)}[${xLabel}]`,
                );
              } else {
                filterComplex.push(
                  `[${prevLabel}][${label}]xfade=transition=fadeblack:duration=${xfadeDur.toFixed(3)}:offset=${xfadeOffset.toFixed(3)}[${xLabel}]`,
                );
              }
              // Replace the previous label and current label with the new merged label.
              labels[labels.length - 2] = `[${xLabel}]`;
              labels.pop();
            }
          }
        }
      }
      videoTrackSegments.set(track.id, labels);
    } else {
      const labels: string[] = [];
      // Build the optional noise-reduction stage once per track so every
      // clip on the track shares the same audio chain.
      // Maps `amount` 0..1 → `afftdn`'s `nf` (noise floor) range -80..-20 dB.
      // higher `amount` → lower noise floor → more aggressive denoising.
      const denoise = track.noiseReduction?.enabled
        ? `,afftdn=nf=${(-80 + track.noiseReduction.amount * 60).toFixed(1)}`
        : '';
      for (let i = 0; i < trackClips.length; i++) {
        const clip = trackClips[i];
        const idx = assetInputIdx.get(clip.assetId)!;
        const start = clip.sourceIn;
        const dur = clip.duration;
        const vol = track.muted ? 0 : track.volume;
        const volExpr = buildVolumeExpression(clip.volumeKeyframes, vol);
        const delay = Math.round(clip.start * 1000);
        const label = `a${segCounter++}`;
        const speed = clip.speed ?? 1;
        const audioSpeed = speed !== 1
          ? `,atempo=${speed <= 0.5 ? `${speed},atempo=${(speed * 2).toFixed(3)}` : speed.toFixed(3)}`
          : '';
        filterComplex.push(
          `[${idx}:a]atrim=start=${start}:duration=${dur},asetpts=PTS-STARTPTS${audioSpeed},volume='${volExpr}'${denoise},adelay=${delay}|${delay}[${label}]`,
        );
        labels.push(`[${label}]`);

        // Audio crossfade between consecutive clips on the same track.
        if (i > 0) {
          const prevClip = trackClips[i - 1];
          const transition = prevClip.transitionIn;
          if (transition?.type === 'crossfade' && transition.durationMs > 0) {
            const prevLabel = labels[labels.length - 2].replace(/[\[\]]/g, '');
            const xfadeDur = Math.min(transition.durationMs / 1000, prevClip.duration, clip.duration);
            const xLabel = `xa${segCounter++}`;
            if (xfadeDur > 0.05) {
              filterComplex.push(
                `[${prevLabel}][${label}]acrossfade=d=${xfadeDur.toFixed(3)}[${xLabel}]`,
              );
              labels[labels.length - 2] = `[${xLabel}]`;
              labels.pop();
            }
          }
        }
      }
      audioTrackSegments.set(track.id, labels);
    }
  }

  // Concat video segments per track → overlay across tracks.
  let finalVideoLabel: string | undefined;
  const videoTracks = sortedTracks.filter(
    (t) => t.kind === 'video' && (videoTrackSegments.get(t.id)?.length ?? 0) > 0,
  );

  if (videoTracks.length === 0) {
    const blackLabel = `vblack`;
    filterComplex.push(
      `color=black:${settings.width}x${settings.height}:duration=10:rate=${settings.fps}[${blackLabel}]`,
    );
    finalVideoLabel = blackLabel;
  } else {
    const trackConcatLabels: string[] = [];
    for (const t of videoTracks) {
      const segs = videoTrackSegments.get(t.id)!;
      if (segs.length === 1) {
        trackConcatLabels.push(segs[0]);
        continue;
      }
      const concatLabel = `vc${t.id}`;
      filterComplex.push(`${segs.join('')}concat=n=${segs.length}:v=1:a=0[${concatLabel}]`);
      trackConcatLabels.push(`[${concatLabel}]`);
    }
    if (trackConcatLabels.length === 1) {
      finalVideoLabel = trackConcatLabels[0].replace(/[\[\]]/g, '');
    } else {
      let acc = trackConcatLabels[0].replace(/[\[\]]/g, '');
      for (let i = 1; i < trackConcatLabels.length; i++) {
        const track = videoTracks[i];
        const isLast = i === trackConcatLabels.length - 1;
        const next = isLast ? 'vout' : `vovr${i}`;
        const nextLabel = trackConcatLabels[i].replace(/[\[\]]/g, '');
        // Determine placement for this overlay track.
        const pos: VideoPosition = track.videoPosition ?? 'full';
        const scale = track.videoScale ?? (pos === 'full' ? 1 : 0.3);
        const opacity = track.videoOpacity ?? 1;
        // Resize the overlay stream to the chosen scale before compositing.
        if (pos === 'full') {
          // No resize; opacity may be applied.
          const opFilter = opacity < 1 ? `,format=yuva420p,colorchannelmixer=aa=${opacity.toFixed(2)}` : '';
          if (opFilter) {
            filterComplex.push(`[${nextLabel}]${opFilter.trim().replace(/^,/, '')}[ovly${i}]`);
            filterComplex.push(`[${acc}][ovly${i}]overlay=shortest=1[${next}]`);
          } else {
            filterComplex.push(`[${acc}][${nextLabel}]overlay=shortest=1[${next}]`);
          }
        } else {
          const placement = overlayPlacement(pos, scale, settings.width, settings.height);
          const newW = Math.max(2, Math.round(settings.width * placement.scaleFactor));
          const newH = Math.max(2, Math.round(settings.height * placement.scaleFactor));
          // Even-dim for yuv420p
          const evenW = newW % 2 === 0 ? newW : newW - 1;
          const evenH = newH % 2 === 0 ? newH : newH - 1;
          const opFilter = opacity < 1 ? `,format=yuva420p,colorchannelmixer=aa=${opacity.toFixed(2)}` : '';
          const scaleFilter = `scale=${evenW}:${evenH}:force_original_aspect_ratio=decrease,pad=${evenW}:${evenH}:(ow-iw)/2:(oh-ih)/2${opFilter}`;
          filterComplex.push(`[${nextLabel}]${scaleFilter}[ovly${i}]`);
          filterComplex.push(
            `[${acc}][ovly${i}]overlay=${placement.xExpr}:${placement.yExpr}:shortest=1[${next}]`,
          );
        }
        acc = next;
      }
      finalVideoLabel = 'vout';
    }
  }

  // Audio: concat per track, then amix across tracks.
  const audioConcatLabels: string[] = [];
  for (const t of sortedTracks.filter((t) => t.kind === 'audio')) {
    const segs = audioTrackSegments.get(t.id);
    if (!segs || segs.length === 0) continue;
    if (segs.length === 1) {
      audioConcatLabels.push(segs[0]);
      continue;
    }
    const lbl = `ac${t.id}`;
    filterComplex.push(`${segs.join('')}concat=n=${segs.length}:v=0:a=1[${lbl}]`);
    audioConcatLabels.push(`[${lbl}]`);
  }

  let finalAudioLabel: string | undefined;
  if (audioConcatLabels.length === 0) {
    finalAudioLabel = undefined;
  } else if (audioConcatLabels.length === 1) {
    finalAudioLabel = audioConcatLabels[0].replace(/[\[\]]/g, '');
  } else {
    finalAudioLabel = 'amix';
    filterComplex.push(
      `${audioConcatLabels.join('')}amix=inputs=${audioConcatLabels.length}:duration=longest:dropout_transition=0[${finalAudioLabel}]`,
    );
  }

  return {
    inputArgs,
    filterComplex,
    finalVideoLabel,
    finalAudioLabel,
  };
}

/**
 * Build a drawtext filter chain for floating text overlays.
 * Each overlay generates a `drawtext=` segment applied to the current
 * video stream, chained (so multiple overlays compose).
 *
 * We only render overlays that overlap with the timeline duration —
 * others are filtered out.
 */
export function applyTextOverlays(
  graph: BuiltGraph,
  textOverlays: TextOverlay[],
  settings: ExportSettings,
): BuiltGraph {
  if (!graph.finalVideoLabel || textOverlays.length === 0) return graph;
  let acc = graph.finalVideoLabel;
  let counter = 0;
  for (const ov of textOverlays) {
    if (!ov.text) continue;
    const escText = ov.text
      .replace(/\\/g, '\\\\')
      .replace(/:/g, '\\:')
      .replace(/'/g, "\\'")
      .replace(/%/g, '\\%');
    const x = Math.round(settings.width * ov.xRatio);
    const y = Math.round(settings.height * ov.yRatio);
    const fontColor = ov.color.replace('#', '0x');
    const fontExpr = `fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf`; // Linux default. macOS bundled.
    const size = ov.fontSize;
    const animation =
      ov.animation === 'fade'
        ? `,alpha='if(lt(t,${ov.start}+0.3),(t-${ov.start})/0.3,if(lt(t,${ov.start + ov.duration - 0.3}),1,(${ov.start + ov.duration}-t)/0.3))'`
        : '';
    const newLabel = `vtxt${counter++}`;
    const enableExpr = `enable='between(t,${ov.start.toFixed(3)},${(ov.start + ov.duration).toFixed(3)})'`;
    graph.filterComplex.push(
      `[${acc}]drawtext=${fontExpr}:text='${escText}':x=${x}:y=${y}:fontsize=${size}:fontcolor=${fontColor}@${ov.opacity.toFixed(2)}:${ov.bold ? 'bold' : 'normal'}${animation}:${enableExpr}[${newLabel}]`,
    );
    acc = newLabel;
  }
  return { ...graph, finalVideoLabel: acc };
}

/**
 * Inject an optional subtitle file into the input args + filter graph so
 * the resulting mp4 has burned-in subtitles (using FFmpeg's `subtitles`
 * filter with `force_style` from the user's SubtitleStyle).
 *
 * Returns the modified graph + a new `finalVideoLabel` if subtitles apply.
 */
export function applySubtitleFilter(
  graph: BuiltGraph,
  assFile: string,
  settings: ExportSettings,
): { graph: BuiltGraph; finalVideoLabel: string } {
  if (!graph.finalVideoLabel) return { graph, finalVideoLabel: '' };
  const newLabel = 'vsubs';
  graph.filterComplex.push(
    `[${graph.finalVideoLabel}]subtitles=${assFile}:si=0:force_style='FontSize=${Math.round(settings.height * 0.04)}'[${newLabel}]`,
  );
  return { graph, finalVideoLabel: newLabel };
}

/**
 * Compose the final FFmpeg argv (input args + filter + codec + output).
 */
export function buildArgs(graph: BuiltGraph, settings: ExportSettings, outName: string): BuiltArgs {
  const { inputArgs, filterComplex, finalVideoLabel, finalAudioLabel } = graph;
  const args: string[] = ['-y', ...inputArgs];
  if (filterComplex.length > 0) {
    args.push('-filter_complex', filterComplex.join(';'));
  }
  if (finalVideoLabel) args.push('-map', `[${finalVideoLabel}]`);
  if (finalAudioLabel) args.push('-map', `[${finalAudioLabel}]`);

  args.push(
    '-c:v', 'libx264',
    '-preset', 'ultrafast',
    '-crf', '23',
    '-r', String(settings.fps),
    '-pix_fmt', 'yuv420p',
    '-movflags', '+faststart',
  );
  if (finalAudioLabel) {
    args.push('-c:a', 'aac', '-b:a', settings.audioBitrate, '-ar', '48000', '-ac', '2');
  } else {
    args.push('-an');
  }

  args.push(outName);
  return { args, finalVideoLabel, finalAudioLabel };
}