import {
  createPlannedInput,
  createPlannedOutput,
  diagnostic,
  normalizeSeconds,
  safePathComponent,
  type SourceReference,
} from './command-utils';
import {
  GENERATED_OUTPUT_LIMIT_EXPLANATION,
  MAX_GENERATED_OUTPUT_FILES,
} from '../limits';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';

export type ImageFormat = 'png' | 'jpeg' | 'webp';

export const MAX_SCENE_CHANGE_FRAMES = 100;
export const MIN_SCENE_CHANGE_THRESHOLD = 0.01;
export const MAX_SCENE_CHANGE_THRESHOLD = 0.95;

export interface SceneChangeFrame {
  /** Exact source presentation timestamp in the source video time base. */
  readonly pts: number;
  readonly timeSeconds: number;
}

export interface ThumbnailSize {
  readonly width?: number;
  readonly height?: number;
  readonly maintainAspect?: boolean;
}

export interface SingleThumbnailOptions {
  readonly jobId: string;
  readonly source: SourceReference;
  readonly timeSeconds: number;
  readonly size?: ThumbnailSize;
  readonly format: ImageFormat;
  readonly quality?: number;
}

export interface ThumbnailSeriesOptions {
  readonly jobId: string;
  readonly source: SourceReference;
  readonly durationSeconds: number;
  readonly timesSeconds?: readonly number[];
  readonly intervalSeconds?: number;
  readonly count?: number;
  readonly size?: ThumbnailSize;
  readonly format: ImageFormat;
  readonly quality?: number;
}

export interface SceneChangeAnalysisOptions {
  readonly jobId: string;
  readonly source: SourceReference;
  readonly durationSeconds: number;
  readonly threshold: number;
  readonly maxFrames: number;
}

export interface SceneThumbnailSeriesOptions extends Omit<
  ThumbnailSeriesOptions,
  'timesSeconds' | 'intervalSeconds' | 'count'
> {
  readonly frames: readonly SceneChangeFrame[];
}

export interface ContactSheetOptions {
  readonly jobId: string;
  readonly source: SourceReference;
  readonly durationSeconds: number;
  readonly frameCount: number;
  readonly rows: number;
  readonly columns: number;
  readonly cellWidth: number;
  readonly spacing?: number;
  readonly background?: string;
  readonly maintainAspect?: boolean;
  readonly timestampLabels?: boolean;
  readonly sourceLabel?: boolean;
  readonly format: ImageFormat;
  readonly quality?: number;
  readonly drawtextAvailable?: boolean;
  /** Reviewed local font mounted as a second, read-only job input. */
  readonly font?: SourceReference;
  /** Exact frames returned by a preceding bounded scene-change analysis. */
  readonly sceneFrames?: readonly SceneChangeFrame[];
}

export function buildSingleThumbnailPlan(
  options: SingleThumbnailOptions
): FFmpegCommandPlan {
  if (!Number.isFinite(options.timeSeconds) || options.timeSeconds < 0) {
    throw new RangeError('Thumbnail time must be nonnegative');
  }
  const input = createPlannedInput(options.jobId, options.source);
  const extension = extensionForImageFormat(options.format);
  const output = createPlannedOutput(options.jobId, {
    baseName: options.source.fileName,
    operation: 'thumbnail',
    suffix: `${Math.round(options.timeSeconds * 1000)}ms`,
    extension,
    role: 'thumbnail',
  });
  const args = [
    '-ss',
    normalizeSeconds(options.timeSeconds),
    '-i',
    input.path,
    '-frames:v',
    '1',
    ...imageFilterArguments(options.size),
    ...imageQualityArguments(options.format, options.quality),
    output.path,
  ];
  return freezeCommandPlan({
    id: `${options.jobId}:thumbnail`,
    operation: 'thumbnail',
    inputs: [input],
    temporaryFiles: [],
    args,
    outputs: [output],
    requiredCapabilities: imageRequirements(options.format),
    diagnostics: [],
  });
}

