import type {
  CommandDiagnostic,
  PlannedInput,
  PlannedOutput,
  PresetRequirements,
} from './command-plan';

// eslint-disable-next-line no-control-regex -- control characters are deliberately removed from virtual filenames.
const CONTROL_OR_SEPARATOR = /[\u0000-\u001f\u007f/\\]+/gu;
const UNSAFE_FILE_CHARACTER = /[^a-zA-Z0-9._-]+/gu;
const RESERVED_COMPONENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;

export interface SourceReference {
  readonly id: string;
  readonly sourceIndex: number;
  readonly fileName: string;
  readonly extension?: string;
}

export interface OutputReference {
  readonly id?: string;
  readonly baseName: string;
  readonly operation: string;
  readonly extension: string;
  readonly role?: PlannedOutput['role'];
  readonly mediaType?: string;
  readonly suffix?: string;
  readonly timeRange?: PlannedOutput['timeRange'];
  /** Optional user-facing leaf name. Its extension is always replaced. */
  readonly fileName?: string;
}

export interface StreamSelection {
  readonly video?: readonly number[];
  readonly audio?: readonly number[];
  readonly subtitles?: readonly number[];
  readonly attachments?: readonly number[];
  readonly data?: readonly number[];
}

export function normalizeExtension(extension: string): string {
  const normalized = extension.trim().replace(/^\.+/u, '').toLowerCase();
  if (!/^[a-z0-9]{1,10}$/u.test(normalized)) {
    throw new TypeError(`Invalid file extension: ${extension}`);
  }
  return normalized;
}

export function safePathComponent(value: string, fallback = 'media'): string {
  let result = value
    .normalize('NFKC')
    .replace(CONTROL_OR_SEPARATOR, '-')
    .replace(UNSAFE_FILE_CHARACTER, '-')
    .replace(/-+/gu, '-')
    .replace(/^[ ._-]+|[ ._-]+$/gu, '')
    .slice(0, 96);

  if (
    !result ||
    result === '.' ||
    result === '..' ||
    RESERVED_COMPONENT.test(result)
  ) {
    result = fallback;
  }
  return result;
}

export function sourceVirtualPath(
  jobId: string,
  source: SourceReference
): string {
  const job = safePathComponent(jobId, 'job');
  const extension = source.extension
    ? `.${normalizeExtension(source.extension)}`
    : extensionFromFileName(source.fileName);
  return `/input/job-${job}/source-${source.sourceIndex}${extension}`;
}

export function workVirtualPath(jobId: string, fileName: string): string {
  return `/work/job-${safePathComponent(jobId, 'job')}/${safePathComponent(fileName)}`;
}

export function createPlannedInput(
  jobId: string,
  source: SourceReference,
  kind: PlannedInput['kind'] = 'media'
): PlannedInput {
  return {
    id: source.id,
    path: sourceVirtualPath(jobId, source),
    sourceIndex: source.sourceIndex,
    kind,
  };
}

export function createPlannedOutput(
  jobId: string,
  reference: OutputReference
): PlannedOutput {
  const extension = normalizeExtension(reference.extension);
  const fileName = reference.fileName
    ? `${safePathComponent(stripExtension(reference.fileName), 'output').slice(
        0,
        180
      )}.${extension}`
    : `${[
        safePathComponent(stripExtension(reference.baseName), 'output'),
        safePathComponent(reference.operation, 'export'),
        reference.suffix
          ? safePathComponent(reference.suffix, 'result')
          : undefined,
      ]
        .filter((part): part is string => Boolean(part))
        .join('-')
        .slice(0, 180)}.${extension}`;

  return {
    id: reference.id ?? `${reference.operation}-output`,
    path: workVirtualPath(jobId, fileName),
    fileName,
    mediaType: reference.mediaType ?? mediaTypeForExtension(extension),
    role: reference.role ?? 'media',
    ...(reference.timeRange ? { timeRange: reference.timeRange } : {}),
  };
}

