import type {
  AudioCodec,
  AudioEncodingSettings,
  ChapterPolicy,
  MetadataPolicy,
  PresetKind,
  QualityMode,
  SubtitlePolicy,
  UserExportPreset,
  VideoCodec,
  VideoEncodingSettings,
} from './preset.types';

export interface PresetValidationResult {
  readonly valid: boolean;
  readonly errors: readonly string[];
  readonly preset?: UserExportPreset;
}

const ROOT_FIELDS = new Set([
  'schemaVersion',
  'id',
  'name',
  'description',
  'kind',
  'container',
  'fileExtension',
  'video',
  'audio',
  'metadataPolicy',
  'chapterPolicy',
  'subtitlePolicy',
  'fastStart',
]);
const VIDEO_FIELDS = new Set([
  'codec',
  'qualityMode',
  'crf',
  'bitrateKbps',
  'width',
  'height',
  'frameRate',
  'pixelFormat',
  'encoderPreset',
]);
const AUDIO_FIELDS = new Set([
  'codec',
  'bitrateKbps',
  'sampleRate',
  'channels',
]);
const LEGACY_ROOT_FIELDS = new Set([
  'schemaVersion',
  'id',
  'name',
  'description',
  'kind',
  'format',
  'fileExtension',
  'video',
  'audio',
  'metadataPolicy',
  'includeChapters',
  'subtitlePolicy',
  'fastStart',
]);

type UserPresetKind = Exclude<PresetKind, 'remux'>;

interface UserPresetContainerCompatibility {
  readonly extension: string;
  readonly kinds: readonly UserPresetKind[];
  readonly videoCodecs: readonly VideoCodec[];
  readonly audioCodecs: readonly AudioCodec[];
  readonly requiresVideo?: boolean;
  readonly requiresAudio?: boolean;
  readonly forbidsVideo?: boolean;
  readonly forbidsAudio?: boolean;
  readonly supportsFastStart?: boolean;
}

/**
 * This is deliberately narrower than everything FFmpeg may happen to accept.
 * Each entry represents a reviewed path through the typed command builder.
 * New combinations must be added together with a command/runtime regression.
 */
const USER_PRESET_CONTAINER_COMPATIBILITY: Readonly<
  Record<string, UserPresetContainerCompatibility>
> = Object.freeze({
  mp4: {
    extension: 'mp4',
    kinds: ['video'],
    videoCodecs: ['libx264'],
    audioCodecs: ['aac'],
    requiresVideo: true,
    supportsFastStart: true,
  },
  webm: {
    extension: 'webm',
    kinds: ['video'],
    videoCodecs: ['libvpx-vp9', 'libvpx'],
    audioCodecs: ['libopus', 'libvorbis'],
    requiresVideo: true,
  },
  matroska: {
    extension: 'mkv',
    kinds: ['video'],
    videoCodecs: ['libx264', 'libvpx-vp9', 'libvpx'],
    audioCodecs: [
      'aac',
      'libmp3lame',
      'libvorbis',
      'libopus',
      'pcm_s16le',
      'flac',
    ],
    requiresVideo: true,
  },
  webp: {
    extension: 'webp',
    kinds: ['derivative'],
    videoCodecs: ['libwebp_anim'],
    audioCodecs: [],
    requiresVideo: true,
    forbidsAudio: true,
  },
  gif: {
    extension: 'gif',
    kinds: ['derivative'],
    videoCodecs: ['gif'],
    audioCodecs: [],
    requiresVideo: true,
    forbidsAudio: true,
  },
  mp3: {
    extension: 'mp3',
    kinds: ['audio'],
    videoCodecs: [],
    audioCodecs: ['libmp3lame'],
    requiresAudio: true,
    forbidsVideo: true,
  },
  ipod: {
    extension: 'm4a',
    kinds: ['audio'],
    videoCodecs: [],
    audioCodecs: ['aac'],
    requiresAudio: true,
    forbidsVideo: true,
    supportsFastStart: true,
  },
  ogg: {
    extension: 'ogg',
    kinds: ['audio'],
    videoCodecs: [],
    audioCodecs: ['libvorbis'],
    requiresAudio: true,
    forbidsVideo: true,
  },
  opus: {
    extension: 'opus',
    kinds: ['audio'],
    videoCodecs: [],
    audioCodecs: ['libopus'],
    requiresAudio: true,
    forbidsVideo: true,
  },
  wav: {
    extension: 'wav',
    kinds: ['audio'],
    videoCodecs: [],
    audioCodecs: ['pcm_s16le'],
    requiresAudio: true,
    forbidsVideo: true,
  },
  flac: {
    extension: 'flac',
    kinds: ['audio'],
    videoCodecs: [],
    audioCodecs: ['flac'],
    requiresAudio: true,
    forbidsVideo: true,
  },
});

