utilities_tools.js

/**
* @module tools
*/
import { GAP_THRESHOLD } from './constants.js';

/**
* Draws a rounded rectangle path on a 2D canvas context with independent
* corner radii. Each radius is clamped to half the shorter dimension to
* prevent over-rounding artefacts.
* @param {CanvasRenderingContext2D} ctx - canvas rendering context to draw on
* @param {number} x - x coordinate of the rectangle's top-left corner
* @param {number} y - y coordinate of the rectangle's top-left corner
* @param {number} w - width
* @param {number} h - height
* @param {number[]} radii - Corner radii [top-left, top-right, bottom-right, bottom-left]
*/
export function roundRectCorners(ctx, x, y, w, h, radii) {
    w = Math.max(0, w);
    h = Math.max(0, h);
    const [tl, tr, br, bl] = radii.map(r => Math.min(r, w / 2, h / 2));
    ctx.beginPath();
    ctx.moveTo(x + tl, y);
    ctx.lineTo(x + w - tr, y);
    ctx.arcTo(x + w, y,     x + w, y + tr, tr);
    ctx.lineTo(x + w, y + h - br);
    ctx.arcTo(x + w, y + h, x + w - br, y + h, br);
    ctx.lineTo(x + bl, y + h);
    ctx.arcTo(x,     y + h, x, y + h - bl, bl);
    ctx.lineTo(x, y + tl);
    ctx.arcTo(x,     y,     x + tl, y, tl);
    ctx.closePath();
}