export function mediaTypeForExtension(extension: string): string {
  const mediaTypes: Readonly<Record<string, string>> = {
    mp4: 'video/mp4',
    m4a: 'audio/mp4',
    webm: 'video/webm',
    mkv: 'video/x-matroska',
    mp3: 'audio/mpeg',
    ogg: 'audio/ogg',
    opus: 'audio/ogg',
    wav: 'audio/wav',
    flac: 'audio/flac',
    gif: 'image/gif',
    webp: 'image/webp',
    png: 'image/png',
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    srt: 'application/x-subrip',
    vtt: 'text/vtt',
    ass: 'text/x-ssa',
    json: 'application/json',
  };
  return (
    mediaTypes[normalizeExtension(extension)] ?? 'application/octet-stream'
  );
}

export function muxerForExtension(extension: string): string | undefined {
  const muxers: Readonly<Record<string, string>> = {
    mp4: 'mp4',
    m4a: 'ipod',
    m4v: 'ipod',
    mov: 'mov',
    webm: 'webm',
    mkv: 'matroska',
    mp3: 'mp3',
    ogg: 'ogg',
    opus: 'opus',
    wav: 'wav',
    flac: 'flac',
    gif: 'gif',
    webp: 'webp',
    png: 'image2',
    jpg: 'image2',
    jpeg: 'image2',
    srt: 'srt',
    vtt: 'webvtt',
    ass: 'ass',
  };
  return muxers[normalizeExtension(extension)];
}

export function assertMuxerMatchesExtension(
  muxer: string,
  extension: string
): void {
  const inferred = muxerForExtension(extension);
  if (!inferred) {
    return;
  }
  const normalizedMuxer =
    muxer === 'mkv' ? 'matroska' : muxer === 'm4a' ? 'ipod' : muxer;
  if (normalizedMuxer !== inferred) {
    throw new TypeError(
      `Muxer ${muxer} does not match the .${normalizeExtension(extension)} output extension (expected ${inferred})`
    );
  }
}

export function stripExtension(fileName: string): string {
  const cleaned = fileName.replace(CONTROL_OR_SEPARATOR, '-');
  const lastDot = cleaned.lastIndexOf('.');
  return lastDot > 0 ? cleaned.slice(0, lastDot) : cleaned;
}

function extensionFromFileName(fileName: string): string {
  const lastDot = fileName.lastIndexOf('.');
  if (lastDot <= 0 || lastDot === fileName.length - 1) {
    return '';
  }
  try {
    return `.${normalizeExtension(fileName.slice(lastDot + 1))}`;
  } catch {
    return '';
  }
}

export function assertFiniteNonNegative(value: number, name: string): void {
  if (!Number.isFinite(value) || value < 0) {
    throw new RangeError(`${name} must be a finite nonnegative number`);
  }
}

export function normalizeSeconds(value: number): string {
  assertFiniteNonNegative(value, 'time');
  if (value === 0) {
    return '0';
  }
  return value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '');
}

export function validateRange(
  startSeconds: number,
  endSeconds: number,
  durationSeconds?: number,
  minimumDurationSeconds = 0.04
): void {
  assertFiniteNonNegative(startSeconds, 'startSeconds');
  assertFiniteNonNegative(endSeconds, 'endSeconds');
  if (endSeconds <= startSeconds) {
    throw new RangeError('endSeconds must be greater than startSeconds');
  }
  if (endSeconds - startSeconds < minimumDurationSeconds) {
    throw new RangeError(
      `clip duration must be at least ${minimumDurationSeconds} seconds`
    );
  }
  if (
    durationSeconds !== undefined &&
    (!Number.isFinite(durationSeconds) || endSeconds > durationSeconds + 1e-6)
  ) {
    throw new RangeError('clip range exceeds source duration');
  }
}

export function streamMapArguments(
  selection: StreamSelection
): readonly string[] {
  const result: string[] = [];
  const selectedIndexes = new Set<number>();
  const groups: readonly (keyof StreamSelection)[] = [
    'video',
    'audio',
    'subtitles',
    'attachments',
    'data',
  ];
  for (const key of groups) {
    const indexes = selection[key];
    if (!indexes) {
      continue;
    }
    if (new Set(indexes).size !== indexes.length) {
      throw new RangeError(`Duplicate ${key} stream selection`);
    }
    for (const index of indexes) {
      if (!Number.isSafeInteger(index) || index < 0) {
        throw new RangeError(`Invalid ${key} stream index: ${index}`);
      }
      if (selectedIndexes.has(index)) {
        throw new RangeError(
          `Stream index ${index} was selected in more than one stream group`
        );
      }
      selectedIndexes.add(index);
      // StreamSelection stores the absolute ffprobe stream index. A typed
      // specifier such as 0:a:1 would instead mean "the second audio stream"
      // and silently points at the wrong stream for common video=0/audio=1
      // inputs.
      result.push('-map', `0:${index}`);
    }
  }
  return Object.freeze(result);
}

