import type { FFmpegCommandPlan } from '../commands/command-plan';
import type { MediaProbe, MediaStreamProbe } from '../media';
import { estimateOutputBytes, type ResourceEstimate } from './resource-policy';

export interface PlanResourceSource {
  readonly size: number;
  readonly probe?: MediaProbe;
}

export type PlanResourceEstimate = Omit<
  ResourceEstimate,
  'inputBytes' | 'streamsPerInput' | 'retainedOutputs'
>;

/**
 * Derives conservative policy inputs from a structured command plan and its
 * already-probed sources. Unknown values remain absent instead of being
 * represented by optimistic zeroes.
 */
export function estimatePlanResources(
  plan: FFmpegCommandPlan,
  sources: readonly PlanResourceSource[]
): PlanResourceEstimate {
  const durationSeconds = plan.expectedDurationSeconds;
  const thumbnailCount = plan.outputs.filter(
    (output) => output.role === 'thumbnail'
  ).length;
  const contactSheetCells = readContactSheetCells(plan.args);
  const geometry = readOutputGeometry(plan.args, sources);
  const generatedFrameRate =
    durationSeconds !== undefined && durationSeconds > 0 && thumbnailCount > 1
      ? thumbnailCount / durationSeconds
      : undefined;
  const frameRate =
    readLastFilterNumber(plan.args, 'fps') ??
    readLastOptionNumber(plan.args, '-r') ??
    generatedFrameRate ??
    firstVideoStream(sources)?.frameRate;
  const expectedOutputBytes =
    estimateMediaOutputBytes(plan, sources, durationSeconds) ??
    estimateImageOutputBytes(plan, geometry);

  return {
    ...(durationSeconds === undefined ? {} : { durationSeconds }),
    ...(expectedOutputBytes === undefined ? {} : { expectedOutputBytes }),
    ...(geometry.width === undefined ? {} : { width: geometry.width }),
    ...(geometry.height === undefined ? {} : { height: geometry.height }),
    ...(frameRate === undefined ? {} : { frameRate }),
    passes: plan.operation.includes('loudness') ? 2 : 1,
    ...(plan.outputs.length > 0
      ? { generatedOutputs: plan.outputs.length }
      : {}),
    ...(thumbnailCount === 0 ? {} : { thumbnailCount }),
    ...(contactSheetCells === undefined ? {} : { contactSheetCells }),
    ...(plan.timeoutMs === undefined
      ? {}
      : { expectedRuntimeMs: plan.timeoutMs }),
    ...(plan.operation === 'timeline-export'
      ? { timelineClips: plan.inputs.length }
      : {}),
  };
}

function estimateImageOutputBytes(
  plan: FFmpegCommandPlan,
  geometry: { readonly width?: number; readonly height?: number }
): number | undefined {
  const imageOutputCount = plan.outputs.filter(
    (output) => output.role === 'thumbnail' || output.role === 'contact-sheet'
  ).length;
  if (
    imageOutputCount === 0 ||
    geometry.width === undefined ||
    geometry.height === undefined
  ) {
    return undefined;
  }
  return Math.ceil(
    geometry.width * geometry.height * 4 * imageOutputCount * 1.1
  );
}

function estimateMediaOutputBytes(
  plan: FFmpegCommandPlan,
  sources: readonly PlanResourceSource[],
  durationSeconds?: number
): number | undefined {
  const hasMediaOutput = plan.outputs.some(
    (output) => output.role === 'media' || output.role === 'audio'
  );
  if (!hasMediaOutput) return undefined;

  const inputBytes = sources.reduce((sum, source) => sum + source.size, 0);
  if (inputBytes === 0) return undefined;
  if (usesStreamCopy(plan.args)) {
    return inputBytes;
  }

  if (durationSeconds !== undefined) {
    const declaredVideoBitrateKbps = readLastBitrateKbps(plan.args, '-b:v');
    const declaredAudioBitrateKbps = readLastBitrateKbps(plan.args, '-b:a');
    const hasVideoOutput =
      !plan.args.includes('-vn') &&
      (hasOption(plan.args, '-c:v') ||
        sources.some((source) =>
          source.probe?.streams.some((stream) => stream.type === 'video')
        ));
    const hasAudioOutput =
      !plan.args.includes('-an') &&
      (plan.outputs.some((output) => output.role === 'audio') ||
        hasOption(plan.args, '-c:a') ||
        sources.some((source) =>
          source.probe?.streams.some((stream) => stream.type === 'audio')
        ));
    const hasCompleteDeclaredBitrate =
      (!hasVideoOutput || declaredVideoBitrateKbps !== undefined) &&
      (!hasAudioOutput || declaredAudioBitrateKbps !== undefined);
    if (hasCompleteDeclaredBitrate) {
      const declaredBitrateKbps =
        (declaredVideoBitrateKbps ?? 0) + (declaredAudioBitrateKbps ?? 0);
      if (declaredBitrateKbps > 0) {
        return estimateOutputBytes(durationSeconds, declaredBitrateKbps);
      }
    }
    const probedBitrateKbps = weightedSourceBitrateKbps(sources);
    if (probedBitrateKbps !== undefined && probedBitrateKbps > 0) {
      return estimateOutputBytes(durationSeconds, probedBitrateKbps);
    }

    const probedDurationSeconds = sources.reduce(
      (sum, source) => sum + (source.probe?.durationSeconds ?? 0),
      0
    );
    if (probedDurationSeconds > 0) {
      return Math.ceil(
        inputBytes * Math.min(1, durationSeconds / probedDurationSeconds) * 1.03
      );
    }
  }
  return inputBytes;
}

