/**
 * Filler-word detection on word-timestamped transcripts.
 *
 * The single biggest creator-time-saver in long-form editing: scan the
 * transcript word-by-word, mark every "um" / "uh" / "like" / "you know"
 * range, then EMIT KEEP RANGES (the inverse). That keep list goes
 * straight into write_edl → import_edl → done.
 *
 * What counts as a "filler" is configurable. The defaults reflect the
 * canonical short list every YouTube auto-cutter (FireCut, Wisecut,
 * Descript) ships with. We also support multi-word phrases ("you know",
 * "i mean", "kind of") because the most distracting fillers are
 * two-word verbal tics.
 *
 * Pure logic only — no I/O. The tool wrapper handles transcript loading
 * and EDL emission.
 */

import type { Transcript } from "./whisper.js";

/** A contiguous range of words to remove. */
export interface FillerRange {
  startSec: number;
  endSec: number;
  /** The actual filler text (lowercase). Useful for surfacing to the user. */
  text: string;
  /** Word indices within the source segment (start inclusive, end exclusive). */
  startWordIndex: number;
  endWordIndex: number;
}

/** A range we keep (between or around fillers). */
export interface KeepRange {
  startSec: number;
  endSec: number;
}

/**
 * Default filler vocabulary. Single words and common multi-word verbal
 * tics. The matcher normalizes punctuation and case, so we list bare
 * surface forms.
 */
export const DEFAULT_FILLERS = [
  "um",
  "uh",
  "uhm",
  "umm",
  "uhh",
  "er",
  "ah",
  "hmm",
  "you know",
  "i mean",
  "kind of",
  "sort of",
  "like",
  "actually",
  "basically",
  "literally",
  "honestly",
  "right",
  "so",
] as const;

export interface DetectFillersOptions {
  /** Filler vocabulary; defaults to DEFAULT_FILLERS. Case-insensitive. */
  fillers?: readonly string[];
  /**
   * Trim the filler-cut start by this many ms so the trailing audio of
   * the PREVIOUS word isn't clipped. Positive = keep more of the previous
   * word (cut starts later than whisper's word boundary). Default 0 ms.
   *
   * Note: this used to default to 20 ms with the OPPOSITE sign — the cut
   * would EXPAND outward by 20 ms on each side, eating ~5 s of speech
   * across a typical podcast cut. Reverted to a 0 default in commit
   * `keep more of the speaker's actual audio` (May 2026); set to a small
   * positive value (e.g. 15 ms) only if whisper is over-reporting word
   * end times in your specific dataset.
   */
  paddingStartMs?: number;
  /**
   * Trim the filler-cut end by this many ms so the leading audio of the
   * NEXT word isn't clipped. Positive = keep more of the next word (cut
   * ends earlier than whisper's word boundary). Default 0 ms. See note on
   * paddingStartMs above for the bug-fix history.
   */
  paddingEndMs?: number;
  /**
   * Minimum gap between adjacent fillers below which we MERGE them into
   * a single cut (avoids producing 3-frame clips). Default 150ms.
   */
  mergeGapMs?: number;
  /**
   * Aggressive single words like "like" / "so" / "actually" / "right" /
   * "honestly" / "literally" / "basically" are filler ONLY when they
   * appear as standalone disfluencies. When `aggressiveSingleWords` is
   * true, these are included; when false (the default), only the safe-list
   * (um/uh/er/hmm/you know/i mean/kind of/sort of) is applied.
   *
   * Default is **false** — the conservative path — because the tool
   * permanently removes audio and words like "like"/"so" are frequently
   * substantive. Pass `true` for ruthless cuts on monologue-style content
   * where the speaker's verbal tics are clearly fillers.
   *
   * Changed from `true` default (May 2026): the old default caused
   * unintended removal of substantive speech for callers who didn't
   * explicitly configure this option.
   */
  aggressiveSingleWords?: boolean;
}

const NON_AGGRESSIVE_SAFE_LIST = new Set([
  "um",
  "uh",
  "uhm",
  "umm",
  "uhh",
  "er",
  "ah",
  "hmm",
  "you know",
  "i mean",
  "kind of",
  "sort of",
]);

/**
 * Find filler-word ranges across the whole transcript. Requires
 * word-level timestamps (transcribe with wordTimestamps=true).
 *
 * Returns ranges in temporal order with no overlaps; adjacent ranges
 * within `mergeGapMs` are merged into a single cut.
 */
