/*
 * ASS/SSA text handling, split into two deliberately distinct contracts:
 *
 *   assToPlainText()             raw ASS event text -> plain text. Ingestion only.
 *   normalizePlainSubtitleText() already-decoded text -> display/lookup form.
 *
 * Subtitle text is decoded from ASS exactly once, at the point it enters the app: the
 * file cue parser does it for sidecar/embedded scripts, and mpv does it for live text
 * (`sub-text` is already run through mpv's own `ass_to_plaintext`). Everything
 * downstream -- renderer, timing tracker, tokenizer, tokenization cache keys -- gets
 * plain text and only normalizes whitespace, so no layer decodes the same string twice.
 *
 * assToPlainText mirrors mpv's `ass_to_plaintext` rather than inventing its own rules,
 * so a cue parsed from a file reads the same as the same line arriving live:
 *   - `{...}` override blocks are markup
 *   - `\pN ... \p0` runs are vector paths, not dialogue
 *   - `\N`, `\n` and `\h` are the only escapes; `\{`, `\}` and `\\` are NOT escapes,
 *     so `\{注\}` decodes to a lone backslash exactly as mpv renders it
 *   - an unclosed `{` is rendered verbatim instead of swallowing the rest of the line
 * Because the decoder never emits an escape or a closed brace, running it twice is a
 * no-op -- but downstream code should still use normalizePlainSubtitleText.
 */

/** What `\N` and `\n` become. */
export type AssLineBreak = '\n' | ' ';

// `\p<n>` with n > 0 switches libass into vector-drawing mode: everything until the
// next `\p0` is a path (`m 20 0 b 10 0 ...`), not dialogue. The negative lookahead keeps
// `\pos(...)` from being read as a drawing tag.
const ASS_DRAWING_SCALE_PATTERN = /\\p(?![a-zA-Z])(\d*)/g;

function readDrawingScale(block: string): number | null {
  ASS_DRAWING_SCALE_PATTERN.lastIndex = 0;
  let scale: number | null = null;
  let match: RegExpExecArray | null;
  // Drawing mode is whatever the last `\p` tag in this block set it to.
  while ((match = ASS_DRAWING_SCALE_PATTERN.exec(block)) !== null) {
    scale = match[1] ? Number(match[1]) : 0;
  }
  return scale;
}

/** Resolve `\N`, `\n` and `\h`. The only text-level escapes libass recognises. */
function resolveWhitespaceEscapes(text: string, lineBreak: AssLineBreak): string {
  return text.replace(/\\([Nnh])/g, (_match, escaped: string) =>
    escaped === 'h' ? ' ' : lineBreak,
  );
}

/** Strip `{...}` override blocks and the drawing runs they enable. */
function stripAssMarkup(raw: string): string {
  let out = '';
  let cursor = 0;
  let drawing = false;

  while (cursor < raw.length) {
    if (raw[cursor] !== '{') {
      if (!drawing) {
        out += raw[cursor];
      }
      cursor += 1;
      continue;
    }

    const close = raw.indexOf('}', cursor + 1);
    if (close === -1) {
      // mpv shows an unclosed `{` and everything after it. Guessing where the block was
      // meant to end can eat a whole line of dialogue.
      if (!drawing) {
        out += raw.slice(cursor);
      }
      break;
    }

    const scale = readDrawingScale(raw.slice(cursor, close + 1));
    if (scale !== null) {
      drawing = scale > 0;
    }
    cursor = close + 1;
  }

  return out;
}

/**
 * Decode a raw ASS/SSA event text field. Call this once, where the text enters the app;
 * downstream layers take the result as plain text.
 */
export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): string {
  if (!text) return '';
  return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
}