export function buildThumbnailSeriesPlan(
  options: ThumbnailSeriesOptions
): FFmpegCommandPlan {
  const times = resolveThumbnailTimes(options);
  const input = createPlannedInput(options.jobId, options.source);
  const extension = extensionForImageFormat(options.format);
  const outputs = times.map((time, index) =>
    createPlannedOutput(options.jobId, {
      id: `thumbnail-${index + 1}`,
      baseName: options.source.fileName,
      operation: 'thumbnail',
      suffix: `${String(index + 1).padStart(3, '0')}-${Math.round(time * 1000)}ms`,
      extension,
      role: 'thumbnail',
    })
  );
  const size = scaleFilter(options.size);
  const splitOutputs = times
    .map((_, index) => `[thumb-source-${index}]`)
    .join('');
  const filtergraph = [
    `[0:v]split=${times.length}${splitOutputs}`,
    ...times.map((time, index) => {
      const filters = [
        `select='gte(t\\,${normalizeSeconds(time)})'`,
        'setpts=N/FRAME_RATE/TB',
        ...(size ? [size] : []),
      ];
      return `[thumb-source-${index}]${filters.join(',')}[thumb-${index}]`;
    }),
  ].join(';');
  const outputArguments = outputs.flatMap((output, index) => [
    '-map',
    `[thumb-${index}]`,
    '-frames:v',
    '1',
    ...imageQualityArguments(options.format, options.quality),
    output.path,
  ]);
  return freezeCommandPlan({
    id: `${options.jobId}:thumbnail-series`,
    operation: 'thumbnail-series',
    inputs: [input],
    temporaryFiles: [],
    args: [
      '-i',
      input.path,
      '-filter_complex',
      filtergraph,
      ...outputArguments,
    ],
    outputs,
    expectedDurationSeconds: options.durationSeconds,
    requiredCapabilities: {
      ...imageRequirements(options.format),
      filters: ['split', 'select', 'setpts', ...(size ? ['scale'] : [])],
    },
    diagnostics: [
      diagnostic(
        'single-pass-series',
        'info',
        'The thumbnail series is decoded in one FFmpeg invocation.'
      ),
    ],
  });
}

export function buildSceneChangeAnalysisPlan(
  options: SceneChangeAnalysisOptions
): FFmpegCommandPlan {
  validateSceneAnalysisOptions(options);
  const input = createPlannedInput(options.jobId, options.source);
  return freezeCommandPlan({
    id: `${options.jobId}:scene-change-analysis`,
    operation: 'scene-change-analysis',
    inputs: [input],
    temporaryFiles: [],
    args: [
      '-i',
      input.path,
      '-an',
      '-vf',
      `select='gt(scene\\,${normalizedSceneThreshold(options.threshold)})',showinfo`,
      '-frames:v',
      String(options.maxFrames),
      '-f',
      'null',
      '-',
    ],
    outputs: [],
    expectedDurationSeconds: options.durationSeconds,
    requiredCapabilities: {
      muxers: ['null'],
      filters: ['select', 'showinfo'],
    },
    diagnostics: [
      diagnostic(
        'bounded-scene-analysis',
        'info',
        `Scene analysis stops after ${options.maxFrames} qualifying frames and retains no source media.`
      ),
    ],
  });
}

export function parseSceneChangeFrames(
  logText: string
): readonly SceneChangeFrame[] {
  const frames: SceneChangeFrame[] = [];
  const seenPts = new Set<number>();
  const linePattern =
    /^[^\n]*\bn:\s*\d+\s+pts:\s*(-?\d+)\s+pts_time:\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/gimu;
  for (const match of logText.matchAll(linePattern)) {
    const pts = Number(match[1]);
    const timeSeconds = Number(match[2]);
    if (
      !Number.isSafeInteger(pts) ||
      pts < 0 ||
      !Number.isFinite(timeSeconds) ||
      timeSeconds < 0 ||
      seenPts.has(pts)
    ) {
      continue;
    }
    seenPts.add(pts);
    frames.push({ pts, timeSeconds });
    if (frames.length >= MAX_SCENE_CHANGE_FRAMES) {
      break;
    }
  }
  return Object.freeze(
    frames
      .sort((left, right) => left.timeSeconds - right.timeSeconds)
      .map((frame) => Object.freeze({ ...frame }))
  );
}