export const USER_PRESET_CONTAINERS: readonly string[] = Object.freeze(
  Object.keys(USER_PRESET_CONTAINER_COMPATIBILITY)
);

export function validateUserPreset(value: unknown): PresetValidationResult {
  const errors: string[] = [];
  if (!isRecord(value)) {
    return { valid: false, errors: ['Preset must be a JSON object'] };
  }
  rejectUnknownFields(value, ROOT_FIELDS, 'preset', errors);
  if (value.schemaVersion !== 1) {
    errors.push('schemaVersion must be 1');
  }
  const id = readIdentifier(value.id, 'id', errors);
  const name = readText(value.name, 'name', 1, 80, errors);
  const description = readOptionalText(
    value.description,
    'description',
    400,
    errors
  );
  const kind = readUnion(
    value.kind,
    ['video', 'audio', 'derivative'],
    'kind',
    errors
  );
  const container = readIdentifier(value.container, 'container', errors);
  const fileExtension = readExtension(value.fileExtension, errors);
  const metadataPolicy = readUnion(
    value.metadataPolicy,
    ['copy', 'edit', 'remove'] satisfies readonly MetadataPolicy[],
    'metadataPolicy',
    errors
  );
  const chapterPolicy = readUnion(
    value.chapterPolicy,
    ['keep', 'remove', 'replace'] satisfies readonly ChapterPolicy[],
    'chapterPolicy',
    errors
  );
  const subtitlePolicy = readUnion(
    value.subtitlePolicy,
    [
      'none',
      'copy-compatible',
      'convert',
      'burn-in',
    ] satisfies readonly SubtitlePolicy[],
    'subtitlePolicy',
    errors
  );
  const video =
    value.video === undefined ? undefined : parseVideo(value.video, errors);
  const audio =
    value.audio === undefined ? undefined : parseAudio(value.audio, errors);
  if (!video && !audio) {
    errors.push('At least one of video or audio is required');
  }
  if (kind === 'audio' && video) {
    errors.push('Audio presets cannot include video settings');
  }
  if (value.fastStart !== undefined && typeof value.fastStart !== 'boolean') {
    errors.push('fastStart must be a boolean');
  }
  validateQuickPresetPolicies(
    metadataPolicy,
    chapterPolicy,
    subtitlePolicy,
    errors
  );
  validateContainerCompatibility(
    {
      kind,
      container,
      fileExtension,
      video,
      audio,
      fastStart: value.fastStart,
    },
    errors
  );
  if (
    errors.length > 0 ||
    !id ||
    !name ||
    !kind ||
    !container ||
    !fileExtension ||
    !metadataPolicy ||
    !chapterPolicy ||
    !subtitlePolicy
  ) {
    return { valid: false, errors: Object.freeze(errors) };
  }

  const preset: UserExportPreset = {
    schemaVersion: 1,
    id,
    name,
    kind,
    container,
    fileExtension,
    metadataPolicy,
    chapterPolicy,
    subtitlePolicy,
    ...(description ? { description } : {}),
    ...(video ? { video } : {}),
    ...(audio ? { audio } : {}),
    ...(typeof value.fastStart === 'boolean'
      ? { fastStart: value.fastStart }
      : {}),
  };
  return {
    valid: true,
    errors: Object.freeze([]),
    preset: deepFreezePreset(preset),
  };
}

