import type { AssVerticalBand, SubtitleCue } from '../../types';
import {
  removeAssControlDebrisLines,
  removeLiveGlyphFragmentLines,
} from '../../core/services/ass-text';

// Slack on top of each cue's recorded animation envelope, for time-pos observation
// staleness and small user sub-delay offsets. The envelope itself covers how far
// entrance/exit frames actually run past the authored timing.
const LIVE_CUE_EDGE_TOLERANCE_SECONDS = 1;

export interface ResolvedPrimarySubtitle {
  text: string;
  startTime: number;
  endTime: number;
  /** The parsed cues behind `text`, for consumers that record lines individually. */
  cues: SubtitleCue[];
}

function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): boolean {
  return (cues ?? []).some(
    (cue) =>
      cue.source === 'canonical-ass' ||
      cue.source === 'reconstructed-ass' ||
      cue.assLayout !== undefined,
  );
}

function decodedLiveText(
  liveText: string,
  cues: readonly SubtitleCue[] | null | undefined,
): string {
  return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(liveText) : liveText;
}

function animationSpan(cue: SubtitleCue): { start: number; end: number } {
  return {
    start: cue.animationStartTime ?? cue.startTime,
    end: cue.animationEndTime ?? cue.endTime,
  };
}

function nearbyCanonicalCues(
  cues: readonly SubtitleCue[] | null | undefined,
  currentTimeSec: number,
  includeFragmentGrids = false,
): SubtitleCue[] {
  return (cues ?? []).filter((cue) => {
    if (
      (cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') ||
      (!includeFragmentGrids && cue.assLayout?.kind === 'fragment-grid')
    ) {
      return false;
    }
    const span = animationSpan(cue);
    return (
      span.end >= currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS &&
      span.start <= currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS
    );
  });
}

function compactWhitespace(text: string): string {
  return text.normalize('NFKC').replace(/\s+/gu, '');
}

/**
 * Distinct simultaneous cues are separated by a blank line so the display layer can tell
 * a wrap inside one utterance from the boundary between two of them. Consumers that read
 * the text rather than display it fold these back to single breaks.
 */
const CUE_BOUNDARY = '\n\n';

const VERTICAL_BAND_RANK: Record<AssVerticalBand, number> = { top: 0, middle: 1, bottom: 2 };

/**
 * Stack simultaneous cues the way they sit on screen: mpv keeps a top-anchored lyric or
 * sign above bottom dialogue for its whole run, while cue-list order follows start time
 * and would swap the pair whenever one side is replaced mid-overlap. The band is
 * constant per event, so a line never changes rows while it is displayed.
 *
 * A cue whose placement could not be read -- an unknown style, a script with no styles
 * section -- sorts to the top. Dialogue is the case that reliably declares a bottom
 * alignment, so what is left unresolved is more often a sign or a song line, and keeping
 * the dialogue on the bottom row means the line worth reading stays where the eye
 * already is. Sort is stable, so cues sharing a rank keep their existing order.
 */
function orderCuesForDisplay(cues: readonly SubtitleCue[]): SubtitleCue[] {
  const rank = (cue: SubtitleCue): number =>
    VERTICAL_BAND_RANK[cue.assLayout?.verticalBand ?? 'top'];
  return [...cues].sort((a, b) => rank(a) - rank(b));
}

// ASS layers can encode the same visible spacing with ordinary, hard, or
// ideographic spaces. Matching and emission must use the same identity or each
// layer reappears as a copy.
function uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] {
  const groups: string[] = [];
  const seen = new Set<string>();
  for (const cue of cues) {
    const lines: string[] = [];
    for (const line of cue.text.split('\n')) {
      const compactText = compactWhitespace(line);
      if (!compactText || seen.has(compactText)) continue;
      seen.add(compactText);
      lines.push(line);
    }
    if (lines.length > 0) {
      groups.push(lines.join('\n'));
    }
  }
  return groups;
}

function compactLineSegments(text: string): string[] {
  return text.split('\n').map(compactWhitespace).filter(Boolean);
}

/**
 * Parsed cues have already collapsed exact ASS layers and animation runs. Trust that
 * cleaner view only when every live mpv line is accounted for by an active parsed cue.
 * This keeps unrelated concurrent dialogue on the live fallback while removing style
 * stacks where mpv repeats one full lyric for fill, border, blur, and shadow layers.
 */