function hasOption(args: readonly string[], option: string): boolean {
  return args.some((argument) => argument === option);
}

function usesStreamCopy(args: readonly string[]): boolean {
  return args.some(
    (argument, index) =>
      /^(?:-c|-codec)(?::[vasdt])?$/u.test(argument) &&
      args[index + 1] === 'copy'
  );
}

function weightedSourceBitrateKbps(
  sources: readonly PlanResourceSource[]
): number | undefined {
  let totalBits = 0;
  let totalDurationSeconds = 0;
  for (const source of sources) {
    const durationSeconds = source.probe?.durationSeconds;
    const bitRate = source.probe?.bitRate;
    if (
      durationSeconds !== undefined &&
      durationSeconds > 0 &&
      bitRate !== undefined &&
      bitRate > 0
    ) {
      totalBits += bitRate * durationSeconds;
      totalDurationSeconds += durationSeconds;
    }
  }
  return totalDurationSeconds > 0
    ? totalBits / totalDurationSeconds / 1_000
    : undefined;
}

function readLastBitrateKbps(
  args: readonly string[],
  option: '-b:v' | '-b:a'
): number | undefined {
  for (let index = args.length - 2; index >= 0; index -= 1) {
    if (args[index] !== option) continue;
    const value = args[index + 1];
    if (!value) return undefined;
    const match = /^(\d+(?:\.\d+)?)([kKmM])?$/u.exec(value);
    if (!match?.[1]) return undefined;
    const numeric = Number(match[1]);
    const unit = match[2]?.toLowerCase();
    return unit === 'm'
      ? numeric * 1_000
      : unit === 'k'
        ? numeric
        : numeric / 1_000;
  }
  return undefined;
}

function readOutputGeometry(
  args: readonly string[],
  sources: readonly PlanResourceSource[]
): { readonly width?: number; readonly height?: number } {
  const filterText = args.join(';');
  const scaleMatches = [
    ...filterText.matchAll(/(?:^|[,;])scale=(\d+):(-?\d+)(?=[:;,]|$)/gu),
  ];
  const lastScale = scaleMatches.at(-1);
  let width = positiveInteger(lastScale?.[1]);
  let height = positiveInteger(lastScale?.[2]);
  const sourceVideo = firstVideoStream(sources);
  if (
    width !== undefined &&
    height === undefined &&
    sourceVideo?.width &&
    sourceVideo.height
  ) {
    height = evenInteger((width * sourceVideo.height) / sourceVideo.width);
  }

  if (width === undefined || height === undefined) {
    const cropMatches = [
      ...filterText.matchAll(/(?:^|[,;])crop=(\d+):(\d+)(?=[:;,]|$)/gu),
    ];
    const lastCrop = cropMatches.at(-1);
    width ??= positiveInteger(lastCrop?.[1]) ?? sourceVideo?.width;
    height ??= positiveInteger(lastCrop?.[2]) ?? sourceVideo?.height;
  }

  const tile = readTile(args);
  if (tile && width !== undefined && height !== undefined) {
    return {
      width:
        tile.columns * width +
        Math.max(0, tile.columns - 1) * tile.padding +
        2 * tile.margin,
      height:
        tile.rows * height +
        Math.max(0, tile.rows - 1) * tile.padding +
        2 * tile.margin,
    };
  }
  return {
    ...(width === undefined ? {} : { width }),
    ...(height === undefined ? {} : { height }),
  };
}

function readContactSheetCells(args: readonly string[]): number | undefined {
  const tile = readTile(args);
  return tile ? tile.columns * tile.rows : undefined;
}

function readTile(args: readonly string[]):
  | {
      readonly columns: number;
      readonly rows: number;
      readonly padding: number;
      readonly margin: number;
    }
  | undefined {
  const text = args.join(';');
  const match =
    /(?:^|[,;])tile=(\d+)x(\d+)(?::padding=(\d+))?(?::margin=(\d+))?/u.exec(
      text
    );
  const columns = positiveInteger(match?.[1]);
  const rows = positiveInteger(match?.[2]);
  if (columns === undefined || rows === undefined) return undefined;
  return {
    columns,
    rows,
    padding: nonnegativeInteger(match?.[3]) ?? 0,
    margin: nonnegativeInteger(match?.[4]) ?? 0,
  };
}

function readLastFilterNumber(
  args: readonly string[],
  filter: string
): number | undefined {
  const matches = [
    ...args
      .join(';')
      .matchAll(new RegExp(`(?:^|[,;])${filter}=(\\d+(?:\\.\\d+)?)`, 'gu')),
  ];
  return positiveNumber(matches.at(-1)?.[1]);
}

function readLastOptionNumber(
  args: readonly string[],
  option: string
): number | undefined {
  for (let index = args.length - 2; index >= 0; index -= 1) {
    if (args[index] === option) return positiveNumber(args[index + 1]);
  }
  return undefined;
}

function firstVideoStream(
  sources: readonly PlanResourceSource[]
): MediaStreamProbe | undefined {
  return sources
    .flatMap((source) => source.probe?.streams ?? [])
    .find((stream) => stream.type === 'video');
}

function positiveInteger(value?: string): number | undefined {
  if (!value) return undefined;
  const parsed = Number(value);
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}

function nonnegativeInteger(value?: string): number | undefined {
  if (value === undefined) return undefined;
  const parsed = Number(value);
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}

function positiveNumber(value?: string): number | undefined {
  if (!value) return undefined;
  const parsed = Number(value);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}

function evenInteger(value: number): number {
  return Math.max(2, Math.round(value / 2) * 2);
}