function validateQuickPresetPolicies(
  metadataPolicy: MetadataPolicy | undefined,
  chapterPolicy: ChapterPolicy | undefined,
  subtitlePolicy: SubtitlePolicy | undefined,
  errors: string[]
): void {
  if (metadataPolicy === 'edit') {
    errors.push(
      'metadataPolicy "edit" is not supported by Quick presets; use "copy" or "remove"'
    );
  }
  if (chapterPolicy === 'replace') {
    errors.push(
      'chapterPolicy "replace" is not supported by Quick presets; use "keep" or "remove"'
    );
  }
  if (subtitlePolicy === 'burn-in') {
    errors.push(
      'subtitlePolicy "burn-in" requires an explicit subtitle source and is not supported by Quick presets'
    );
  }
}

function validateContainerCompatibility(
  fields: {
    readonly kind: UserPresetKind | undefined;
    readonly container: string | undefined;
    readonly fileExtension: string | undefined;
    readonly video: VideoEncodingSettings | undefined;
    readonly audio: AudioEncodingSettings | undefined;
    readonly fastStart: unknown;
  },
  errors: string[]
): void {
  if (!fields.container) {
    return;
  }
  const compatibility = USER_PRESET_CONTAINER_COMPATIBILITY[fields.container];
  if (!compatibility) {
    errors.push(
      `container "${fields.container}" is not in the reviewed user-preset allowlist (allowed: ${USER_PRESET_CONTAINERS.join(', ')})`
    );
    return;
  }

  if (
    fields.fileExtension &&
    fields.fileExtension !== compatibility.extension
  ) {
    errors.push(
      `fileExtension must be "${compatibility.extension}" for container "${fields.container}"`
    );
  }
  if (fields.kind && !compatibility.kinds.includes(fields.kind)) {
    errors.push(
      `container "${fields.container}" is not compatible with preset kind "${fields.kind}"`
    );
  }
  if (compatibility.requiresVideo && !fields.video) {
    errors.push(`container "${fields.container}" requires video settings`);
  }
  if (compatibility.requiresAudio && !fields.audio) {
    errors.push(`container "${fields.container}" requires audio settings`);
  }
  if (compatibility.forbidsVideo && fields.video) {
    errors.push(
      `container "${fields.container}" does not support video settings`
    );
  }
  if (compatibility.forbidsAudio && fields.audio) {
    errors.push(
      `container "${fields.container}" does not support audio settings`
    );
  }
  if (fields.video && !compatibility.videoCodecs.includes(fields.video.codec)) {
    errors.push(
      `video codec "${fields.video.codec}" is not supported in container "${fields.container}" (allowed: ${formatAllowlist(compatibility.videoCodecs)})`
    );
  }
  if (fields.audio && !compatibility.audioCodecs.includes(fields.audio.codec)) {
    errors.push(
      `audio codec "${fields.audio.codec}" is not supported in container "${fields.container}" (allowed: ${formatAllowlist(compatibility.audioCodecs)})`
    );
  }
  if (fields.fastStart === true && !compatibility.supportsFastStart) {
    errors.push(
      `fastStart is only supported for reviewed MP4/M4A containers, not "${fields.container}"`
    );
  }
}

function formatAllowlist(values: readonly string[]): string {
  return values.length > 0 ? values.join(', ') : 'none';
}

