import type {
  ChapterProbe,
  FractionParseOptions,
  FractionParseResult,
  MediaProbe,
  MediaStreamProbe,
  MediaStreamType,
  ParsedProbeReport,
  ProbeWarning,
  ProbeWarningCode,
} from './media.types';

const INTEGER_PATTERN = /^[+-]?\d+$/;
const DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
const FINITE_NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
const DEFAULT_MAX_COMPONENT = 1_000_000_000_000;
const DEFAULT_MAX_ABSOLUTE_VALUE = 1_000_000;

export class ProbeParseError extends Error {
  readonly causeValue?: unknown;

  constructor(message: string, causeValue?: unknown) {
    super(message);
    this.name = 'ProbeParseError';
    this.causeValue = causeValue;
  }
}

export function parseFractionDetailed(
  input: unknown,
  options: FractionParseOptions = {}
): FractionParseResult {
  if (typeof input !== 'string' && typeof input !== 'number') {
    return { ok: false, reason: 'not-a-string-or-number' };
  }

  const text = String(input).trim();
  if (!text) {
    return { ok: false, reason: 'empty' };
  }

  const maxComponent = normalizePositiveLimit(
    options.maxComponent,
    DEFAULT_MAX_COMPONENT
  );
  const maxAbsoluteValue = normalizePositiveLimit(
    options.maxAbsoluteValue,
    DEFAULT_MAX_ABSOLUTE_VALUE
  );

  let value: number;
  const slashIndex = text.indexOf('/');
  if (slashIndex >= 0) {
    if (
      text.indexOf('/', slashIndex + 1) >= 0 ||
      slashIndex === 0 ||
      slashIndex === text.length - 1
    ) {
      return { ok: false, reason: 'invalid-syntax' };
    }

    const numeratorText = text.slice(0, slashIndex).trim();
    const denominatorText = text.slice(slashIndex + 1).trim();
    if (
      !INTEGER_PATTERN.test(numeratorText) ||
      !INTEGER_PATTERN.test(denominatorText)
    ) {
      return { ok: false, reason: 'invalid-syntax' };
    }

    const numerator = BigInt(numeratorText);
    const denominator = BigInt(denominatorText);
    if (denominator === 0n) {
      return { ok: false, reason: 'division-by-zero' };
    }

    const componentLimit = BigInt(Math.floor(maxComponent));
    if (
      absoluteBigInt(numerator) > componentLimit ||
      absoluteBigInt(denominator) > componentLimit
    ) {
      return { ok: false, reason: 'component-too-large' };
    }

    value = Number(numerator) / Number(denominator);
  } else {
    if (!DECIMAL_PATTERN.test(text)) {
      return { ok: false, reason: 'invalid-syntax' };
    }
    value = Number(text);
  }

  if (!Number.isFinite(value)) {
    return { ok: false, reason: 'not-finite' };
  }
  if (Math.abs(value) > maxAbsoluteValue) {
    return { ok: false, reason: 'result-too-large' };
  }
  if (value < 0 && options.allowNegative === false) {
    return { ok: false, reason: 'negative-not-allowed' };
  }
  if (value === 0 && options.allowZero === false) {
    return { ok: false, reason: 'zero-not-allowed' };
  }

  return { ok: true, value };
}

export function parseFraction(
  input: unknown,
  options?: FractionParseOptions
): number | undefined {
  const result = parseFractionDetailed(input, options);
  return result.ok ? result.value : undefined;
}

export function parseFrameRate(input: unknown): number | undefined {
  return parseFraction(input, {
    allowNegative: false,
    allowZero: false,
    maxComponent: DEFAULT_MAX_COMPONENT,
    maxAbsoluteValue: 1_000,
  });
}

export function parseFfprobeJson(rawReport: string): ParsedProbeReport {
  let raw: unknown;
  try {
    raw = JSON.parse(rawReport) as unknown;
  } catch (error) {
    throw new ProbeParseError('ffprobe returned invalid JSON.', error);
  }

  return {
    probe: normalizeFfprobeReport(raw),
    rawReport,
  };
}