/**
* Generates a locally-unique project id.
* Format: "local_" + base-36 timestamp + 4 random base-36 chars.
* Collision probability is negligible for typical usage.
* @returns {string}
*/
export function generateId() {
    return 'local_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}

/**
* Formats a time in seconds as "H:MM:SS" (always includes hours).
* @param {number} s - seconds
* @returns {string} e.g. "0:01:05"
*/
export function formatTime(s) {
    const h = Math.floor(s / 3600);
    const m = Math.floor((s % 3600) / 60);
    const sec = Math.floor(s % 60);
    return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}


/**
* Formats a time in seconds as "H:MM:SS.S" (always includes hours, one decimal second).
* Used for timecode display and split popup labels.
* @param {number} s - seconds
* @returns {string} e.g. "0:01:05.3"
*/
export function formatTimeMs(s) {
    const h = Math.floor(s / 3600);
    const m = Math.floor((s % 3600) / 60);
    const sec = (s % 60).toFixed(1);
    return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(4, '0')}`;
}


/**
* Parses a 6-digit hex color string into separate R, G, B integer components.
* @param {string} hex - e.g. '#47c8ff' or '47c8ff'
* @returns {{r: number, g: number, b: number}}
*/
export function hexToRgb(hex) {
    const m = hex.replace('#','');
    const n = parseInt(m, 16);
    return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}


/**
* Converts HSL color values to a 6-digit hex string.
* Uses the standard HSL → RGB algorithm.
* @param {number} h - hue in degrees 0–360
* @param {number} s - saturation 0–100
* @param {number} l - lightness 0–100
* @returns {string} e.g. '#47c8ff'
*/
export function hslToHex(h, s, l) {
    s /= 100; l /= 100;
    const k = n => (n + h / 30) % 12;
    const a = s * Math.min(l, 1 - l);
    const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
    const toHex = x => Math.round(x * 255).toString(16).padStart(2, '0');
    return '#' + toHex(f(0)) + toHex(f(8)) + toHex(f(4));
}

/**
* Extracts the hue angle (0–360) from a hex color string.
* Used to position the cursor in the hue picker at the color's current hue.
* @param {string} hex - e.g. '#47c8ff' or '47c8ff'
* @returns {number} hue in degrees
*/
export function hexToHue(hex) {
    const { r, g, b } = hexToRgb(hex);
    const rn = r / 255, gn = g / 255, bn = b / 255;
    const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
    if (max === min) return 0; // achromatic — any hue works, return 0
    const d = max - min;
    // Standard hue formula split by which channel is dominant
    let h = max === rn ? (gn - bn) / d + (gn < bn ? 6 : 0)
           : max === gn ? (bn - rn) / d + 2
           :              (rn - gn) / d + 4;
    return (h * 60) % 360;
}

/**
* Converts a CSS color string (hex or rgb()) to HSV components.
* @param {string} color - A hex color ('#rgb', '#rrggbb') or 'rgb(r, g, b)' string
* @returns {{h: number, s: number, v: number}} Hue 0–360, saturation 0–1, value 0–1
*/
export function colorToHSV(color) {
  let r, g, b;

  color = color.trim();

  if (color.startsWith('rgb')) {
    [r, g, b] = color.match(/[\d.]+/g).map(Number);
  } else {
    let hex = color.replace(/^#/, '');
    if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(hex)) {
      throw new Error(`Unsupported color format: ${color}`);
    }
    if (hex.length === 3) {
      hex = hex.split('').map(c => c + c).join('');
    }
    r = parseInt(hex.slice(0, 2), 16);
    g = parseInt(hex.slice(2, 4), 16);
    b = parseInt(hex.slice(4, 6), 16);
  }

  // Normalize RGB to 0–1
  const rn = r / 255, gn = g / 255, bn = b / 255;

  const max = Math.max(rn, gn, bn);
  const min = Math.min(rn, gn, bn);
  const delta = max - min;

  // Value
  const v = max;

  // Saturation
  const s = max === 0 ? 0 : delta / max;

  // Hue
  let h = 0;
  if (delta !== 0) {
    if (max === rn)      h = ((gn - bn) / delta) % 6;
    else if (max === gn) h = (bn - rn) / delta + 2;
    else                 h = (rn - gn) / delta + 4;

    h = h * 60;
    if (h < 0) h += 360;
  }

  return { h, s, v };
}

/**
* Flattens a transcript JSON object (paragraphs > sentences > words) into a flat
* segments array compatible with the Transcript class. Each segment gains a
* `words` array for word-level playback highlighting.
* @param {object} json - transcript JSON with a `paragraphs` array
* @returns {Array<{start: number, end: number, speaker: string, text: string, words: object[]}>}
*/
export function parseTranscriptJSON(json) {
    const segments = [];
    for (const p of json.paragraphs) {
        if (p.sentences) {
            // New format: paragraph > sentences > words
            for (let i = 0; i < p.sentences.length; i++) {
                const s = p.sentences[i];
                const seg = {
                    start:   s.start,
                    end:     s.end,
                    speaker: p.speaker,
                    text:    s.text,
                    words:   s.words ?? [],
                };
                if (s.wordsStale) seg.wordsStale = true;
                if (i === 0) {
                    // First sentence: preserve same-speaker paragraph boundary
                    if (segments.length > 0 && segments[segments.length - 1].speaker === p.speaker) {
                        seg.manualParaBreak = true;
                    }
                } else {
                    // Subsequent sentences: prevent gap-based re-splitting within the paragraph
                    if (s.start - p.sentences[i - 1].end > GAP_THRESHOLD) {
                        seg.manualParaBreak = false;
                    }
                }
                segments.push(seg);
            }
        } else {
            // Old format: paragraph is a single flat text block (no sentences)
            const seg = {
                start:   p.start,
                end:     p.end,
                speaker: p.speaker,
                text:    p.text ?? '',
                words:   [],
            };
            if (segments.length > 0 && segments[segments.length - 1].speaker === p.speaker) {
                seg.manualParaBreak = true;
            }
            segments.push(seg);
        }
    }
    return segments;
}

/**
* Merges a flat array of Whisper segments (arbitrary audio chunks) into
* sentence-like units. Flushes the accumulator on:
*   - speaker change
*   - gap ≥ GAP_THRESHOLD between segments
*   - accumulated text ending with sentence-final punctuation (.?!)
* Used as a transitional shim when loading legacy CSV transcripts.
* @param {object[]} segments - flat segments from parseCSV
* @returns {object[]} merged sentence-like segments
*/
export function mergeSegmentsToSentences(segments) {
    if (!segments.length) return [];
    const merged = [];
    let acc = { ...segments[0] };

    for (let i = 1; i < segments.length; i++) {
        const seg = segments[i];
        const gap = seg.start - acc.end;
        const speakerChanged = seg.speaker !== acc.speaker;
        const sentenceEnd = /[.?!]$/.test(acc.text.trimEnd());

        if (speakerChanged || gap >= GAP_THRESHOLD || sentenceEnd) {
            merged.push(acc);
            acc = { ...seg };
        } else {
            acc = { ...acc, end: seg.end, text: acc.text.trimEnd() + ' ' + seg.text.trimStart() };
        }
    }
    merged.push(acc);
    return merged;
}

/**
* Parses a CSV string in the format: start,end,speaker,text
* Implements RFC 4180 field quoting: quoted fields may contain commas,
* newlines, and escaped double quotes ("" → ").
* The header row is skipped; rows with fewer than 4 columns are ignored.
* @param {string} text - raw CSV string
* @returns {Array<{start: number, end: number, speaker: string, text: string}>}
*/
export function parseCSV(text) {
    const rows = [];
    let i = 0;
    while (i < text.length && text[i] !== '\n') i++;
    i++;
    while (i < text.length) {
      const row = [];
      while (i < text.length && text[i] !== '\n') {
        if (text[i] === '"') {
          i++;
          let val = '';
          while (i < text.length) {
            if (text[i] === '"' && text[i+1] === '"') { val += '"'; i += 2; }
            else if (text[i] === '"') { i++; break; }
            else { val += text[i++]; }
          }
          row.push(val);
          if (text[i] === ',') i++;
        } else {
          let val = '';
          while (i < text.length && text[i] !== ',' && text[i] !== '\n') val += text[i++];
          row.push(val.trim());
          if (text[i] === ',') i++;
        }
      }
      if (text[i] === '\n') i++;
      if (row.length >= 4) {
        rows.push({ start: parseFloat(row[0]), end: parseFloat(row[1]), speaker: row[2].trim(), text: row[3].trim() });
      }
    }
    return rows;
}

/**
 * Finds the color farthest from all others, considering only hue.
 *
 * @param {string[]} colors - Array of CSS color strings (hex, rgb, hsl, named)
 * @returns {string} - The color that is farthest from all others
 */
export function farthestColor(colors) {
    const hueAngles = colors.map((color) => {
        return colorToHSV(color).h;
    });
    const farthest = farthestAngle(hueAngles);
    return hslToHex(farthest, 100, 50)
}

/**
* Finds the angle (in degrees) that is farthest from all given angles on a
* circular 0–360 scale. Works by locating the largest arc gap between the
* sorted input angles and returning its midpoint.
* - Zero angles: returns a random angle.
* - One angle: returns the opposite (180° away).
* - Multiple angles: returns the midpoint of the largest gap.
* @param {number[]} angles - Array of angles in degrees (0–360)
* @returns {number} The angle farthest from all inputs, in degrees (0–360)
*/
export function farthestAngle(angles = []) {
    // No numbers: return a random angle
    if (angles.length === 0) {
        return Math.random() * 360;
    }

    // One number: return the circular opposite (180 degrees away)
    if (angles.length === 1) {
        return (angles[0] + 180) % 360;
    }

    // Multiple numbers: find the angle farthest from all others
    // Strategy: find the largest gap in the sorted angles, and return its midpoint
    const sorted = [...angles].sort((a, b) => a - b);

    let largestGap = -1;
    let gapMidpoint = 0;

    for (let i = 0; i < sorted.length; i++) {
        const current = sorted[i];
        const next = sorted[(i + 1) % sorted.length];

        // For the wraparound gap, next will be < current, so we add 360
        const gap = (next - current + 360) % 360;

        if (gap > largestGap) {
            largestGap = gap;
            // Midpoint of this gap, wrapped to [0, 360)
            gapMidpoint = (current + gap / 2) % 360;
        }
    }

    return gapMidpoint;
}