function resolveActiveParsedPrimarySubtitle(options: {
  liveText: string;
  currentTimeSec: number;
  cues: readonly SubtitleCue[] | null | undefined;
}): ResolvedPrimarySubtitle | null {
  if (!Number.isFinite(options.currentTimeSec)) {
    return null;
  }

  const liveSegments = compactLineSegments(options.liveText);
  if (liveSegments.length === 0) {
    return null;
  }
  const liveSegmentSet = new Set(liveSegments);
  const selected = (options.cues ?? []).filter((cue) => {
    if (
      cue.startTime > options.currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS ||
      cue.endTime <= options.currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS
    ) {
      return false;
    }
    const cueSegments = compactLineSegments(cue.text);
    if (cueSegments.length === 0) return false;
    if (cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass') {
      return liveSegments.some((segment) =>
        cueSegments.some((cueSegment) => cueSegment.includes(segment)),
      );
    }
    return cueSegments.every((segment) => liveSegmentSet.has(segment));
  });
  if (selected.length === 0) {
    return null;
  }

  const parsedSegments = selected.flatMap((cue) => {
    const recovered = cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass';
    return [
      ...compactLineSegments(cue.text).map((segment) => ({ segment, recovered })),
      ...(cue.assFurigana ?? []).flatMap((text) =>
        compactLineSegments(text).map((segment) => ({ segment, recovered: false })),
      ),
    ];
  });
  if (
    !liveSegments.every((liveSegment) =>
      parsedSegments.some(({ segment, recovered }) =>
        recovered ? segment.includes(liveSegment) : segment === liveSegment,
      ),
    )
  ) {
    return null;
  }

  // A cue selected only through the edge tolerance on its end has already finished by
  // its published timing: a lyric whose exit ghosts linger into the next line. It still
  // explains those live fragments above, but must not re-surface beside cues that are
  // still running. The start side keeps the tolerance: mpv publishes the combined
  // sub-text the moment a joining line's first frame renders, while the observed
  // time-pos still sits just before that line's start, and the selection above already
  // required the cue's text to be on screen (#220). With every selected cue finished,
  // the edge cues remain the display fallback for stale time-pos readings.
  const unfinished = selected.filter((cue) => cue.endTime > options.currentTimeSec);
  const displayCues = unfinished.length > 0 ? unfinished : selected;

  // Dense sign grids still explain their raw mpv fragments, but are visual
  // typesetting rather than a publishable subtitle line.
  const groups = uniqueCueTextGroups(
    orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')),
  );
  return {
    text: groups.join(CUE_BOUNDARY),
    startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
    endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
    cues: displayCues,
  };
}

/**
 * mpv's `sub-text` renders each simultaneously active ASS event on its own line, so
 * while a generated animation plays every live line is a contiguous piece of the
 * authored text. A line that is not -- concurrent dialogue during an insert song, or a
 * fresh line starting just after the animation ended -- proves the live text is not this
 * animation, and substituting the canonical line would swallow real dialogue.
 */
function liveTextIsFromCues(liveText: string, cues: readonly SubtitleCue[]): boolean {
  const compactCues = cues.map((cue) => compactWhitespace(cue.text));
  const segments = liveText.split('\n').map(compactWhitespace).filter(Boolean);
  return (
    segments.length > 0 &&
    segments.every((segment) => compactCues.some((cueText) => cueText.includes(segment)))
  );
}