export function normalizeFfprobeReport(raw: unknown): MediaProbe {
  if (!isRecord(raw)) {
    throw new ProbeParseError('The ffprobe report must be a JSON object.', raw);
  }

  const warnings: ProbeWarning[] = [];
  const format = isRecord(raw.format) ? raw.format : {};
  if (raw.format !== undefined && !isRecord(raw.format)) {
    warn(
      warnings,
      'invalid-field',
      'format',
      'Expected the format section to be an object.',
      raw.format
    );
  }

  const formatName = optionalString(
    format.format_name,
    'format.format_name',
    warnings
  );
  const streams = normalizeStreams(raw.streams, warnings);
  const chapters = normalizeChapters(raw.chapters, warnings);

  return compactObject({
    durationSeconds: optionalNonNegativeNumber(
      format.duration,
      'format.duration',
      warnings
    ),
    startTimeSeconds: optionalFiniteNumber(
      format.start_time,
      'format.start_time',
      warnings
    ),
    bitRate: optionalNonNegativeInteger(
      format.bit_rate,
      'format.bit_rate',
      warnings
    ),
    formatNames: formatName
      ? [
          ...new Set(
            formatName
              .split(',')
              .map((name) => name.trim())
              .filter(Boolean)
          ),
        ]
      : [],
    formatLongName: optionalString(
      format.format_long_name,
      'format.format_long_name',
      warnings
    ),
    tags: normalizeTags(format.tags, 'format.tags', warnings),
    streams,
    chapters,
    warnings,
  });
}

function normalizeStreams(
  value: unknown,
  warnings: ProbeWarning[]
): MediaStreamProbe[] {
  if (value === undefined) {
    return [];
  }
  if (!Array.isArray(value)) {
    warn(
      warnings,
      'invalid-field',
      'streams',
      'Expected streams to be an array.',
      value
    );
    return [];
  }

  const streams: MediaStreamProbe[] = [];
  const streamIndexes = new Set<number>();
  value.forEach((entry, arrayIndex) => {
    const path = `streams[${arrayIndex}]`;
    if (!isRecord(entry)) {
      warn(
        warnings,
        'invalid-stream',
        path,
        'Ignored a stream entry that was not an object.',
        entry
      );
      return;
    }

    const index = optionalNonNegativeInteger(
      entry.index,
      `${path}.index`,
      warnings
    );
    if (index === undefined) {
      warn(
        warnings,
        'missing-stream-index',
        `${path}.index`,
        'Ignored a stream without a valid non-negative index.',
        entry.index
      );
      return;
    }
    if (streamIndexes.has(index)) {
      warn(
        warnings,
        'duplicate-stream-index',
        `${path}.index`,
        `Ignored duplicate stream index ${index}.`,
        index
      );
      return;
    }
    streamIndexes.add(index);

    const tags = normalizeTags(entry.tags, `${path}.tags`, warnings);
    const type = normalizeStreamType(entry.codec_type, path, warnings);
    const frameRate = normalizeFrameRate(entry, path, warnings);
    const timeBase = normalizeStreamTimeBase(entry.time_base, path, warnings);
    const rotationDegrees = normalizeRotation(entry, tags, path, warnings);

    streams.push(
      compactObject({
        index,
        type,
        codecName: optionalString(
          entry.codec_name,
          `${path}.codec_name`,
          warnings
        ),
        codecLongName: optionalString(
          entry.codec_long_name,
          `${path}.codec_long_name`,
          warnings
        ),
        profile: optionalString(entry.profile, `${path}.profile`, warnings),
        durationSeconds: optionalNonNegativeNumber(
          entry.duration,
          `${path}.duration`,
          warnings
        ),
        bitRate: optionalNonNegativeInteger(
          entry.bit_rate,
          `${path}.bit_rate`,
          warnings
        ),
        language: tags.language,
        title: tags.title,
        disposition: normalizeDisposition(
          entry.disposition,
          `${path}.disposition`,
          warnings
        ),
        width: optionalPositiveInteger(entry.width, `${path}.width`, warnings),
        height: optionalPositiveInteger(
          entry.height,
          `${path}.height`,
          warnings
        ),
        codedWidth: optionalPositiveInteger(
          entry.coded_width,
          `${path}.coded_width`,
          warnings
        ),
        codedHeight: optionalPositiveInteger(
          entry.coded_height,
          `${path}.coded_height`,
          warnings
        ),
        pixelFormat: optionalString(entry.pix_fmt, `${path}.pix_fmt`, warnings),
        timeBase,
        sampleAspectRatio: normalizeAspectRatio(
          entry.sample_aspect_ratio,
          `${path}.sample_aspect_ratio`,
          warnings
        ),
        displayAspectRatio: normalizeAspectRatio(
          entry.display_aspect_ratio,
          `${path}.display_aspect_ratio`,
          warnings
        ),
        rotationDegrees,
        frameRate,
        sampleRate: optionalPositiveInteger(
          entry.sample_rate,
          `${path}.sample_rate`,
          warnings
        ),
        channels: optionalPositiveInteger(
          entry.channels,
          `${path}.channels`,
          warnings
        ),
        channelLayout: optionalString(
          entry.channel_layout,
          `${path}.channel_layout`,
          warnings
        ),
        sampleFormat: optionalString(
          entry.sample_fmt,
          `${path}.sample_fmt`,
          warnings
        ),
        tags,
      })
    );
  });

  return streams.sort((left, right) => left.index - right.index);
}