export function detectFillerRanges(
  transcript: Transcript,
  opts: DetectFillersOptions = {},
): FillerRange[] {
  // Default 0 ms — trust whisper's word timings. Previously defaulted to
  // 20 ms with the wrong sign, producing 5+ s of unintended speech loss
  // across a 73-cut podcast. See DetectFillersOptions docs.
  const padStart = (opts.paddingStartMs ?? 0) / 1000;
  const padEnd = (opts.paddingEndMs ?? 0) / 1000;
  const mergeGap = (opts.mergeGapMs ?? 150) / 1000;
  const aggressive = opts.aggressiveSingleWords ?? false;

  const vocab = (opts.fillers ?? DEFAULT_FILLERS)
    .map((f) => f.trim().toLowerCase())
    .filter((f) => f.length > 0)
    .filter((f) => aggressive || NON_AGGRESSIVE_SAFE_LIST.has(f));

  // Sort so multi-word phrases match before single-word ones at the same
  // position. Otherwise "you" + "know" gets matched as two single words.
  const sortedVocab = [...vocab].sort((a, b) => b.split(/\s+/).length - a.split(/\s+/).length);

  const out: FillerRange[] = [];

  for (const segment of transcript.segments) {
    const words = segment.words;
    if (!words || words.length === 0) continue;
    const normalized = words.map((w) => normalizeWord(w.text));

    let i = 0;
    while (i < words.length) {
      const match = findVocabMatch(sortedVocab, normalized, i);
      if (!match) {
        i++;
        continue;
      }
      const startWord = words[i];
      const endWord = words[i + match.length - 1];
      // Padding now SHRINKS the filler range — positive padStart means
      // the cut starts LATER (preserves more of the previous word's tail);
      // positive padEnd means the cut ends EARLIER (preserves more of the
      // next word's head). Old code used `- padStart` / `+ padEnd` which
      // EXPANDED the cut outward, eating ~40 ms per filler.
      const rawStart = startWord.start + padStart;
      const rawEnd = endWord.end - padEnd;
      // Clamp: padding can't make the filler range negative-duration; if
      // it would, keep the original word boundaries unpadded.
      const startSec = rawEnd > rawStart ? Math.max(0, rawStart) : Math.max(0, startWord.start);
      const endSec = rawEnd > rawStart ? rawEnd : endWord.end;
      out.push({
        startSec,
        endSec,
        text: match.phrase,
        startWordIndex: i,
        endWordIndex: i + match.length,
      });
      i += match.length;
    }
  }

  return mergeOverlappingFillers(out, mergeGap);
}

/**
 * Compute KEEP ranges between fillers. The returned list is what you
 * pass into write_edl as a sequence of source-in / source-out pairs.
 *
 * Adjacent keep ranges with a tiny gap (< minKeepDurSec) get dropped —
 * a 40ms keep clip between two filler cuts is just noise.
 */
export function keepRangesFromFillers(
  fillers: FillerRange[],
  totalSec: number,
  minKeepDurSec = 0.05,
): KeepRange[] {
  if (totalSec <= 0) return [];
  const sorted = [...fillers].sort((a, b) => a.startSec - b.startSec);
  const keeps: KeepRange[] = [];
  let cursor = 0;

  for (const f of sorted) {
    if (f.startSec > cursor) {
      const start = cursor;
      const end = Math.min(totalSec, f.startSec);
      if (end - start >= minKeepDurSec) {
        keeps.push({ startSec: start, endSec: end });
      }
    }
    cursor = Math.max(cursor, Math.min(totalSec, f.endSec));
  }
  if (cursor < totalSec) {
    const dur = totalSec - cursor;
    if (dur >= minKeepDurSec) {
      keeps.push({ startSec: cursor, endSec: totalSec });
    }
  }
  return keeps;
}

/**
 * From a sorted keep-range list, compute the TIMELINE-space cut points.
 *
 * After `import_edl` lands the keep ranges concatenated onto the timeline,
 * each junction between adjacent keeps becomes a visible cut at the
 * timeline-relative timestamp = sum of all earlier keep durations.
 *
 * Example: keeps = [[0,10], [12,25], [28,40]]
 *   timeline = [0..10] + [10..23] + [23..35]
 *   cuts at  =        10s,         23s   (two junctions)
 *
 * Why this matters: tools like `add_sfx_to_timeline` and `add_sfx_at_cuts`
 * place SFX at TIMELINE timestamps. If callers pass SOURCE timestamps from
 * the transcript, the drift compounds with every cut and the SFX lands
 * progressively further out of sync (fine for the first ~30s, off by 20+s
 * by the end of a typical podcast). This helper exists to remove that
 * footgun.
 */