const MALFORMED_ASS_ROTATION_RESET = /^\\?\{\\(?:fr|frx|fry|frz|fax|fay)[-+.0-9]*$/u;

/**
 * Drop non-rendering spacer events left as literal text by a malformed, unclosed ASS
 * rotation reset. These events otherwise become repeated `\\` or `{\\fr0` subtitle
 * lines after mpv-compatible decoding.
 */
export function removeAssControlDebrisLines(text: string): string {
  return text
    .split('\n')
    .filter((line) => {
      const compact = line.replace(/\s+/gu, '');
      return compact !== '\\' && !MALFORMED_ASS_ROTATION_RESET.test(compact);
    })
    .join('\n');
}

const MIN_GLYPH_BURST_LINES = 6;
const MAX_GLYPH_BURST_COMPANION_GLYPHS = 3;

/**
 * Per-glyph karaoke typesetting flattened into live text becomes a wall of
 * single-character lines plus the short syllable currently being typed. No authored
 * subtitle stacks this many one-glyph lines at once, so when the wall is present drop
 * it and its short companion fragments while keeping any concurrent dialogue line.
 */
export function removeLiveGlyphFragmentLines(text: string): string {
  const lines = text.split('\n');
  const singleGlyphLines = lines.filter((line) => [...line.trim()].length === 1).length;
  if (singleGlyphLines < MIN_GLYPH_BURST_LINES) return text;
  return lines
    .filter((line) => [...line.trim()].length > MAX_GLYPH_BURST_COMPANION_GLYPHS)
    .join('\n');
}

export interface NormalizePlainSubtitleTextOptions {
  /** Fold every line break into a single space. */
  collapseLineBreaks?: boolean;
  trim?: boolean;
}

/**
 * Whitespace normalization for text that has already been decoded -- by mpv for live
 * subtitles, by the cue parser for files. Override blocks and drawing runs are none of
 * this function's business; a `{` that reaches here is literal text mpv chose to show.
 *
 * `\N`/`\n`/`\h` are still folded, because subtitle sources outside the ASS path (asbplayer
 * and other websocket clients) forward them raw and the display layer has to cope.
 */
export function normalizePlainSubtitleText(
  text: string,
  options: NormalizePlainSubtitleTextOptions = {},
): string {
  if (!text) return '';
  const { collapseLineBreaks = false, trim = true } = options;

  let normalized = resolveWhitespaceEscapes(
    text.replace(/\r\n/g, '\n'),
    collapseLineBreaks ? ' ' : '\n',
  );
  if (collapseLineBreaks) {
    normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
  } else {
    // Simultaneous cues reach the display layer separated by a blank line; every other
    // consumer wants the plain one-break-per-line form.
    normalized = normalized.replace(/\n{2,}/g, '\n');
  }

  return trim ? normalized.trim() : normalized;
}

/** The contents of each `{...}` block, without the braces. */
export function extractAssOverrideBlocks(text: string): string[] {
  const blocks: string[] = [];
  let cursor = 0;

  while (cursor < text.length) {
    const open = text.indexOf('{', cursor);
    if (open === -1) {
      break;
    }
    const close = text.indexOf('}', open + 1);
    if (close === -1) {
      break;
    }
    blocks.push(text.slice(open + 1, close));
    cursor = close + 1;
  }

  return blocks;
}

export interface AssOverrideCommand {
  /** Tag name without the backslash, e.g. `pos`, `kf`, `1c`. */
  name: string;
  /** Everything the tag was given, e.g. `960,1068` for `\pos(960,1068)`. */
  args: string;
  /** Nested inside a `\t(...)` argument, so its value is animated over the event. */
  animated: boolean;
}

const ASS_OVERRIDE_NAME_PATTERN = /[1-4]?[a-zA-Z]+/y;

function readCommandArgs(block: string, start: number): { args: string; next: number } {
  if (block[start] === '(') {
    let depth = 0;
    for (let i = start; i < block.length; i += 1) {
      if (block[i] === '(') depth += 1;
      else if (block[i] === ')') {
        depth -= 1;
        if (depth === 0) {
          return { args: block.slice(start + 1, i), next: i + 1 };
        }
      }
    }
    return { args: block.slice(start + 1), next: block.length };
  }

  const nextTag = block.indexOf('\\', start);
  const end = nextTag === -1 ? block.length : nextTag;
  return { args: block.slice(start, end), next: end };
}

// `\t(...)` can wrap another `\t(...)`, and nothing in the format stops an author (or a
// malformed file) from nesting them thousands deep. Real typesetting never goes past one
// or two levels, so stop recursing well before the call stack is at risk.
const MAX_ANIMATION_NESTING_DEPTH = 8;

function parseOverrideBlock(
  block: string,
  animated: boolean,
  into: AssOverrideCommand[],
  depth = 0,
): void {
  let cursor = 0;

  while (cursor < block.length) {
    if (block[cursor] !== '\\') {
      cursor += 1;
      continue;
    }

    ASS_OVERRIDE_NAME_PATTERN.lastIndex = cursor + 1;
    const nameMatch = ASS_OVERRIDE_NAME_PATTERN.exec(block);
    if (!nameMatch) {
      cursor += 1;
      continue;
    }

    const name = nameMatch[0];
    const { args, next } = readCommandArgs(block, cursor + 1 + name.length);
    into.push({ name, args: args.trim(), animated });
    // `\t(0,500,\frz30)` animates whatever it wraps, so record the inner tags too.
    if (name === 't' && args.includes('\\') && depth < MAX_ANIMATION_NESTING_DEPTH) {
      parseOverrideBlock(args, true, into, depth + 1);
    }
    cursor = next;
  }
}

/**
 * Override commands with their arguments, in source order. Only `{...}` blocks are
 * inspected, so a `\pos(...)` sitting in visible text is never mistaken for markup.
 */
export function collectAssOverrideCommands(text: string): AssOverrideCommand[] {
  const commands: AssOverrideCommand[] = [];
  for (const block of extractAssOverrideBlocks(text)) {
    parseOverrideBlock(block, false, commands);
  }
  return commands;
}

// Tags that are animated by definition: `\t` interpolates, `\move` travels, and the
// karaoke tags advance a highlight across the event's own duration. Everything else --
// `\pos`, `\clip`, `\frz`, `\blur`, `\fad` -- is a static value for the event, so its
// presence says nothing about whether neighbouring events form one animation.
const ASS_TEMPORAL_COMMANDS = new Set(['t', 'move', 'k', 'kf', 'ko', 'K']);

export function isAssTemporalCommand(name: string): boolean {
  return ASS_TEMPORAL_COMMANDS.has(name);
}

/** True when the event animates on its own, or animates a static tag through `\t(...)`. */
export function hasAssTemporalOverride(commands: readonly AssOverrideCommand[]): boolean {
  return commands.some((command) => command.animated || isAssTemporalCommand(command.name));
}

/**
 * Canonical form of an event's override values, for comparing consecutive events. Two
 * events with the same signature were typeset identically, so neither is a frame of an
 * animation the other belongs to.
 */
export function assOverrideSignature(commands: readonly AssOverrideCommand[]): string {
  return commands.map((command) => `${command.name}(${command.args})`).join('|');
}

export type AssEffectKind = 'none' | 'banner' | 'scroll' | 'karaoke' | 'other';

// The stock effects, matched exactly. Typesetting groups put their own template names in
// this column -- `scrolling-credit` is a static sign, not libass's `Scroll up` -- so a
// prefix match would hand out animation evidence to arbitrary custom effects.
const STOCK_ASS_EFFECTS = new Map<string, AssEffectKind>([
  ['banner', 'banner'],
  ['scroll up', 'scroll'],
  ['scroll down', 'scroll'],
  ['karaoke', 'karaoke'],
]);

/**
 * The event-level `Effect` column. The stock values (`Banner;...`, `Scroll up;...`,
 * `Scroll down;...`, `Karaoke`) all animate; anything else is a custom name and lands in
 * `other`.
 */
export function parseAssEffectField(raw: string): AssEffectKind {
  const value = raw.trim().toLowerCase();
  if (!value) return 'none';

  const name = value.split(';', 1)[0]!.trim();
  return STOCK_ASS_EFFECTS.get(name) ?? 'other';
}

const ANIMATED_ASS_EFFECT_KINDS = new Set<AssEffectKind>(['banner', 'scroll', 'karaoke']);

export function isAnimatedAssEffectKind(kind: AssEffectKind): boolean {
  return ANIMATED_ASS_EFFECT_KINDS.has(kind);
}