function normalizeStreamTimeBase(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): string | undefined {
  const timeBase = optionalString(value, `${path}.time_base`, warnings);
  if (timeBase === undefined) {
    return undefined;
  }
  if (
    parseFraction(timeBase, {
      allowNegative: false,
      allowZero: false,
      maxAbsoluteValue: 1,
    }) === undefined
  ) {
    warn(
      warnings,
      'invalid-fraction',
      `${path}.time_base`,
      'Ignored an invalid stream time base.',
      value
    );
    return undefined;
  }
  return timeBase;
}

function normalizeChapters(
  value: unknown,
  warnings: ProbeWarning[]
): ChapterProbe[] {
  if (value === undefined) {
    return [];
  }
  if (!Array.isArray(value)) {
    warn(
      warnings,
      'invalid-field',
      'chapters',
      'Expected chapters to be an array.',
      value
    );
    return [];
  }

  const chapters: ChapterProbe[] = [];
  value.forEach((entry, arrayIndex) => {
    const path = `chapters[${arrayIndex}]`;
    if (!isRecord(entry)) {
      warn(
        warnings,
        'invalid-chapter',
        path,
        'Ignored a chapter entry that was not an object.',
        entry
      );
      return;
    }

    const id =
      optionalNonNegativeInteger(entry.id, `${path}.id`, warnings) ??
      arrayIndex;
    const timeBase = optionalString(
      entry.time_base,
      `${path}.time_base`,
      warnings
    );
    const timeBaseValue =
      timeBase === undefined
        ? undefined
        : parseFraction(timeBase, {
            allowNegative: false,
            allowZero: false,
            maxAbsoluteValue: 1,
          });

    if (timeBase !== undefined && timeBaseValue === undefined) {
      warn(
        warnings,
        'invalid-fraction',
        `${path}.time_base`,
        'Ignored an invalid chapter time base.',
        timeBase
      );
    }

    const startSeconds =
      optionalNonNegativeNumber(
        entry.start_time,
        `${path}.start_time`,
        warnings
      ) ??
      ticksToSeconds(entry.start, timeBaseValue, `${path}.start`, warnings);
    const endSeconds =
      optionalNonNegativeNumber(entry.end_time, `${path}.end_time`, warnings) ??
      ticksToSeconds(entry.end, timeBaseValue, `${path}.end`, warnings);

    if (
      startSeconds === undefined ||
      endSeconds === undefined ||
      endSeconds < startSeconds
    ) {
      warn(
        warnings,
        'invalid-chapter',
        path,
        'Ignored a chapter without a valid start and end time.',
        entry
      );
      return;
    }

    const tags = normalizeTags(entry.tags, `${path}.tags`, warnings);
    chapters.push(
      compactObject({
        id,
        startSeconds,
        endSeconds,
        timeBase,
        title: tags.title,
        tags,
      })
    );
  });

  return chapters.sort(
    (left, right) =>
      left.startSeconds - right.startSeconds || left.id - right.id
  );
}

function normalizeFrameRate(
  stream: Record<string, unknown>,
  path: string,
  warnings: ProbeWarning[]
): number | undefined {
  const candidates: Array<[string, unknown]> = [
    ['avg_frame_rate', stream.avg_frame_rate],
    ['r_frame_rate', stream.r_frame_rate],
  ];

  for (const [field, value] of candidates) {
    if (value === undefined || value === null || value === '') {
      continue;
    }
    const frameRate = parseFrameRate(value);
    if (frameRate !== undefined) {
      return frameRate;
    }
    warn(
      warnings,
      'invalid-fraction',
      `${path}.${field}`,
      'Ignored an invalid or implausible frame-rate fraction.',
      value
    );
  }
  return undefined;
}

