import type {
  MediaTimingReviewContextLine,
  MediaTimingReviewDecision,
  MediaTimingReviewOpenPayload,
} from '../../types/anki';
import type { ModalStateReader, RendererContext } from '../context';
import { createModalFocusGuard } from './modal-focus-guard';

const MINIMUM_CLIP_SECONDS = 0.1;
const FINE_ADJUST_SECONDS = 0.1;
const COARSE_ADJUST_SECONDS = 0.5;
const TIMELINE_EXPANSION_SECONDS = 2;
const LINE_REVEAL_MARGIN_SECONDS = 1;
/**
 * Subtitles usually linger past the dialogue for readability, so an untouched clip end
 * follows the last speech-weighted waveform slice above this level (0..1, relative to the
 * clip's own noise floor) plus a short tail, when that saves at least the minimum trim.
 */
const SPEECH_LEVEL_THRESHOLD = 0.3;
const SPEECH_TAIL_SECONDS = 0.15;
const MINIMUM_TRAILING_TRIM_SECONDS = 0.1;
/** Slack past the clip length before the UI gives up waiting for mpv's end-of-clip signal. */
const PREVIEW_END_GRACE_MS = 2_500;

function clamp(value: number, minimum: number, maximum: number): number {
  return Math.min(maximum, Math.max(minimum, value));
}

export function formatMediaTimingTimestamp(seconds: number, includeMilliseconds = true): string {
  const unitsPerSecond = includeMilliseconds ? 1_000 : 1;
  const totalUnits = Math.max(0, Math.round(seconds * unitsPerSecond));
  const unitsPerMinute = 60 * unitsPerSecond;
  const minutes = Math.floor(totalUnits / unitsPerMinute);
  const remainingUnits = totalUnits % unitsPerMinute;
  const remaining = includeMilliseconds
    ? (remainingUnits / unitsPerSecond).toFixed(3)
    : String(remainingUnits);
  return `${String(minutes).padStart(2, '0')}:${remaining.padStart(includeMilliseconds ? 6 : 2, '0')}`;
}

/**
 * Resolves which subtitle lines the card sentence currently includes. The counts say
 * how many adjacent lines were pulled in on each side; the range covers those lines'
 * subtitle timings so the clip edges can follow them.
 */
export function buildMediaTimingLineSelection(options: {
  previousLines: MediaTimingReviewContextLine[];
  nextLines: MediaTimingReviewContextLine[];
  text: string;
  originalStartTime: number;
  originalEndTime: number;
  previousCount: number;
  nextCount: number;
}): {
  lineTexts: string[];
  currentLineIndex: number;
  sentence: string;
  rangeStart: number;
  rangeEnd: number;
} {
  const previous =
    options.previousCount > 0 ? options.previousLines.slice(-options.previousCount) : [];
  const next = options.nextCount > 0 ? options.nextLines.slice(0, options.nextCount) : [];
  const lineTexts = [
    ...previous.map((line) => line.text),
    options.text,
    ...next.map((line) => line.text),
  ];
  return {
    lineTexts,
    currentLineIndex: previous.length,
    sentence: lineTexts.join(' '),
    rangeStart: previous[0]?.startTime ?? options.originalStartTime,
    rangeEnd: next[next.length - 1]?.endTime ?? options.originalEndTime,
  };
}

export function createMediaTimingPreviewRequestGuard() {
  let sequence = 0;
  let activeRequestId: number | null = null;

  return {
    begin(): number | null {
      if (activeRequestId !== null) return null;
      sequence += 1;
      activeRequestId = sequence;
      return activeRequestId;
    },
    invalidate(): void {
      sequence += 1;
      activeRequestId = null;
    },
    isCurrent(requestId: number): boolean {
      return requestId === activeRequestId;
    },
    finish(requestId: number): void {
      if (activeRequestId === requestId) activeRequestId = null;
    },
    isInFlight(): boolean {
      return activeRequestId !== null;
    },
  };
}

export function buildMediaTimingWaveformPath(peaks: number[]): string {
  if (peaks.length < 2) return '';
  const points = peaks.map((peak, index) => ({
    x: (index / (peaks.length - 1)) * 1_000,
    amplitude: clamp(Number.isFinite(peak) ? peak : 0, 0, 1) * 44,
  }));
  const upper = points.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 - amplitude).toFixed(2)}`);
  const lower = [...points]
    .reverse()
    .map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 + amplitude).toFixed(2)}`);
  return `M ${upper.join(' L ')} L ${lower.join(' L ')} Z`;
}