export function parseUserPresetJson(json: string): UserExportPreset {
  if (new TextEncoder().encode(json).byteLength > 64 * 1024) {
    throw new RangeError('Preset JSON exceeds the 64 KiB import limit');
  }
  let value: unknown;
  try {
    value = JSON.parse(json) as unknown;
  } catch {
    throw new TypeError('Preset JSON is malformed');
  }
  const result = validateUserPreset(migrateUserPreset(value));
  if (!result.valid || !result.preset) {
    throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`);
  }
  return result.preset;
}

/**
 * Migrates the historical development schema into schema 1. Unknown legacy
 * fields are rejected before migration, so they cannot smuggle executable
 * arguments or filter expressions into the allowlisted model.
 */
export function migrateUserPreset(value: unknown): unknown {
  if (!isRecord(value) || value.schemaVersion !== 0) {
    return value;
  }
  const errors: string[] = [];
  rejectUnknownFields(value, LEGACY_ROOT_FIELDS, 'legacy preset', errors);
  if (errors.length > 0) {
    throw new TypeError(errors.join('; '));
  }
  if (typeof value.format !== 'string') {
    throw new TypeError('Legacy preset format is required');
  }
  const extension =
    typeof value.fileExtension === 'string'
      ? value.fileExtension
      : value.format === 'matroska'
        ? 'mkv'
        : value.format === 'ipod'
          ? 'm4a'
          : value.format;
  return {
    schemaVersion: 1,
    id: value.id,
    name: value.name,
    ...(value.description !== undefined
      ? { description: value.description }
      : {}),
    kind: value.kind,
    container: value.format,
    fileExtension: extension,
    ...(value.video !== undefined ? { video: value.video } : {}),
    ...(value.audio !== undefined ? { audio: value.audio } : {}),
    metadataPolicy: value.metadataPolicy ?? 'copy',
    chapterPolicy: value.includeChapters === true ? 'keep' : 'remove',
    subtitlePolicy: value.subtitlePolicy ?? 'none',
    ...(value.fastStart !== undefined ? { fastStart: value.fastStart } : {}),
  };
}

export function serializeUserPreset(preset: UserExportPreset): string {
  const result = validateUserPreset(preset);
  if (!result.valid || !result.preset) {
    throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`);
  }
  return `${JSON.stringify(result.preset, null, 2)}\n`;
}

function parseVideo(
  value: unknown,
  errors: string[]
): VideoEncodingSettings | undefined {
  if (!isRecord(value)) {
    errors.push('video must be an object');
    return undefined;
  }
  rejectUnknownFields(value, VIDEO_FIELDS, 'video', errors);
  const codec = readUnion(
    value.codec,
    ['libx264', 'libvpx-vp9', 'libvpx', 'libwebp_anim', 'gif'],
    'video.codec',
    errors
  );
  const qualityMode = readUnion(
    value.qualityMode,
    [
      'compatibility',
      'balanced',
      'smaller-file',
      'higher-quality',
      'custom',
    ] satisfies readonly QualityMode[],
    'video.qualityMode',
    errors
  );
  const crf = readOptionalNumber(value.crf, 'video.crf', 0, 63, errors);
  const bitrateKbps = readOptionalNumber(
    value.bitrateKbps,
    'video.bitrateKbps',
    0,
    1_000_000,
    errors
  );
  const width = readOptionalInteger(
    value.width,
    'video.width',
    2,
    8192,
    errors
  );
  const height = readOptionalInteger(
    value.height,
    'video.height',
    2,
    8192,
    errors
  );
  const frameRate = readOptionalNumber(
    value.frameRate,
    'video.frameRate',
    0.01,
    240,
    errors
  );
  const pixelFormat =
    value.pixelFormat === undefined
      ? undefined
      : readUnion(
          value.pixelFormat,
          ['yuv420p', 'yuv422p', 'yuv444p', 'rgba'],
          'video.pixelFormat',
          errors
        );
  const encoderPreset =
    value.encoderPreset === undefined
      ? undefined
      : readUnion(
          value.encoderPreset,
          ['ultrafast', 'veryfast', 'fast', 'medium', 'slow', 'veryslow'],
          'video.encoderPreset',
          errors
        );
  if (!codec || !qualityMode) {
    return undefined;
  }
  return {
    codec,
    qualityMode,
    ...(crf !== undefined ? { crf } : {}),
    ...(bitrateKbps !== undefined ? { bitrateKbps } : {}),
    ...(width !== undefined ? { width } : {}),
    ...(height !== undefined ? { height } : {}),
    ...(frameRate !== undefined ? { frameRate } : {}),
    ...(pixelFormat ? { pixelFormat } : {}),
    ...(encoderPreset ? { encoderPreset } : {}),
  };
}

