import { roundSeconds } from './duration';

export type TimecodeParseFailure =
  | 'empty'
  | 'invalid-syntax'
  | 'negative-not-allowed'
  | 'minutes-out-of-range'
  | 'seconds-out-of-range'
  | 'frames-out-of-range'
  | 'frame-rate-required'
  | 'invalid-frame-rate'
  | 'not-finite';

export type TimecodeParseResult =
  { ok: true; seconds: number } | { ok: false; reason: TimecodeParseFailure };

export interface ParseTimecodeOptions {
  allowNegative?: boolean;
  /**
   * Enables HH:MM:SS:FF input. This is nominal, non-drop-frame timecode.
   */
  frameRate?: number;
}

export interface FormatTimecodeOptions {
  decimalPlaces?: number;
  alwaysShowHours?: boolean;
}

const DECIMAL_COMPONENT = /^\d+(?:\.\d+)?$/;
const INTEGER_COMPONENT = /^\d+$/;

export function parseTimecodeDetailed(
  input: string | number,
  options: ParseTimecodeOptions = {}
): TimecodeParseResult {
  const text = String(input).trim();
  if (!text) {
    return { ok: false, reason: 'empty' };
  }

  const negative = text.startsWith('-');
  if (negative && options.allowNegative !== true) {
    return { ok: false, reason: 'negative-not-allowed' };
  }

  const unsigned = negative || text.startsWith('+') ? text.slice(1) : text;
  const parts = unsigned.split(':');
  if (parts.length > 4 || parts.some((part) => part === '')) {
    return { ok: false, reason: 'invalid-syntax' };
  }

  let seconds: number;
  if (parts.length === 4) {
    if (options.frameRate === undefined) {
      return { ok: false, reason: 'frame-rate-required' };
    }
    if (!isValidFrameRate(options.frameRate)) {
      return { ok: false, reason: 'invalid-frame-rate' };
    }
    if (!parts.every((part) => INTEGER_COMPONENT.test(part))) {
      return { ok: false, reason: 'invalid-syntax' };
    }

    const hours = Number(parts[0]);
    const minutes = Number(parts[1]);
    const wholeSeconds = Number(parts[2]);
    const frames = Number(parts[3]);
    if (minutes >= 60) {
      return { ok: false, reason: 'minutes-out-of-range' };
    }
    if (wholeSeconds >= 60) {
      return { ok: false, reason: 'seconds-out-of-range' };
    }
    if (frames >= Math.ceil(options.frameRate)) {
      return { ok: false, reason: 'frames-out-of-range' };
    }
    seconds =
      hours * 3_600 + minutes * 60 + wholeSeconds + frames / options.frameRate;
  } else {
    if (
      !parts.every((part, index) =>
        index === parts.length - 1
          ? DECIMAL_COMPONENT.test(part)
          : INTEGER_COMPONENT.test(part)
      )
    ) {
      return { ok: false, reason: 'invalid-syntax' };
    }
    const values = parts.map(Number);
    const last = values.at(-1);
    if (last === undefined) {
      return { ok: false, reason: 'invalid-syntax' };
    }
    if (parts.length >= 2 && last >= 60) {
      return { ok: false, reason: 'seconds-out-of-range' };
    }
    if (parts.length === 3 && (values[1] ?? 0) >= 60) {
      return { ok: false, reason: 'minutes-out-of-range' };
    }

    seconds =
      parts.length === 1
        ? last
        : parts.length === 2
          ? (values[0] ?? 0) * 60 + last
          : (values[0] ?? 0) * 3_600 + (values[1] ?? 0) * 60 + last;
  }

  seconds *= negative ? -1 : 1;
  if (!Number.isFinite(seconds)) {
    return { ok: false, reason: 'not-finite' };
  }
  return { ok: true, seconds: roundSeconds(seconds, 9) };
}

export function parseTimecode(
  input: string | number,
  options?: ParseTimecodeOptions
): number | undefined {
  const result = parseTimecodeDetailed(input, options);
  return result.ok ? result.seconds : undefined;
}

export function formatTimecode(
  seconds: number,
  options: FormatTimecodeOptions = {}
): string {
  if (!Number.isFinite(seconds)) {
    throw new RangeError('Timecode seconds must be finite.');
  }
  const decimalPlaces = options.decimalPlaces ?? 3;
  if (
    !Number.isInteger(decimalPlaces) ||
    decimalPlaces < 0 ||
    decimalPlaces > 9
  ) {
    throw new RangeError('Decimal places must be an integer from 0 to 9.');
  }

  const negative = seconds < 0;
  const unitsPerSecond = 10 ** decimalPlaces;
  const totalUnits = Math.round(Math.abs(seconds) * unitsPerSecond);
  const unitsPerMinute = 60 * unitsPerSecond;
  const unitsPerHour = 60 * unitsPerMinute;
  const hours = Math.floor(totalUnits / unitsPerHour);
  const minuteRemainder = totalUnits % unitsPerHour;
  const minutes = Math.floor(minuteRemainder / unitsPerMinute);
  const secondUnits = minuteRemainder % unitsPerMinute;
  const wholeSeconds = Math.floor(secondUnits / unitsPerSecond);
  const fractionalUnits = secondUnits % unitsPerSecond;
  const sign = negative && totalUnits !== 0 ? '-' : '';
  const secondText =
    decimalPlaces === 0
      ? padTwo(wholeSeconds)
      : `${padTwo(wholeSeconds)}.${String(fractionalUnits).padStart(
          decimalPlaces,
          '0'
        )}`;

  if (hours > 0 || options.alwaysShowHours !== false) {
    return `${sign}${String(hours).padStart(2, '0')}:${padTwo(minutes)}:${secondText}`;
  }
  return `${sign}${padTwo(minutes)}:${secondText}`;
}

export function formatFrameTimecode(
  seconds: number,
  frameRate: number
): string {
  if (!Number.isFinite(seconds)) {
    throw new RangeError('Timecode seconds must be finite.');
  }
  if (!isValidFrameRate(frameRate)) {
    throw new RangeError('Frame rate must be finite and positive.');
  }

  const nominalFrameRate = Math.round(frameRate);
  const totalFrames = Math.round(Math.abs(seconds) * frameRate);
  const frames = totalFrames % nominalFrameRate;
  const totalWholeSeconds = Math.floor(totalFrames / nominalFrameRate);
  const wholeSeconds = totalWholeSeconds % 60;
  const totalMinutes = Math.floor(totalWholeSeconds / 60);
  const minutes = totalMinutes % 60;
  const hours = Math.floor(totalMinutes / 60);
  const sign = seconds < 0 && totalFrames !== 0 ? '-' : '';

  return `${sign}${String(hours).padStart(2, '0')}:${padTwo(minutes)}:${padTwo(
    wholeSeconds
  )}:${String(frames).padStart(2, '0')}`;
}

function isValidFrameRate(frameRate: number): boolean {
  return Number.isFinite(frameRate) && frameRate >= 1 && frameRate <= 1_000;
}

function padTwo(value: number): string {
  return String(value).padStart(2, '0');
}