/**
 * Where an untouched clip should end once the waveform is known: just after the line's
 * last speech slice, plus the configured end padding. Null keeps the subtitle timing when
 * no speech shows inside the line, when speech runs through the line end (the subtitle is
 * cutting the audio off, not lingering), or when the saving is too small to matter.
 */
export function trimMediaTimingSelectionEnd(options: {
  peaks: readonly number[];
  timelineStart: number;
  timelineEnd: number;
  lineStart: number;
  lineEnd: number;
  selectionEnd: number;
  endPadSeconds: number;
}): number | null {
  const pointCount = options.peaks.length;
  const span = options.timelineEnd - options.timelineStart;
  if (pointCount < 2 || span <= 0 || options.lineEnd <= options.lineStart) return null;
  // Point i covers [i, i + 1) / pointCount of the timeline (see computeWaveformPeaks).
  const sliceEnd = (index: number): number =>
    options.timelineStart + ((index + 1) / pointCount) * span;
  const firstIndex = Math.max(
    0,
    Math.floor(((options.lineStart - options.timelineStart) / span) * pointCount),
  );
  const lastIndex = Math.min(
    pointCount - 1,
    Math.ceil(((options.lineEnd - options.timelineStart) / span) * pointCount) - 1,
  );
  let lastSpeechIndex = -1;
  for (let index = lastIndex; index >= firstIndex; index -= 1) {
    if ((options.peaks[index] ?? 0) >= SPEECH_LEVEL_THRESHOLD) {
      lastSpeechIndex = index;
      break;
    }
  }
  if (lastSpeechIndex === -1 || lastSpeechIndex >= lastIndex) return null;
  const trimmedEnd = sliceEnd(lastSpeechIndex) + SPEECH_TAIL_SECONDS + options.endPadSeconds;
  if (options.selectionEnd - trimmedEnd < MINIMUM_TRAILING_TRIM_SECONDS) return null;
  return trimmedEnd;
}

export function constrainMediaTimingSelection(options: {
  nextStart: number;
  nextEnd: number;
  currentStart: number;
  timelineStart: number;
  timelineEnd: number;
  mediaEnd: number;
  maxMediaDuration: number;
}): { start: number; end: number } {
  let start = clamp(options.nextStart, options.timelineStart, options.timelineEnd);
  let end = clamp(options.nextEnd, options.timelineStart, options.timelineEnd);
  const startMoved = options.nextStart !== options.currentStart;
  if (end - start < MINIMUM_CLIP_SECONDS) {
    if (startMoved) start = end - MINIMUM_CLIP_SECONDS;
    else end = start + MINIMUM_CLIP_SECONDS;
  }
  if (options.maxMediaDuration > 0 && end - start > options.maxMediaDuration) {
    if (startMoved) start = end - options.maxMediaDuration;
    else end = start + options.maxMediaDuration;
  }
  return {
    start: Math.max(0, start),
    end: Math.min(options.mediaEnd, end),
  };
}

/** Maps a pointer position over the timeline track back onto a media timestamp. */
export function mediaTimingTimeFromPointer(options: {
  clientX: number;
  trackLeft: number;
  trackWidth: number;
  timelineStart: number;
  timelineEnd: number;
}): number {
  if (options.trackWidth <= 0) return options.timelineStart;
  const ratio = clamp((options.clientX - options.trackLeft) / options.trackWidth, 0, 1);
  return options.timelineStart + ratio * (options.timelineEnd - options.timelineStart);
}

/** Slides the selection without resizing it, keeping the whole clip inside the visible timeline. */
export function slideMediaTimingSelection(options: {
  nextStart: number;
  span: number;
  timelineStart: number;
  timelineEnd: number;
  mediaEnd: number;
}): { start: number; end: number } {
  const latestEnd = Math.min(options.timelineEnd, options.mediaEnd);
  const maxStart = Math.max(options.timelineStart, latestEnd - options.span);
  const start = clamp(options.nextStart, options.timelineStart, maxStart);
  return { start, end: start + options.span };
}