function normalizeAspectRatio(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): string | undefined {
  if (value === undefined || value === null || value === '') {
    return undefined;
  }
  if (typeof value !== 'string') {
    warn(warnings, 'invalid-field', path, 'Expected an aspect ratio.', value);
    return undefined;
  }

  if (value.length > 64) {
    warn(
      warnings,
      'invalid-fraction',
      path,
      'Ignored an implausibly long aspect ratio.',
      value
    );
    return undefined;
  }
  const match = /^\s*(\d+)\s*[:/]\s*(\d+)\s*$/u.exec(value);
  if (!match?.[1] || !match[2]) {
    warn(
      warnings,
      'invalid-fraction',
      path,
      'Ignored an invalid aspect ratio.',
      value
    );
    return undefined;
  }

  const numerator = BigInt(match[1]);
  const denominator = BigInt(match[2]);
  const componentLimit = BigInt(DEFAULT_MAX_COMPONENT);
  if (
    numerator === 0n ||
    denominator === 0n ||
    numerator > componentLimit ||
    denominator > componentLimit
  ) {
    warn(
      warnings,
      'invalid-fraction',
      path,
      'Ignored a zero or implausibly large aspect ratio.',
      value
    );
    return undefined;
  }

  const divisor = greatestCommonDivisor(numerator, denominator);
  return `${numerator / divisor}:${denominator / divisor}`;
}

function normalizeRotation(
  stream: Record<string, unknown>,
  tags: Record<string, string>,
  path: string,
  warnings: ProbeWarning[]
): number | undefined {
  const sideData = stream.side_data_list;
  if (sideData !== undefined && !Array.isArray(sideData)) {
    warn(
      warnings,
      'invalid-field',
      `${path}.side_data_list`,
      'Expected stream side data to be an array.',
      sideData
    );
  } else if (Array.isArray(sideData)) {
    for (let index = 0; index < sideData.length; index += 1) {
      const entry = sideData[index];
      if (!isRecord(entry)) {
        warn(
          warnings,
          'invalid-field',
          `${path}.side_data_list[${index}]`,
          'Ignored a side-data entry that was not an object.',
          entry
        );
        continue;
      }
      if (entry.rotation === undefined) continue;
      const rotation = normalizeRotationValue(
        entry.rotation,
        `${path}.side_data_list[${index}].rotation`,
        warnings
      );
      if (rotation !== undefined) return rotation;
    }
  }

  const legacyRotation = Object.entries(tags).find(
    ([key]) => key.toLowerCase() === 'rotate'
  )?.[1];
  return legacyRotation === undefined
    ? undefined
    : normalizeRotationValue(legacyRotation, `${path}.tags.rotate`, warnings);
}

function normalizeRotationValue(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): number | undefined {
  const rotation = parseFiniteNumericValue(value);
  if (!Number.isFinite(rotation) || Math.abs(rotation) > 1_000_000) {
    warn(
      warnings,
      'invalid-field',
      path,
      'Ignored invalid or implausible rotation metadata.',
      value
    );
    return undefined;
  }

  const normalized = ((((rotation + 180) % 360) + 360) % 360) - 180;
  const signed = normalized === -180 ? 180 : normalized;
  return Object.is(signed, -0) ? 0 : signed;
}

function normalizeStreamType(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): MediaStreamType {
  if (
    value === 'video' ||
    value === 'audio' ||
    value === 'subtitle' ||
    value === 'attachment' ||
    value === 'data'
  ) {
    return value;
  }

  if (value !== undefined && value !== 'unknown') {
    warn(
      warnings,
      'unknown-stream-type',
      `${path}.codec_type`,
      'Mapped an unrecognized stream type to "unknown".',
      value
    );
  }
  return 'unknown';
}

function normalizeTags(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): Record<string, string> {
  if (value === undefined) {
    return {};
  }
  if (!isRecord(value)) {
    warn(
      warnings,
      'invalid-field',
      path,
      'Expected tags to be an object.',
      value
    );
    return {};
  }

  const tags: Record<string, string> = {};
  for (const [key, tagValue] of Object.entries(value)) {
    if (!isSafeRecordKey(key)) {
      warn(
        warnings,
        'invalid-field',
        `${path}.${key}`,
        'Ignored an unsafe tag key.',
        key
      );
    } else if (typeof tagValue === 'string') {
      tags[key] = tagValue;
    } else {
      warn(
        warnings,
        'invalid-field',
        `${path}.${key}`,
        'Ignored a tag whose value was not a string.',
        tagValue
      );
    }
  }
  return tags;
}

function normalizeDisposition(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): Record<string, boolean> {
  if (value === undefined) {
    return {};
  }
  if (!isRecord(value)) {
    warn(
      warnings,
      'invalid-field',
      path,
      'Expected stream disposition to be an object.',
      value
    );
    return {};
  }

  const disposition: Record<string, boolean> = {};
  for (const [key, flag] of Object.entries(value)) {
    if (!isSafeRecordKey(key)) {
      warn(
        warnings,
        'invalid-field',
        `${path}.${key}`,
        'Ignored an unsafe disposition key.',
        key
      );
    } else if (flag === true || flag === 1 || flag === '1') {
      disposition[key] = true;
    } else if (flag === false || flag === 0 || flag === '0') {
      disposition[key] = false;
    } else {
      warn(
        warnings,
        'invalid-field',
        `${path}.${key}`,
        'Ignored a disposition flag that was not boolean-like.',
        flag
      );
    }
  }
  return disposition;
}

