export type CapabilityKind = 'muxers' | 'encoders' | 'decoders' | 'filters';

export interface PresetRequirements {
  readonly muxers?: readonly string[];
  readonly encoders?: readonly string[];
  readonly decoders?: readonly string[];
  readonly filters?: readonly string[];
}

export interface PlannedInput {
  readonly id: string;
  readonly path: string;
  readonly sourceIndex: number;
  readonly kind: 'media' | 'subtitle' | 'font' | 'metadata' | 'generated';
}

export interface PlannedTemporaryFile {
  readonly path: string;
  readonly content: string | Uint8Array;
  readonly purpose:
    'concat-list' | 'metadata' | 'subtitle' | 'analysis' | 'other';
}

export interface PlannedOutput {
  readonly id: string;
  readonly path: string;
  readonly fileName: string;
  readonly mediaType: string;
  /** Exact source interval represented by this output, when applicable. */
  readonly timeRange?: {
    readonly startSeconds: number;
    readonly endSeconds: number;
  };
  readonly role:
    'media' | 'audio' | 'subtitle' | 'thumbnail' | 'contact-sheet' | 'analysis';
}

export type DiagnosticSeverity = 'info' | 'warning' | 'error';

export interface CommandDiagnostic {
  readonly code: string;
  readonly severity: DiagnosticSeverity;
  readonly message: string;
  readonly field?: string;
}

export interface FFmpegCommandPlan {
  readonly id: string;
  readonly operation: string;
  readonly inputs: readonly PlannedInput[];
  readonly temporaryFiles: readonly PlannedTemporaryFile[];
  /**
   * Structured argv passed directly to FFmpeg. It is never interpreted by a
   * shell and intentionally excludes the executable name.
   */
  readonly args: readonly string[];
  readonly outputs: readonly PlannedOutput[];
  readonly expectedDurationSeconds?: number;
  readonly timeoutMs?: number;
  readonly requiredCapabilities: PresetRequirements;
  readonly diagnostics: readonly CommandDiagnostic[];
}

export interface CommandPlanDefaults {
  readonly timeoutMs?: number;
  readonly expectedDurationSeconds?: number;
}

export function freezeCommandPlan(plan: FFmpegCommandPlan): FFmpegCommandPlan {
  validateCommandPlanShape(plan);
  const requirements: PresetRequirements = Object.freeze({
    ...(plan.requiredCapabilities.muxers
      ? { muxers: Object.freeze([...plan.requiredCapabilities.muxers]) }
      : {}),
    ...(plan.requiredCapabilities.encoders
      ? { encoders: Object.freeze([...plan.requiredCapabilities.encoders]) }
      : {}),
    ...(plan.requiredCapabilities.decoders
      ? { decoders: Object.freeze([...plan.requiredCapabilities.decoders]) }
      : {}),
    ...(plan.requiredCapabilities.filters
      ? { filters: Object.freeze([...plan.requiredCapabilities.filters]) }
      : {}),
  });

  return Object.freeze({
    ...plan,
    inputs: Object.freeze(
      plan.inputs.map((entry) => Object.freeze({ ...entry }))
    ),
    temporaryFiles: Object.freeze(
      plan.temporaryFiles.map((entry) => Object.freeze({ ...entry }))
    ),
    args: Object.freeze([...plan.args]),
    outputs: Object.freeze(
      plan.outputs.map((entry) =>
        Object.freeze({
          ...entry,
          ...(entry.timeRange
            ? { timeRange: Object.freeze({ ...entry.timeRange }) }
            : {}),
        })
      )
    ),
    requiredCapabilities: requirements,
    diagnostics: Object.freeze(
      plan.diagnostics.map((entry) => Object.freeze({ ...entry }))
    ),
  });
}

function validateCommandPlanShape(plan: FFmpegCommandPlan): void {
  if (!plan.id.trim() || !plan.operation.trim()) {
    throw new TypeError('Command plan id and operation are required');
  }
  if (
    plan.args.some(
      (argument) => argument.length === 0 || argument.includes('\0')
    )
  ) {
    throw new TypeError('Command arguments cannot be empty or contain NUL');
  }
  if (
    plan.expectedDurationSeconds !== undefined &&
    (!Number.isFinite(plan.expectedDurationSeconds) ||
      plan.expectedDurationSeconds <= 0)
  ) {
    throw new RangeError('Expected command duration must be positive');
  }
  if (
    plan.timeoutMs !== undefined &&
    (!Number.isSafeInteger(plan.timeoutMs) || plan.timeoutMs <= 0)
  ) {
    throw new RangeError('Command timeout must be a positive integer');
  }
  assertUnique(
    plan.outputs.map((output) => output.id),
    'output id'
  );
  assertUnique(
    plan.outputs.map((output) => output.path),
    'output path'
  );
  assertUnique(
    plan.temporaryFiles.map((temporary) => temporary.path),
    'temporary-file path'
  );
  const temporaryPaths = new Set(
    plan.temporaryFiles.map((temporary) => temporary.path)
  );
  if (plan.outputs.some((output) => temporaryPaths.has(output.path))) {
    throw new TypeError('Output and temporary-file paths must be distinct');
  }
  for (const output of plan.outputs) {
    if (
      output.timeRange &&
      (!Number.isFinite(output.timeRange.startSeconds) ||
        output.timeRange.startSeconds < 0 ||
        !Number.isFinite(output.timeRange.endSeconds) ||
        output.timeRange.endSeconds <= output.timeRange.startSeconds)
    ) {
      throw new RangeError(
        `Output "${output.id}" has an invalid source time range.`
      );
    }
    if (!plan.args.some((argument) => argument.includes(output.path))) {
      throw new TypeError(
        `Declared output path is absent from command arguments: ${output.path}`
      );
    }
  }
  for (const temporary of plan.temporaryFiles) {
    if (!plan.args.some((argument) => argument.includes(temporary.path))) {
      throw new TypeError(
        `Declared temporary path is absent from command arguments: ${temporary.path}`
      );
    }
  }
}

function assertUnique(values: readonly string[], label: string): void {
  const unique = new Set(values);
  if (unique.size !== values.length) {
    throw new TypeError(`Command plan contains a duplicate ${label}`);
  }
}

export class CommandPlanError extends Error {
  readonly diagnostics: readonly CommandDiagnostic[];

  constructor(message: string, diagnostics: readonly CommandDiagnostic[]) {
    super(message);
    this.name = 'CommandPlanError';
    this.diagnostics = diagnostics;
  }
}