export function buildSceneThumbnailSeriesPlan(
  options: SceneThumbnailSeriesOptions
): FFmpegCommandPlan {
  const frames = validateSceneFrames(
    options.frames,
    options.durationSeconds,
    'Thumbnail series'
  );
  const input = createPlannedInput(options.jobId, options.source);
  const extension = extensionForImageFormat(options.format);
  const outputs = frames.map((frame, index) =>
    createPlannedOutput(options.jobId, {
      id: `thumbnail-${index + 1}`,
      baseName: options.source.fileName,
      operation: 'thumbnail',
      suffix: `${String(index + 1).padStart(3, '0')}-${Math.round(frame.timeSeconds * 1000)}ms`,
      extension,
      role: 'thumbnail',
    })
  );
  const size = scaleFilter(options.size);
  const selected = frames.map((frame) => `eq(pts\\,${frame.pts})`).join('+');
  const splitOutputs = frames
    .map((_, index) => `[scene-source-${index}]`)
    .join('');
  const filtergraph = [
    `[0:v]select='${selected}',split=${frames.length}${splitOutputs}`,
    ...frames.map((_, index) => {
      const filters = [
        `select='eq(n\\,${index})'`,
        'setpts=N/FRAME_RATE/TB',
        ...(size ? [size] : []),
      ];
      return `[scene-source-${index}]${filters.join(',')}[thumb-${index}]`;
    }),
  ].join(';');
  const outputArguments = outputs.flatMap((output, index) => [
    '-map',
    `[thumb-${index}]`,
    '-frames:v',
    '1',
    ...imageQualityArguments(options.format, options.quality),
    output.path,
  ]);
  return freezeCommandPlan({
    id: `${options.jobId}:scene-thumbnail-series`,
    operation: 'thumbnail-series',
    inputs: [input],
    temporaryFiles: [],
    args: [
      '-i',
      input.path,
      '-filter_complex',
      filtergraph,
      ...outputArguments,
    ],
    outputs,
    expectedDurationSeconds: options.durationSeconds,
    requiredCapabilities: {
      ...imageRequirements(options.format),
      filters: ['select', 'split', 'setpts', ...(size ? ['scale'] : [])],
    },
    diagnostics: [
      diagnostic(
        'scene-change-selection',
        'info',
        `${frames.length} frames are selected by exact presentation timestamp from a preceding bounded scene analysis.`
      ),
    ],
  });
}

export function resolveThumbnailTimes(
  options: Pick<
    ThumbnailSeriesOptions,
    'durationSeconds' | 'timesSeconds' | 'intervalSeconds' | 'count'
  >
): readonly number[] {
  if (
    !Number.isFinite(options.durationSeconds) ||
    options.durationSeconds <= 0
  ) {
    throw new RangeError('Source duration must be positive');
  }
  let times: number[];
  if (options.timesSeconds) {
    times = [...options.timesSeconds];
  } else if (options.intervalSeconds !== undefined) {
    if (
      !Number.isFinite(options.intervalSeconds) ||
      options.intervalSeconds <= 0
    ) {
      throw new RangeError('Thumbnail interval must be positive');
    }
    if (
      Math.ceil(options.durationSeconds / options.intervalSeconds) >
      MAX_GENERATED_OUTPUT_FILES
    ) {
      throw new RangeError(
        `A thumbnail series can contain at most ${MAX_GENERATED_OUTPUT_FILES} images. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
      );
    }
    times = [];
    for (
      let time = 0;
      time < options.durationSeconds;
      time += options.intervalSeconds
    ) {
      times.push(time);
    }
  } else {
    const count = options.count ?? 12;
    if (
      !Number.isSafeInteger(count) ||
      count < 1 ||
      count > MAX_GENERATED_OUTPUT_FILES
    ) {
      throw new RangeError(
        `Thumbnail count must be an integer from 1 to ${MAX_GENERATED_OUTPUT_FILES}. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
      );
    }
    times = Array.from(
      { length: count },
      (_, index) => (options.durationSeconds * (index + 1)) / (count + 1)
    );
  }
  const unique = [...new Set(times)].sort((left, right) => left - right);
  if (
    unique.length === 0 ||
    unique.length > MAX_GENERATED_OUTPUT_FILES ||
    unique.some(
      (time) =>
        !Number.isFinite(time) || time < 0 || time >= options.durationSeconds
    )
  ) {
    throw new RangeError('Thumbnail times must be within the source duration');
  }
  return Object.freeze(unique);
}