export function createMediaTimingReviewModal(
  ctx: RendererContext,
  options: {
    modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
    syncSettingsModalSubtitleSuppression: () => void;
  },
) {
  let payload: MediaTimingReviewOpenPayload | null = null;
  let selectionStart = 0;
  let selectionEnd = 0;
  let timelineStart = 0;
  let timelineEnd = 0;
  let previousCount = 0;
  let nextCount = 0;
  let startPadSeconds = 0;
  let endPadSeconds = 0;
  /** True until the user moves the clip; the first waveform then trims trailing silence. */
  let trailingTrimPending = false;
  let resolveInFlight = false;
  let previewPlaying = false;
  let previewTimer: ReturnType<typeof setTimeout> | null = null;
  let waveformTimer: ReturnType<typeof setTimeout> | null = null;
  let waveformSequence = 0;
  let drag: {
    edge: 'start' | 'end' | 'both';
    pointerId: number;
    trackLeft: number;
    trackWidth: number;
    grabOffset: number;
  } | null = null;

  const focus = createModalFocusGuard({
    isOpen: () => ctx.state.mediaTimingReviewModalOpen,
    getModalRoot: () => ctx.dom.mediaTimingReviewModal,
    getPreferredFocusTargets: () => [
      ctx.dom.mediaTimingReviewStartHandle,
      ctx.dom.mediaTimingReviewCancelBack,
    ],
    getFallbackFocusTarget: () => ctx.dom.mediaTimingReviewCancel,
    isModalLayer: ctx.platform.isModalLayer,
  });
  const previewRequest = createMediaTimingPreviewRequestGuard();

  function setStatus(message: string, isError = false): void {
    ctx.dom.mediaTimingReviewStatus.textContent = message;
    ctx.dom.mediaTimingReviewStatus.classList.toggle('is-error', isError);
  }

  function clearPreviewTimer(): void {
    if (previewTimer !== null) clearTimeout(previewTimer);
    previewTimer = null;
  }

  /**
   * Drives the play button label plus the playhead sweep that mirrors the hidden audio player.
   * mpv reports when the clip actually finishes (see handlePreviewEnded), which accounts for
   * output latency such as Bluetooth headphones; the timer only covers a player that never does.
   */
  function setPreviewPlaying(playing: boolean): void {
    previewPlaying = playing;
    ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
    ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
    clearPreviewTimer();
    const track = ctx.dom.mediaTimingReviewSelectionTrack;
    track.classList.remove('is-previewing');
    if (!playing) return;
    const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart);
    track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
    void track.offsetWidth;
    track.classList.add('is-previewing');
    previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000 + PREVIEW_END_GRACE_MS);
  }

  /** The hidden player reached the end of the clip and paused itself. */
  function handlePreviewEnded(reviewId: string): void {
    if (!payload || payload.reviewId !== reviewId || !previewPlaying) return;
    setPreviewPlaying(false);
    setStatus('');
  }

  /** Callers that need to report a failure set their own status after stopping the preview. */
  function stopPreview(): void {
    previewRequest.invalidate();
    setPreviewPlaying(false);
    setStatus('');
    if (payload) {
      void window.electronAPI.stopMediaTimingReviewPreview(payload.reviewId).catch(() => {});
    }
  }

  function renderHandle(handle: HTMLDivElement, value: number): void {
    handle.setAttribute('aria-valuemin', timelineStart.toFixed(3));
    handle.setAttribute('aria-valuemax', timelineEnd.toFixed(3));
    handle.setAttribute('aria-valuenow', value.toFixed(3));
    handle.setAttribute('aria-valuetext', formatMediaTimingTimestamp(value));
  }

  function renderSelection(): void {
    const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
    const startPercent = ((selectionStart - timelineStart) / span) * 100;
    const endPercent = ((selectionEnd - timelineStart) / span) * 100;
    const lineRange = currentLineSelection();
    const originalStartPercent = payload
      ? ((lineRange.rangeStart - timelineStart) / span) * 100
      : 0;
    const originalEndPercent = payload ? ((lineRange.rangeEnd - timelineStart) / span) * 100 : 0;
    ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
      '--selection-start',
      `${clamp(startPercent, 0, 100)}%`,
    );
    ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
      '--selection-end',
      `${clamp(endPercent, 0, 100)}%`,
    );
    ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
      '--original-start',
      `${clamp(originalStartPercent, 0, 100)}%`,
    );
    ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
      '--original-end',
      `${clamp(originalEndPercent, 0, 100)}%`,
    );
    renderHandle(ctx.dom.mediaTimingReviewStartHandle, selectionStart);
    renderHandle(ctx.dom.mediaTimingReviewEndHandle, selectionEnd);
    ctx.dom.mediaTimingReviewStartValue.textContent = formatMediaTimingTimestamp(selectionStart);
    ctx.dom.mediaTimingReviewEndValue.textContent = formatMediaTimingTimestamp(selectionEnd);
    ctx.dom.mediaTimingReviewDuration.textContent = `${(selectionEnd - selectionStart).toFixed(2)}s`;
    ctx.dom.mediaTimingReviewTimelineStart.textContent = formatMediaTimingTimestamp(
      timelineStart,
      false,
    );
    ctx.dom.mediaTimingReviewTimelineEnd.textContent = formatMediaTimingTimestamp(
      timelineEnd,
      false,
    );
    ctx.dom.mediaTimingReviewShowEarlier.disabled = timelineStart <= 0;
    ctx.dom.mediaTimingReviewShowLater.disabled =
      payload?.mediaDuration !== undefined && timelineEnd >= payload.mediaDuration;
  }

  function setWaveformState(state: 'loading' | 'ready' | 'unavailable'): void {
    ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle('is-loading', state === 'loading');
    ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle(
      'is-waveform-unavailable',
      state === 'unavailable',
    );
    ctx.dom.mediaTimingReviewWaveformLabel.textContent =
      state === 'loading'
        ? 'isolating dialogue...'
        : state === 'ready'
          ? 'speech-weighted waveform'
          : 'waveform unavailable';
  }

  async function loadWaveform(): Promise<void> {
    if (!payload || !ctx.state.mediaTimingReviewModalOpen) return;
    waveformSequence += 1;
    const sequence = waveformSequence;
    const reviewId = payload.reviewId;
    const startTime = timelineStart;
    const endTime = timelineEnd;
    setWaveformState('loading');
    ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
    try {
      const result = await window.electronAPI.getMediaTimingReviewWaveform({
        reviewId,
        startTime,
        endTime,
      });
      if (
        sequence !== waveformSequence ||
        payload?.reviewId !== reviewId ||
        timelineStart !== startTime ||
        timelineEnd !== endTime
      ) {
        return;
      }
      if (!result.ok && result.stale) {
        closeResolvedReview();
        return;
      }
      const path = result.ok ? buildMediaTimingWaveformPath(result.peaks ?? []) : '';
      if (!path) {
        setWaveformState('unavailable');
        return;
      }
      ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', path);
      setWaveformState('ready');
      trimTrailingSilence(result.peaks ?? []);
    } catch {
      if (sequence === waveformSequence && payload?.reviewId === reviewId) {
        setWaveformState('unavailable');
      }
    }
  }

  /**
   * Moves an untouched clip end back to where the line's dialogue ends. The Line end
   * rail keeps marking the subtitle timing, and Reset restores it.
   */
  function trimTrailingSilence(peaks: readonly number[]): void {
    if (!payload || !trailingTrimPending || previewPlaying || previewRequest.isInFlight()) {
      return;
    }
    const lineRange = currentLineSelection();
    const trimmedEnd = trimMediaTimingSelectionEnd({
      peaks,
      timelineStart,
      timelineEnd,
      lineStart: lineRange.rangeStart,
      lineEnd: lineRange.rangeEnd,
      selectionEnd,
      endPadSeconds,
    });
    if (trimmedEnd === null) return;
    updateSelection(selectionStart, trimmedEnd);
    setStatus('Clip end moved to where the dialogue ends. Reset restores the subtitle timing.');
  }

  function queueWaveformLoad(delayMs = 0): void {
    if (waveformTimer !== null) clearTimeout(waveformTimer);
    waveformSequence += 1;
    if (payload && ctx.state.mediaTimingReviewModalOpen) {
      ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
      setWaveformState('loading');
    }
    waveformTimer = setTimeout(() => {
      waveformTimer = null;
      void loadWaveform();
    }, delayMs);
  }

  function currentLineSelection(): ReturnType<typeof buildMediaTimingLineSelection> {
    return buildMediaTimingLineSelection({
      previousLines: payload?.previousLines ?? [],
      nextLines: payload?.nextLines ?? [],
      text: payload?.text ?? '',
      originalStartTime: payload?.originalStartTime ?? 0,
      originalEndTime: payload?.originalEndTime ?? 0,
      previousCount,
      nextCount,
    });
  }

  function renderSentence(): void {
    if (!payload) return;
    const selection = currentLineSelection();
    ctx.dom.mediaTimingReviewText.replaceChildren(
      ...selection.lineTexts.map((text, index) => {
        const line = document.createElement('span');
        line.className =
          index === selection.currentLineIndex
            ? 'media-timing-review-line is-current'
            : 'media-timing-review-line';
        line.textContent = text;
        return line;
      }),
    );
    const total = selection.lineTexts.length;
    ctx.dom.mediaTimingReviewLineCount.textContent = total === 1 ? '1 line' : `${total} lines`;
    const hasContext = payload.previousLines.length > 0 || payload.nextLines.length > 0;
    ctx.dom.mediaTimingReviewLineControls.classList.toggle('hidden', !hasContext);
    ctx.dom.mediaTimingReviewPrevAdd.disabled = previousCount >= payload.previousLines.length;
    ctx.dom.mediaTimingReviewPrevRemove.disabled = previousCount <= 0;
    ctx.dom.mediaTimingReviewNextAdd.disabled = nextCount >= payload.nextLines.length;
    ctx.dom.mediaTimingReviewNextRemove.disabled = nextCount <= 0;
  }

  /**
   * Adds or removes an adjacent subtitle line from the card sentence, then follows the
   * affected clip edge to the new outermost line while keeping the user's other edge.
   */
  function adjustLines(direction: 'previous' | 'next', delta: number): void {
    if (!payload || resolveInFlight) return;
    const available =
      direction === 'previous' ? payload.previousLines.length : payload.nextLines.length;
    const current = direction === 'previous' ? previousCount : nextCount;
    const updated = clamp(current + delta, 0, available);
    if (updated === current) return;
    if (direction === 'previous') previousCount = updated;
    else nextCount = updated;

    const selection = currentLineSelection();
    const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
    let timelineChanged = false;
    let capped = false;
    if (direction === 'previous') {
      const target = Math.max(0, selection.rangeStart - startPadSeconds);
      if (target < timelineStart) {
        timelineStart = Math.max(0, target - LINE_REVEAL_MARGIN_SECONDS);
        timelineChanged = true;
      }
      updateSelection(target, selectionEnd);
      capped = selectionStart > target + 0.001;
    } else {
      const target = Math.min(mediaEnd, selection.rangeEnd + endPadSeconds);
      if (target > timelineEnd) {
        timelineEnd = Math.min(mediaEnd, target + LINE_REVEAL_MARGIN_SECONDS);
        timelineChanged = true;
      }
      updateSelection(selectionStart, target);
      capped = selectionEnd < target - 0.001;
    }
    renderSentence();
    if (capped && payload.maxMediaDuration > 0) {
      setStatus(
        `Clip length is capped at ${payload.maxMediaDuration}s, so the audio cannot cover every added line.`,
      );
    }
    if (timelineChanged) queueWaveformLoad(120);
  }

  function updateSelection(nextStart: number, nextEnd: number): void {
    if (!payload) return;
    const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
    const nextSelection = constrainMediaTimingSelection({
      nextStart,
      nextEnd,
      currentStart: selectionStart,
      timelineStart,
      timelineEnd,
      mediaEnd,
      maxMediaDuration: payload.maxMediaDuration,
    });
    selectionStart = nextSelection.start;
    selectionEnd = nextSelection.end;
    trailingTrimPending = false;
    if (previewPlaying || previewRequest.isInFlight()) stopPreview();
    setStatus('');
    renderSelection();
  }

  /** Shifts the whole clip without changing its length. */
  function moveSelection(nextStart: number): void {
    if (!payload) return;
    const slid = slideMediaTimingSelection({
      nextStart,
      span: selectionEnd - selectionStart,
      timelineStart,
      timelineEnd,
      mediaEnd: payload.mediaDuration ?? Number.POSITIVE_INFINITY,
    });
    updateSelection(slid.start, slid.end);
  }

  function setEdge(edge: 'start' | 'end', value: number): void {
    if (edge === 'start') updateSelection(value, selectionEnd);
    else updateSelection(selectionStart, value);
  }

  function applyDrag(clientX: number): void {
    if (!drag) return;
    const pointerTime = mediaTimingTimeFromPointer({
      clientX,
      trackLeft: drag.trackLeft,
      trackWidth: drag.trackWidth,
      timelineStart,
      timelineEnd,
    });
    const time = pointerTime - drag.grabOffset;
    if (drag.edge === 'both') moveSelection(time);
    else setEdge(drag.edge, time);
  }

  /**
   * Grabbing a handle drags that edge, grabbing the highlighted clip slides the whole selection,
   * and pressing anywhere else snaps the nearest edge to that point and keeps dragging it.
   */
  function beginDrag(event: PointerEvent): void {
    if (!payload || resolveInFlight || drag !== null || event.button !== 0) return;
    const track = ctx.dom.mediaTimingReviewSelectionTrack;
    const rect = track.getBoundingClientRect();
    const pointerTime = mediaTimingTimeFromPointer({
      clientX: event.clientX,
      trackLeft: rect.left,
      trackWidth: rect.width,
      timelineStart,
      timelineEnd,
    });
    const target = event.target;
    let edge: 'start' | 'end' | 'both';
    let grabOffset: number;
    if (target === ctx.dom.mediaTimingReviewStartHandle) {
      edge = 'start';
      grabOffset = pointerTime - selectionStart;
    } else if (target === ctx.dom.mediaTimingReviewEndHandle) {
      edge = 'end';
      grabOffset = pointerTime - selectionEnd;
    } else if (target === ctx.dom.mediaTimingReviewSelectedRange) {
      edge = 'both';
      grabOffset = pointerTime - selectionStart;
    } else {
      edge =
        Math.abs(pointerTime - selectionStart) <= Math.abs(pointerTime - selectionEnd)
          ? 'start'
          : 'end';
      grabOffset = 0;
    }
    event.preventDefault();
    drag = {
      edge,
      pointerId: event.pointerId,
      trackLeft: rect.left,
      trackWidth: rect.width,
      grabOffset,
    };
    track.setPointerCapture(event.pointerId);
    ctx.dom.mediaTimingReviewModal.classList.add(edge === 'both' ? 'is-sliding' : 'is-scrubbing');
    if (edge !== 'both') {
      const handle =
        edge === 'start'
          ? ctx.dom.mediaTimingReviewStartHandle
          : ctx.dom.mediaTimingReviewEndHandle;
      handle.focus();
      if (grabOffset === 0) applyDrag(event.clientX);
    }
  }

  function endDrag(event: PointerEvent): void {
    if (!drag || drag.pointerId !== event.pointerId) return;
    const track = ctx.dom.mediaTimingReviewSelectionTrack;
    if (track.hasPointerCapture(event.pointerId)) track.releasePointerCapture(event.pointerId);
    drag = null;
    ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
  }

  function cancelDrag(): void {
    drag = null;
    ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
  }

  function handleEdgeKeydown(event: KeyboardEvent, edge: 'start' | 'end'): void {
    const step = event.shiftKey ? COARSE_ADJUST_SECONDS : FINE_ADJUST_SECONDS;
    const current = edge === 'start' ? selectionStart : selectionEnd;
    if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') setEdge(edge, current - step);
    else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') setEdge(edge, current + step);
    else if (event.key === 'Home') setEdge(edge, timelineStart);
    else if (event.key === 'End') setEdge(edge, timelineEnd);
    else return;
    event.preventDefault();
  }

  function showEditor(): void {
    ctx.dom.mediaTimingReviewCancelStep.classList.add('hidden');
    ctx.dom.mediaTimingReviewEditor.classList.remove('hidden');
    ctx.dom.mediaTimingReviewCancel.classList.remove('hidden');
  }

  function requestCancel(): void {
    if (!ctx.state.mediaTimingReviewModalOpen || !payload || resolveInFlight) return;
    stopPreview();
    ctx.dom.mediaTimingReviewEditor.classList.add('hidden');
    ctx.dom.mediaTimingReviewCancel.classList.add('hidden');
    ctx.dom.mediaTimingReviewCancelStep.classList.remove('hidden');
    ctx.dom.mediaTimingReviewCancelMessage.textContent =
      payload.noteId !== undefined
        ? 'Keep editing, keep this card without media, use its original timing, or delete it.'
        : 'Keep editing, create this card without media, use its original timing, or do not create it.';
    ctx.dom.mediaTimingReviewSkipMedia.textContent =
      payload.noteId !== undefined ? 'Keep without media' : 'Create without media';
    ctx.dom.mediaTimingReviewDiscard.textContent =
      payload.noteId !== undefined ? 'Delete card' : "Don't create card";
    ctx.dom.mediaTimingReviewCancelBack.focus();
  }

  function closeResolvedReview(): void {
    if (!ctx.state.mediaTimingReviewModalOpen) return;
    cancelDrag();
    clearPreviewTimer();
    if (waveformTimer !== null) clearTimeout(waveformTimer);
    waveformTimer = null;
    waveformSequence += 1;
    previewPlaying = false;
    ctx.state.mediaTimingReviewModalOpen = false;
    ctx.dom.mediaTimingReviewModal.classList.add('hidden');
    ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'true');
    focus.detach();
    window.electronAPI.notifyOverlayModalClosed('media-timing-review');
    options.syncSettingsModalSubtitleSuppression();
    payload = null;
    if (!options.modalStateReader.isAnyModalOpen()) {
      ctx.dom.overlay.classList.remove('interactive');
      if (ctx.platform.shouldToggleMouseIgnore) {
        window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
      }
    }
  }

  async function resolveReview(decision: MediaTimingReviewDecision): Promise<void> {
    if (!payload || resolveInFlight) return;
    resolveInFlight = true;
    stopPreview();
    const controls = ctx.dom.mediaTimingReviewModal.querySelectorAll<HTMLButtonElement>('button');
    controls.forEach((button) => {
      button.disabled = true;
    });
    try {
      const result = await window.electronAPI.resolveMediaTimingReview({
        reviewId: payload.reviewId,
        decision,
      });
      if (!result.ok && !result.stale) {
        setStatus(result.message ?? 'The timing review could not be resolved.', true);
        showEditor();
        return;
      }
      // A stale review was already settled by main; keeping the modal up would leave
      // controls that can never succeed over a live mpv window.
      closeResolvedReview();
    } catch (error) {
      setStatus(error instanceof Error ? error.message : String(error), true);
      showEditor();
    } finally {
      resolveInFlight = false;
      controls.forEach((button) => {
        button.disabled = false;
      });
      renderSelection();
      renderSentence();
    }
  }

  function confirmSelection(): void {
    if (!payload) return;
    const includesAdjacentLines = previousCount > 0 || nextCount > 0;
    void resolveReview({
      action: 'confirm',
      startTime: selectionStart,
      endTime: selectionEnd,
      ...(includesAdjacentLines ? { text: currentLineSelection().sentence } : {}),
    });
  }

  async function togglePreview(): Promise<void> {
    if (!payload || resolveInFlight) return;
    if (previewPlaying) {
      stopPreview();
      return;
    }
    const requestId = previewRequest.begin();
    if (requestId === null) return;
    const requestedReviewId = payload.reviewId;
    setStatus('Starting audio preview...');
    try {
      const result = await window.electronAPI.previewMediaTimingReview({
        reviewId: requestedReviewId,
        startTime: selectionStart,
        endTime: selectionEnd,
      });
      if (!previewRequest.isCurrent(requestId) || payload?.reviewId !== requestedReviewId) {
        if (result.ok && !previewRequest.isInFlight() && !previewPlaying) {
          void window.electronAPI.stopMediaTimingReviewPreview(requestedReviewId).catch(() => {});
        }
        return;
      }
      if (!result.ok && result.stale) {
        closeResolvedReview();
        return;
      }
      if (!result.ok) {
        setStatus(result.message ?? 'Audio preview is unavailable.', true);
        setPreviewPlaying(false);
        return;
      }
      setStatus('Previewing in the hidden audio player.');
      setPreviewPlaying(true);
    } catch (error) {
      if (!previewRequest.isCurrent(requestId) || payload?.reviewId !== requestedReviewId) return;
      setStatus(
        `Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
        true,
      );
      setPreviewPlaying(false);
    } finally {
      previewRequest.finish(requestId);
    }
  }

  function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
    previewRequest.invalidate();
    cancelDrag();
    payload = {
      ...nextPayload,
      previousLines: nextPayload.previousLines ?? [],
      nextLines: nextPayload.nextLines ?? [],
    };
    selectionStart = nextPayload.selectionStartTime;
    selectionEnd = nextPayload.selectionEndTime;
    timelineStart = nextPayload.timelineStartTime;
    timelineEnd = nextPayload.timelineEndTime;
    previousCount = 0;
    nextCount = 0;
    startPadSeconds = Math.max(0, nextPayload.originalStartTime - nextPayload.selectionStartTime);
    endPadSeconds = Math.max(0, nextPayload.selectionEndTime - nextPayload.originalEndTime);
    trailingTrimPending = true;
    resolveInFlight = false;
    setPreviewPlaying(false);
    ctx.dom.mediaTimingReviewKind.textContent =
      nextPayload.kind === 'word'
        ? 'Word card'
        : nextPayload.kind === 'audio'
          ? 'Audio card'
          : 'Sentence card';
    ctx.dom.mediaTimingReviewKind.dataset.kind = nextPayload.kind;
    ctx.dom.mediaTimingReviewDiscard.textContent =
      nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
    setStatus('');
    showEditor();
    renderSelection();
    renderSentence();
    ctx.state.mediaTimingReviewModalOpen = true;
    options.syncSettingsModalSubtitleSuppression();
    ctx.dom.overlay.classList.add('interactive');
    if (ctx.platform.shouldToggleMouseIgnore) window.electronAPI.setIgnoreMouseEvents(false);
    ctx.dom.mediaTimingReviewModal.classList.remove('hidden');
    ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'false');
    window.electronAPI.notifyOverlayModalOpened('media-timing-review');
    focus.attach();
    focus.requestOverlayFocus();
    window.focus();
    focus.enforceModalFocus();
    queueWaveformLoad();
  }

  function expandTimeline(direction: 'earlier' | 'later'): void {
    if (!payload) return;
    if (direction === 'earlier') {
      timelineStart = Math.max(0, timelineStart - TIMELINE_EXPANSION_SECONDS);
    } else {
      timelineEnd = Math.min(
        payload.mediaDuration ?? Number.POSITIVE_INFINITY,
        timelineEnd + TIMELINE_EXPANSION_SECONDS,
      );
    }
    renderSelection();
    queueWaveformLoad(120);
  }

  function handleMediaTimingReviewKeydown(event: KeyboardEvent): boolean {
    if (!ctx.state.mediaTimingReviewModalOpen) return false;
    if (event.key === 'Escape') {
      event.preventDefault();
      if (!ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden')) showEditor();
      else requestCancel();
      return true;
    }
    if (
      event.code === 'Space' &&
      event.target instanceof Element &&
      !event.target.closest('button')
    ) {
      event.preventDefault();
      void togglePreview();
      return true;
    }
    if (
      event.key === 'Enter' &&
      ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') &&
      !(event.target instanceof Element && event.target.closest('button'))
    ) {
      event.preventDefault();
      confirmSelection();
      return true;
    }
    if (
      (event.key === 'p' || event.key === 'P' || event.key === 'n' || event.key === 'N') &&
      ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') &&
      !event.ctrlKey &&
      !event.metaKey &&
      !event.altKey
    ) {
      event.preventDefault();
      adjustLines(
        event.key === 'p' || event.key === 'P' ? 'previous' : 'next',
        event.shiftKey ? -1 : 1,
      );
      return true;
    }
    return false;
  }

  function wireDomEvents(): void {
    const track = ctx.dom.mediaTimingReviewSelectionTrack;
    track.addEventListener('pointerdown', beginDrag);
    track.addEventListener('pointermove', (event) => {
      if (drag?.pointerId === event.pointerId) applyDrag(event.clientX);
    });
    track.addEventListener('pointerup', endDrag);
    track.addEventListener('pointercancel', endDrag);
    ctx.dom.mediaTimingReviewStartHandle.addEventListener('keydown', (event) =>
      handleEdgeKeydown(event, 'start'),
    );
    ctx.dom.mediaTimingReviewEndHandle.addEventListener('keydown', (event) =>
      handleEdgeKeydown(event, 'end'),
    );
    ctx.dom.mediaTimingReviewShowEarlier.addEventListener('click', () => expandTimeline('earlier'));
    ctx.dom.mediaTimingReviewShowLater.addEventListener('click', () => expandTimeline('later'));
    ctx.dom.mediaTimingReviewStartBack.addEventListener('click', () =>
      updateSelection(selectionStart - FINE_ADJUST_SECONDS, selectionEnd),
    );
    ctx.dom.mediaTimingReviewStartForward.addEventListener('click', () =>
      updateSelection(selectionStart + FINE_ADJUST_SECONDS, selectionEnd),
    );
    ctx.dom.mediaTimingReviewEndBack.addEventListener('click', () =>
      updateSelection(selectionStart, selectionEnd - FINE_ADJUST_SECONDS),
    );
    ctx.dom.mediaTimingReviewEndForward.addEventListener('click', () =>
      updateSelection(selectionStart, selectionEnd + FINE_ADJUST_SECONDS),
    );
    ctx.dom.mediaTimingReviewPlay.addEventListener('click', () => void togglePreview());
    ctx.dom.mediaTimingReviewReset.addEventListener('click', () => {
      if (!payload) return;
      previousCount = 0;
      nextCount = 0;
      updateSelection(payload.selectionStartTime, payload.selectionEndTime);
      renderSentence();
    });
    ctx.dom.mediaTimingReviewPrevAdd.addEventListener('click', () => adjustLines('previous', 1));
    ctx.dom.mediaTimingReviewPrevRemove.addEventListener('click', () =>
      adjustLines('previous', -1),
    );
    ctx.dom.mediaTimingReviewNextAdd.addEventListener('click', () => adjustLines('next', 1));
    ctx.dom.mediaTimingReviewNextRemove.addEventListener('click', () => adjustLines('next', -1));
    ctx.dom.mediaTimingReviewCancel.addEventListener('click', requestCancel);
    ctx.dom.mediaTimingReviewCancelBack.addEventListener('click', showEditor);
    ctx.dom.mediaTimingReviewUseOriginal.addEventListener(
      'click',
      () => void resolveReview({ action: 'use-original' }),
    );
    ctx.dom.mediaTimingReviewSkipMedia.addEventListener(
      'click',
      () => void resolveReview({ action: 'skip-media' }),
    );
    ctx.dom.mediaTimingReviewDiscard.addEventListener(
      'click',
      () => void resolveReview({ action: 'discard' }),
    );
    ctx.dom.mediaTimingReviewConfirm.addEventListener('click', () => confirmSelection());
  }

  return {
    openMediaTimingReviewModal,
    handlePreviewEnded,
    requestCancel,
    handleMediaTimingReviewKeydown,
    wireDomEvents,
  };
}