export function mergeRequirements(
  ...requirements: readonly PresetRequirements[]
): PresetRequirements {
  const kinds = ['muxers', 'encoders', 'decoders', 'filters'] as const;
  const result: Record<(typeof kinds)[number], string[]> = {
    muxers: [],
    encoders: [],
    decoders: [],
    filters: [],
  };
  for (const requirement of requirements) {
    for (const kind of kinds) {
      for (const value of requirement[kind] ?? []) {
        if (!result[kind].includes(value)) {
          result[kind].push(value);
        }
      }
    }
  }
  return Object.fromEntries(
    kinds
      .filter((kind) => result[kind].length > 0)
      .map((kind) => [kind, Object.freeze(result[kind])])
  ) as PresetRequirements;
}

export function diagnostic(
  code: string,
  severity: CommandDiagnostic['severity'],
  message: string,
  field?: string
): CommandDiagnostic {
  return field
    ? { code, severity, message, field }
    : { code, severity, message };
}

/**
 * Shell-like rendering for diagnostics only. The returned string must never be
 * executed; command plans always expose argv separately.
 */
export function displayArguments(args: readonly string[]): string {
  return args.map(displayArgument).join(' ');
}

function displayArgument(argument: string): string {
  if (/^[a-zA-Z0-9_./:=+%@,-]+$/u.test(argument)) {
    return argument;
  }
  return `'${argument.replace(/'/gu, `'\\''`)}'`;
}

export function escapeConcatFilePath(path: string): string {
  if (path.includes('\n') || path.includes('\r') || path.includes('\0')) {
    throw new TypeError('Concat paths cannot contain line breaks or NUL');
  }
  return path.replace(/'/gu, "'\\''");
}

export function createConcatList(paths: readonly string[]): string {
  if (paths.length === 0) {
    throw new RangeError('At least one concat path is required');
  }
  return `${paths.map((path) => `file '${escapeConcatFilePath(path)}'`).join('\n')}\n`;
}

export interface ConcatListEntry {
  readonly path: string;
  readonly inPointSeconds?: number;
  readonly outPointSeconds?: number;
}

export function createConcatListEntries(
  entries: readonly ConcatListEntry[]
): string {
  if (entries.length === 0) {
    throw new RangeError('At least one concat entry is required');
  }
  const lines: string[] = [];
  for (const entry of entries) {
    lines.push(`file '${escapeConcatFilePath(entry.path)}'`);
    if (entry.inPointSeconds !== undefined) {
      lines.push(`inpoint ${normalizeSeconds(entry.inPointSeconds)}`);
    }
    if (entry.outPointSeconds !== undefined) {
      lines.push(`outpoint ${normalizeSeconds(entry.outPointSeconds)}`);
    }
    if (
      entry.inPointSeconds !== undefined &&
      entry.outPointSeconds !== undefined &&
      entry.outPointSeconds <= entry.inPointSeconds
    ) {
      throw new RangeError('Concat outpoint must be after its inpoint');
    }
  }
  return `${lines.join('\n')}\n`;
}

export function escapeFilterValue(value: string): string {
  if (value.includes('\0') || value.includes('\n') || value.includes('\r')) {
    throw new TypeError('Filter values cannot contain line breaks or NUL');
  }
  return value
    .replace(/\\/gu, '\\\\')
    .replace(/'/gu, "\\'")
    .replace(/:/gu, '\\:')
    .replace(/,/gu, '\\,')
    .replace(/;/gu, '\\;')
    .replace(/\[/gu, '\\[')
    .replace(/\]/gu, '\\]');
}

export function escapeMetadataValue(value: string): string {
  if (value.includes('\0')) {
    throw new TypeError('Metadata values cannot contain NUL');
  }
  return value.replace(/\r\n?/gu, '\n');
}