export function keepRangesToTimelineCuts(keeps: KeepRange[]): number[] {
  if (keeps.length <= 1) return [];
  const cuts: number[] = [];
  let acc = 0;
  for (let i = 0; i < keeps.length - 1; i++) {
    acc += keeps[i].endSec - keeps[i].startSec;
    cuts.push(+acc.toFixed(3));
  }
  return cuts;
}

/**
 * Frame-align keep ranges. Rounds OUTWARD (start floors, end ceils) so
 * we never DROP speech that lives in a partial frame at the keep's edge.
 *
 * Was: rounds inward (start ceils, end floors). Inward rounding never
 * extends INTO an adjacent filler, but it ALSO loses up to one frame at
 * each end of every keep — ~33 ms at 30 fps, ~17 ms at 60 fps. Across
 * a 73-cut podcast that's 5+ seconds of speech disappearing into
 * sub-frame rounding error.
 *
 * Trade-off: outward rounding can include up to one frame of an adjacent
 * filler at each junction. That's ~33 ms at 30 fps — well within whisper
 * timing noise (typically ±50 ms) and inaudible compared to losing 5 s
 * of actual speech. Worth it.
 */
export function keepRangesToFrameRanges(
  keeps: KeepRange[],
  fps: number,
): Array<{ startFrame: number; endFrame: number }> {
  const out: Array<{ startFrame: number; endFrame: number }> = [];
  for (const k of keeps) {
    const sf = Math.floor(k.startSec * fps);
    const ef = Math.ceil(k.endSec * fps);
    if (ef > sf) out.push({ startFrame: sf, endFrame: ef });
  }
  return out;
}

// ── Internal ─────────────────────────────────────────────────

function normalizeWord(text: string): string {
  // Lowercase and strip non-letter trailing/leading punctuation.
  // We keep apostrophes inside words ("don't", "i'm").
  return text
    .toLowerCase()
    .trim()
    .replace(/^[^a-z']+/u, "")
    .replace(/[^a-z']+$/u, "");
}

interface VocabMatch {
  phrase: string;
  length: number;
}

function findVocabMatch(
  sortedVocab: string[],
  normalized: string[],
  startIndex: number,
): VocabMatch | undefined {
  for (const phrase of sortedVocab) {
    const parts = phrase.split(/\s+/);
    if (startIndex + parts.length > normalized.length) continue;
    let ok = true;
    for (let j = 0; j < parts.length; j++) {
      if (normalized[startIndex + j] !== parts[j]) {
        ok = false;
        break;
      }
    }
    if (ok) return { phrase, length: parts.length };
  }
  return undefined;
}

function mergeOverlappingFillers(ranges: FillerRange[], mergeGap: number): FillerRange[] {
  if (ranges.length <= 1) return ranges;
  const sorted = [...ranges].sort((a, b) => a.startSec - b.startSec);
  const out: FillerRange[] = [sorted[0]];
  for (let i = 1; i < sorted.length; i++) {
    const prev = out[out.length - 1];
    const cur = sorted[i];
    if (cur.startSec - prev.endSec <= mergeGap) {
      out[out.length - 1] = {
        startSec: prev.startSec,
        endSec: Math.max(prev.endSec, cur.endSec),
        text: `${prev.text} + ${cur.text}`,
        startWordIndex: prev.startWordIndex,
        endWordIndex: cur.endWordIndex,
      };
    } else {
      out.push(cur);
    }
  }
  return out;
}

/**
 * Quick stats for the agent to surface to the user. "I removed 47
 * filler words totalling 8.3 seconds" is a much better summary than a
 * raw range list.
 */
export interface FillerStats {
  count: number;
  totalRemovedSec: number;
  /** Top-5 most frequent fillers, descending. */
  topFillers: Array<{ text: string; count: number }>;
}

export function summarizeFillers(fillers: FillerRange[]): FillerStats {
  const counts = new Map<string, number>();
  let totalRemovedSec = 0;
  for (const f of fillers) {
    totalRemovedSec += f.endSec - f.startSec;
    // For merged ranges the .text is "a + b" — we count each token.
    for (const piece of f.text.split(/\s*\+\s*/)) {
      counts.set(piece, (counts.get(piece) ?? 0) + 1);
    }
  }
  const topFillers = [...counts.entries()]
    .map(([text, count]) => ({ text, count }))
    .sort((a, b) => b.count - a.count)
    .slice(0, 5);
  return {
    count: fillers.length,
    totalRemovedSec: +totalRemovedSec.toFixed(3),
    topFillers,
  };
}