function parseAudio(
  value: unknown,
  errors: string[]
): AudioEncodingSettings | undefined {
  if (!isRecord(value)) {
    errors.push('audio must be an object');
    return undefined;
  }
  rejectUnknownFields(value, AUDIO_FIELDS, 'audio', errors);
  const codec = readUnion(
    value.codec,
    ['aac', 'libmp3lame', 'libvorbis', 'libopus', 'pcm_s16le', 'flac'],
    'audio.codec',
    errors
  );
  const bitrateKbps = readOptionalNumber(
    value.bitrateKbps,
    'audio.bitrateKbps',
    8,
    2_000,
    errors
  );
  const sampleRate =
    value.sampleRate === undefined
      ? undefined
      : readUnion(
          value.sampleRate,
          [22_050, 32_000, 44_100, 48_000, 96_000],
          'audio.sampleRate',
          errors
        );
  const channels =
    value.channels === undefined
      ? undefined
      : readUnion(value.channels, [1, 2, 6], 'audio.channels', errors);
  if (!codec) {
    return undefined;
  }
  return {
    codec,
    ...(bitrateKbps !== undefined ? { bitrateKbps } : {}),
    ...(sampleRate ? { sampleRate } : {}),
    ...(channels ? { channels } : {}),
  };
}

function rejectUnknownFields(
  value: Record<string, unknown>,
  allowlist: ReadonlySet<string>,
  label: string,
  errors: string[]
): void {
  for (const key of Object.keys(value)) {
    if (!allowlist.has(key)) {
      errors.push(`${label} contains unsupported field "${key}"`);
    }
  }
}

function readIdentifier(
  value: unknown,
  label: string,
  errors: string[]
): string | undefined {
  if (
    typeof value !== 'string' ||
    !/^[a-z0-9][a-z0-9._-]{0,79}$/iu.test(value)
  ) {
    errors.push(`${label} must be a safe identifier`);
    return undefined;
  }
  return value;
}

function readText(
  value: unknown,
  label: string,
  minimum: number,
  maximum: number,
  errors: string[]
): string | undefined {
  if (
    typeof value !== 'string' ||
    value.trim().length < minimum ||
    value.trim().length > maximum ||
    // eslint-disable-next-line no-control-regex -- imported preset text must reject non-printing control characters.
    /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
  ) {
    errors.push(`${label} must contain ${minimum}–${maximum} safe characters`);
    return undefined;
  }
  return value.trim();
}

function readOptionalText(
  value: unknown,
  label: string,
  maximum: number,
  errors: string[]
): string | undefined {
  if (value === undefined) {
    return undefined;
  }
  return readText(value, label, 1, maximum, errors);
}

function readExtension(value: unknown, errors: string[]): string | undefined {
  if (typeof value !== 'string' || !/^[a-z0-9]{1,10}$/u.test(value)) {
    errors.push('fileExtension must contain 1–10 lowercase letters or digits');
    return undefined;
  }
  return value;
}

function readUnion<T extends string | number>(
  value: unknown,
  values: readonly T[],
  label: string,
  errors: string[]
): T | undefined {
  const match = values.find((entry) => entry === value);
  if (match === undefined) {
    errors.push(`${label} has an unsupported value`);
  }
  return match;
}

function readOptionalNumber(
  value: unknown,
  label: string,
  minimum: number,
  maximum: number,
  errors: string[]
): number | undefined {
  if (value === undefined) {
    return undefined;
  }
  if (
    typeof value !== 'number' ||
    !Number.isFinite(value) ||
    value < minimum ||
    value > maximum
  ) {
    errors.push(`${label} must be between ${minimum} and ${maximum}`);
    return undefined;
  }
  return value;
}

function readOptionalInteger(
  value: unknown,
  label: string,
  minimum: number,
  maximum: number,
  errors: string[]
): number | undefined {
  const result = readOptionalNumber(value, label, minimum, maximum, errors);
  if (result !== undefined && !Number.isSafeInteger(result)) {
    errors.push(`${label} must be an integer`);
    return undefined;
  }
  return result;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function deepFreezePreset(preset: UserExportPreset): UserExportPreset {
  return Object.freeze({
    ...preset,
    ...(preset.video ? { video: Object.freeze({ ...preset.video }) } : {}),
    ...(preset.audio ? { audio: Object.freeze({ ...preset.audio }) } : {}),
  });
}