export function buildContactSheetPlan(
  options: ContactSheetOptions
): FFmpegCommandPlan {
  if (
    !Number.isFinite(options.durationSeconds) ||
    options.durationSeconds <= 0
  ) {
    throw new RangeError('Contact sheet source duration must be positive');
  }
  if (
    !Number.isSafeInteger(options.frameCount) ||
    options.frameCount < 1 ||
    options.frameCount > 500 ||
    !Number.isSafeInteger(options.rows) ||
    !Number.isSafeInteger(options.columns) ||
    options.rows < 1 ||
    options.columns < 1 ||
    options.rows * options.columns < options.frameCount ||
    options.rows * options.columns > 500
  ) {
    throw new RangeError(
      'Contact sheet grid must contain 1–500 cells and fit all frames'
    );
  }
  if (
    !Number.isSafeInteger(options.cellWidth) ||
    options.cellWidth < 32 ||
    options.cellWidth > 4096
  ) {
    throw new RangeError('Contact sheet cell width must be 32–4096 pixels');
  }
  const spacing = options.spacing ?? 4;
  if (!Number.isSafeInteger(spacing) || spacing < 0 || spacing > 100) {
    throw new RangeError('Contact sheet spacing must be 0–100 pixels');
  }
  const background = options.background ?? 'black';
  if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) {
    throw new TypeError('Background must be a named colour or #RRGGBB');
  }
  const sceneFrames = options.sceneFrames
    ? validateSceneFrames(
        options.sceneFrames,
        options.durationSeconds,
        'Contact sheet'
      )
    : undefined;
  if (sceneFrames && sceneFrames.length !== options.frameCount) {
    throw new RangeError(
      'Contact sheet frame count must match the analyzed scene selection'
    );
  }
  const input = createPlannedInput(options.jobId, options.source);
  const fontInput = options.font
    ? createPlannedInput(options.jobId, options.font, 'font')
    : undefined;
  const output = createPlannedOutput(options.jobId, {
    baseName: options.source.fileName,
    operation: 'contact-sheet',
    extension: extensionForImageFormat(options.format),
    role: 'contact-sheet',
  });
  const fps = options.frameCount / options.durationSeconds;
  const filters = [
    ...(sceneFrames
      ? [
          `select='${sceneFrames
            .map((frame) => `eq(pts\\,${frame.pts})`)
            .join('+')}'`,
        ]
      : [`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`]),
    options.maintainAspect === false
      ? `scale=${options.cellWidth}:${options.cellWidth}`
      : `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`,
  ];
  let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel);
  const diagnostics = [];
  if (labelsEnabled && (!options.drawtextAvailable || !fontInput)) {
    labelsEnabled = false;
    diagnostics.push(
      diagnostic(
        'labels-unavailable',
        'warning',
        'Timestamp and filename labels were omitted because drawtext or a safe local font is unavailable.'
      )
    );
  }
  if (labelsEnabled) {
    const text = options.sourceLabel
      ? `${safePathComponent(options.source.fileName)} %{pts\\:hms}`
      : '%{pts\\:hms}';
    filters.push(
      `drawtext=fontfile='${fontInput?.path ?? ''}':text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
    );
  }
  if (sceneFrames) {
    filters.push('setpts=N/FRAME_RATE/TB');
  }
  filters.push(
    `tile=${options.columns}x${options.rows}:nb_frames=${options.frameCount}:padding=${spacing}:margin=${spacing}:color=${background}`
  );
  return freezeCommandPlan({
    id: `${options.jobId}:contact-sheet`,
    operation: 'contact-sheet',
    inputs: [input, ...(fontInput ? [fontInput] : [])],
    temporaryFiles: [],
    args: [
      '-i',
      input.path,
      '-vf',
      filters.join(','),
      '-frames:v',
      '1',
      ...imageQualityArguments(options.format, options.quality),
      output.path,
    ],
    outputs: [output],
    expectedDurationSeconds: options.durationSeconds,
    requiredCapabilities: {
      ...imageRequirements(options.format),
      filters: [
        sceneFrames ? 'select' : 'fps',
        'scale',
        ...(sceneFrames ? ['setpts'] : []),
        'tile',
        ...(labelsEnabled ? ['drawtext'] : []),
      ],
    },
    diagnostics: [
      ...diagnostics,
      ...(sceneFrames
        ? [
            diagnostic(
              'scene-change-selection',
              'info',
              `${sceneFrames.length} analyzed scene changes populate this contact sheet.`
            ),
          ]
        : []),
    ],
  });
}

function validateSceneAnalysisOptions(
  options: Pick<
    SceneChangeAnalysisOptions,
    'durationSeconds' | 'threshold' | 'maxFrames'
  >
): void {
  if (
    !Number.isFinite(options.durationSeconds) ||
    options.durationSeconds <= 0
  ) {
    throw new RangeError('Scene analysis source duration must be positive');
  }
  normalizedSceneThreshold(options.threshold);
  if (
    !Number.isSafeInteger(options.maxFrames) ||
    options.maxFrames < 1 ||
    options.maxFrames > MAX_SCENE_CHANGE_FRAMES
  ) {
    throw new RangeError(
      `Scene analysis frame limit must be an integer from 1 to ${MAX_SCENE_CHANGE_FRAMES}`
    );
  }
}

function normalizedSceneThreshold(value: number): string {
  if (
    !Number.isFinite(value) ||
    value < MIN_SCENE_CHANGE_THRESHOLD ||
    value > MAX_SCENE_CHANGE_THRESHOLD
  ) {
    throw new RangeError(
      `Scene threshold must be ${MIN_SCENE_CHANGE_THRESHOLD}–${MAX_SCENE_CHANGE_THRESHOLD}`
    );
  }
  return String(Number(value.toFixed(3)));
}

function validateSceneFrames(
  frames: readonly SceneChangeFrame[],
  durationSeconds: number,
  label: string
): readonly SceneChangeFrame[] {
  if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
    throw new RangeError(`${label} source duration must be positive`);
  }
  if (
    frames.length < 1 ||
    frames.length > MAX_SCENE_CHANGE_FRAMES ||
    frames.some(
      (frame) =>
        !Number.isSafeInteger(frame.pts) ||
        frame.pts < 0 ||
        !Number.isFinite(frame.timeSeconds) ||
        frame.timeSeconds < 0 ||
        frame.timeSeconds >= durationSeconds
    )
  ) {
    throw new RangeError(
      `${label} scene frames must be bounded, finite points inside the source`
    );
  }
  const uniquePts = new Set(frames.map((frame) => frame.pts));
  if (uniquePts.size !== frames.length) {
    throw new RangeError(`${label} scene frames must have unique timestamps`);
  }
  return Object.freeze(
    [...frames]
      .sort((left, right) => left.timeSeconds - right.timeSeconds)
      .map((frame) => Object.freeze({ ...frame }))
  );
}

function imageFilterArguments(size?: ThumbnailSize): readonly string[] {
  const filter = scaleFilter(size);
  return filter ? ['-vf', filter] : [];
}

function scaleFilter(size?: ThumbnailSize): string | undefined {
  if (!size || (size.width === undefined && size.height === undefined)) {
    return undefined;
  }
  for (const value of [size.width, size.height]) {
    if (
      value !== undefined &&
      (!Number.isSafeInteger(value) || value < 1 || value > 8192)
    ) {
      throw new RangeError(
        'Thumbnail dimensions must be integer values from 1 to 8192'
      );
    }
  }
  const dimensions = `scale=${size.width ?? -2}:${size.height ?? -2}`;
  return size.maintainAspect === false
    ? dimensions
    : `${dimensions}:force_original_aspect_ratio=decrease`;
}

function imageQualityArguments(
  format: ImageFormat,
  quality?: number
): readonly string[] {
  if (quality === undefined) {
    return [];
  }
  if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
    throw new RangeError('Image quality must be between 1 and 100');
  }
  if (format === 'jpeg') {
    return ['-q:v', String(Math.max(2, Math.round(31 - quality * 0.29)))];
  }
  if (format === 'webp') {
    return ['-quality', String(Math.round(quality))];
  }
  return [];
}

function imageRequirements(format: ImageFormat): {
  readonly encoders: readonly string[];
  readonly muxers: readonly string[];
} {
  return format === 'jpeg'
    ? { encoders: ['mjpeg'], muxers: ['image2'] }
    : format === 'png'
      ? { encoders: ['png'], muxers: ['image2'] }
      : { encoders: ['libwebp'], muxers: ['webp'] };
}

function extensionForImageFormat(format: ImageFormat): string {
  return format === 'jpeg' ? 'jpg' : format;
}