export function resolveCanonicalPrimarySubtitle(options: {
  liveText: string;
  currentTimeSec: number;
  cues: readonly SubtitleCue[] | null | undefined;
}): ResolvedPrimarySubtitle | null {
  if (!Number.isFinite(options.currentTimeSec)) {
    return null;
  }

  // Consecutive karaoke lines overlap: one line's exit frames are still on screen while
  // the next line's entrance frames appear. The fragment check therefore runs against
  // every canonical cue whose animation envelope reaches the current time, while only
  // the active (or single nearest) cue supplies the displayed text.
  const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
  const active = nearby.filter(
    (cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
  );
  const liveSegments = options.liveText.split('\n').map(compactWhitespace).filter(Boolean);
  const selected =
    active.length > 0
      ? active
      : nearby
          // Between authored spans, proximity alone can pick the wrong neighbor: the
          // next line can sit closer while only the previous line's exit fragments are
          // on screen. Only cues that explain at least one live line may be selected.
          .filter((cue) => {
            const cueText = compactWhitespace(cue.text);
            return liveSegments.some((segment) => cueText.includes(segment));
          })
          .map((cue) => {
            const span = animationSpan(cue);
            const distance =
              options.currentTimeSec < span.start
                ? span.start - options.currentTimeSec
                : Math.max(0, options.currentTimeSec - span.end);
            return { cue, distance };
          })
          .sort((a, b) => a.distance - b.distance || a.cue.startTime - b.cue.startTime)
          .slice(0, 1)
          .map(({ cue }) => cue);
  if (selected.length === 0 || !liveTextIsFromCues(options.liveText, nearby)) {
    return null;
  }

  const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
  return {
    text: groups.join(CUE_BOUNDARY),
    startTime: Math.min(...selected.map((cue) => cue.startTime)),
    endTime: Math.max(...selected.map((cue) => cue.endTime)),
    cues: selected,
  };
}

/**
 * Live text with generated-animation fragment lines removed. Recording paths use this
 * when full canonical substitution declined -- concurrent dialogue during an insert
 * song: the dialogue is worth recording, the glyph fragments beside it are not. An
 * all-fragment visual grid becomes empty; other all-matched input remains unchanged as a
 * defensive fallback.
 */
export function stripCanonicalFragmentLines(options: {
  liveText: string;
  currentTimeSec: number;
  cues: readonly SubtitleCue[] | null | undefined;
}): string {
  if (!Number.isFinite(options.currentTimeSec)) {
    return removeLiveGlyphFragmentLines(options.liveText);
  }
  const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true);
  if (nearby.length === 0) {
    return removeLiveGlyphFragmentLines(options.liveText);
  }
  const compactCues = nearby.map((cue) => compactWhitespace(cue.text));
  const kept = options.liveText.split('\n').filter((line) => {
    const compact = compactWhitespace(line);
    return compact && !compactCues.some((cueText) => cueText.includes(compact));
  });
  if (kept.length > 0) return removeLiveGlyphFragmentLines(kept.join('\n'));
  if (nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid')) return '';
  return removeLiveGlyphFragmentLines(options.liveText);
}

/**
 * Recording text for a live sample. Callers substitute canonical cues themselves and
 * record those cue by cue, so what is resolved here is the parsed view -- the one that
 * folds ASS furigana events back into their base line. Its text is already a complete
 * line, while fragment stripping takes raw mpv text and would discard a resolved line
 * whole while a fragment grid is on screen, so only one of the two ever runs.
 */
export function resolveRecordedPrimarySubtitleText(options: {
  liveText: string;
  currentTimeSec: number;
  cues: readonly SubtitleCue[] | null | undefined;
}): string {
  const liveText = decodedLiveText(options.liveText, options.cues);
  if (!liveText.trim()) {
    return liveText;
  }
  return (
    resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
    stripCanonicalFragmentLines({ ...options, liveText })
  );
}

/**
 * The parsed view of the live text with its cue timings: a canonical animation when one
 * explains the live lines, otherwise the active parsed cues. Null when the parsed cues
 * cannot account for every live line, in which case callers keep the raw mpv text.
 */
export function resolvePrimarySubtitle(options: {
  liveText: string;
  currentTimeSec: number;
  cues: readonly SubtitleCue[] | null | undefined;
}): ResolvedPrimarySubtitle | null {
  const liveText = decodedLiveText(options.liveText, options.cues);
  if (!liveText.trim()) {
    return null;
  }
  return (
    resolveCanonicalPrimarySubtitle({ ...options, liveText }) ??
    resolveActiveParsedPrimarySubtitle({ ...options, liveText })
  );
}

export function resolvePrimarySubtitleText(options: {
  liveText: string;
  currentTimeSec: number;
  cues: readonly SubtitleCue[] | null | undefined;
}): string {
  const liveText = decodedLiveText(options.liveText, options.cues);
  if (!liveText.trim()) {
    return liveText;
  }
  return resolvePrimarySubtitle(options)?.text ?? removeLiveGlyphFragmentLines(liveText);
}