function ticksToSeconds(
  ticks: unknown,
  timeBase: number | undefined,
  path: string,
  warnings: ProbeWarning[]
): number | undefined {
  if (ticks === undefined || timeBase === undefined) {
    return undefined;
  }
  const tickValue = optionalNonNegativeInteger(ticks, path, warnings);
  return tickValue === undefined ? undefined : tickValue * timeBase;
}

function optionalString(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): string | undefined {
  if (value === undefined || value === null || value === '') {
    return undefined;
  }
  if (typeof value !== 'string') {
    warn(warnings, 'invalid-field', path, 'Expected a string value.', value);
    return undefined;
  }
  return value;
}

function optionalFiniteNumber(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): number | undefined {
  if (value === undefined || value === null || value === '') {
    return undefined;
  }
  const number = parseFiniteNumericValue(value);
  if (!Number.isFinite(number)) {
    warn(
      warnings,
      'invalid-field',
      path,
      'Expected a finite numeric value.',
      value
    );
    return undefined;
  }
  return number;
}

function optionalNonNegativeNumber(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): number | undefined {
  const number = optionalFiniteNumber(value, path, warnings);
  if (number === undefined) {
    return undefined;
  }
  if (number < 0) {
    warn(
      warnings,
      'invalid-field',
      path,
      'Expected a non-negative numeric value.',
      value
    );
    return undefined;
  }
  return number;
}

function optionalPositiveInteger(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): number | undefined {
  const integer = optionalInteger(value, path, warnings);
  if (integer === undefined) {
    return undefined;
  }
  if (integer <= 0) {
    warn(
      warnings,
      'invalid-field',
      path,
      'Expected a positive integer.',
      value
    );
    return undefined;
  }
  return integer;
}

function optionalNonNegativeInteger(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): number | undefined {
  const integer = optionalInteger(value, path, warnings);
  if (integer === undefined) {
    return undefined;
  }
  if (integer < 0) {
    warn(
      warnings,
      'invalid-field',
      path,
      'Expected a non-negative integer.',
      value
    );
    return undefined;
  }
  return integer;
}

function optionalInteger(
  value: unknown,
  path: string,
  warnings: ProbeWarning[]
): number | undefined {
  if (value === undefined || value === null || value === '') {
    return undefined;
  }
  const number = parseFiniteNumericValue(value);
  if (!Number.isSafeInteger(number)) {
    warn(warnings, 'invalid-field', path, 'Expected a safe integer.', value);
    return undefined;
  }
  return number;
}

function warn(
  warnings: ProbeWarning[],
  code: ProbeWarningCode,
  path: string,
  message: string,
  received: unknown
): void {
  warnings.push({
    code,
    path,
    message,
    received: describe(received),
  });
}

function describe(value: unknown): string {
  if (typeof value === 'string') {
    return value.length > 120 ? `${value.slice(0, 117)}...` : value;
  }
  try {
    const serialized = JSON.stringify(value);
    return serialized === undefined ? String(value) : serialized.slice(0, 120);
  } catch {
    return String(value);
  }
}

function compactObject<T extends object>(value: T): T {
  for (const key of Object.keys(value) as Array<keyof T>) {
    if (value[key] === undefined) {
      delete value[key];
    }
  }
  return value;
}

function normalizePositiveLimit(
  value: number | undefined,
  fallback: number
): number {
  return value !== undefined && Number.isFinite(value) && value > 0
    ? value
    : fallback;
}

function absoluteBigInt(value: bigint): bigint {
  return value < 0n ? -value : value;
}

function greatestCommonDivisor(left: bigint, right: bigint): bigint {
  let dividend = left;
  let divisor = right;
  while (divisor !== 0n) {
    const remainder = dividend % divisor;
    dividend = divisor;
    divisor = remainder;
  }
  return dividend;
}

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

function parseFiniteNumericValue(value: unknown): number {
  if (typeof value === 'number') {
    return value;
  }
  if (typeof value !== 'string' || !FINITE_NUMBER_PATTERN.test(value.trim())) {
    return Number.NaN;
  }
  return Number(value);
}

function isSafeRecordKey(key: string): boolean {
  return key !== '__proto__' && key !== 'prototype' && key !== 'constructor';
}
