/**
 * Syncitol — dsp.js
 * Pure, host-independent core: audio-envelope DSP, waveform cross-correlation,
 * fine-tune anchor/window planning, coarse search policy (rail guard, predictor
 * tiers, learned offsets, strong-override / near-fallback), .pek peak-file
 * parsing, drift probing, and display formatting.
 *
 * Input shapes: buildEnvelope takes an Int16Array of mono samples (from the
 * native FFmpeg addon); parsePekInfo/pekToEnvelope accept a Node Buffer (unit
 * tests) OR an ArrayBuffer/TypedArray (UXP fs.readFile) via a small
 * byte-reader shim.
 *
 * Loadable as a CommonJS module (UXP `require()` and node:test) and, harmlessly,
 * as a browser global.
 */

(function (root, factory) {
    var api = factory();
    var key;
    if (typeof window !== "undefined") {
        for (key in api) {
            if (Object.prototype.hasOwnProperty.call(api, key)) {
                window[key] = api[key];
            }
        }
    }
    if (typeof module !== "undefined" && module.exports) {
        module.exports = api;
    }
})(typeof window !== "undefined" ? window : this, function () {
    "use strict";

    // ─── Constants ────────────────────────────────────────────────────────────
    var TICKS_PER_SECOND = 254016000000;
    var MAX_SPAN_SEC = 86400;

    var AUDIO_SAMPLE_RATE = 8000;
    var ENVELOPE_WINDOW_SAMPLES = 80;
    var ENVELOPE_RATE = AUDIO_SAMPLE_RATE / ENVELOPE_WINDOW_SAMPLES; // 100 Hz

    var FINE_TUNE_MAX_SHIFT_SEC = 5;
    var FINE_TUNE_MIN_OVERLAP_SEC = 3;
    // Audio window decoded per compare. The coarse pass lands tracks within a few
    // seconds, so the fine pass only refines a small residual — a 10s window keeps
    // ≥5s of overlap even at the ±5s search extreme (window − maxShift) while
    // halving the per-clip decode vs. the old 20s. Don't drop below
    // ~2×MAX_SHIFT or the extreme-lag overlap falls under MIN_OVERLAP.
    var FINE_TUNE_MAX_COMPARE_SEC = 10;
    var FINE_TUNE_MIN_SCORE = 0.2;
    var FINE_TUNE_MIN_APPLY_SEC = 0.02;
    // Window start positions (fraction of slack) tried in order until one scores
    // ≥ DECENT_SCORE. Two positions instead of three trims a third of the work on
    // weak-correlation footage where the short-circuit rarely fires.
    var FINE_TUNE_WINDOW_POSITIONS = [0.5, 0.2];
    var FINE_TUNE_DECENT_SCORE = 0.7;

    // Drift detection. A single offset assumes both devices' clocks run at the
    // same RATE — consumer cameras drift ~10–50 ppm, which on a multi-hour
    // recording puts the tail audibly out even when the head is perfectly
    // aligned. We can't fix that (rate-stretch isn't scriptable cleanly), but we
    // can MEASURE it: correlate a window near each end of a long overlap and
    // compare the residual lags. Only overlaps this long are worth checking:
    var DRIFT_MIN_OVERLAP_SEC = 600;
    // Probe windows sit this fraction in from each end of the overlap.
    var DRIFT_EDGE_FRACTION = 0.05;
    // Report only when the ends diverge by more than this (the 100 Hz envelope
    // resolves ~10 ms per end, so anything under 40 ms is measurement noise).
    var DRIFT_MIN_REPORT_SEC = 0.04;
    // Above this, the two ends disagree by more than any real pair of devices can
    // drift apart (cheap consumer crystals are ±100 ppm each, so ~200 ppm relative
    // is the realistic worst case; 500 ppm is 1.8 s per hour). A "match" implying
    // this much drift is not the same audio at all — it's correlated noise — so it
    // is rejected rather than reported. Deliberately far above real drift: a false
    // rejection re-breaks a good sync, which is worse than an unflagged slow drift.
    var DRIFT_IMPLAUSIBLE_PPM = 500;

    // ─── Envelope extraction ──────────────────────────────────────────────────
    // Aggregate signed 16-bit mono samples into a mean-absolute-amplitude envelope,
    // one frame per `windowSamples` samples. `samples` is an Int16Array (or any
    // indexable of int16 values) decoded by the native addon.
    function buildEnvelope(samples, windowSamples) {
        windowSamples = windowSamples || ENVELOPE_WINDOW_SAMPLES;
        var sampleCount = samples.length;
        var frameCount = Math.floor(sampleCount / windowSamples);
        var envelope = new Float32Array(frameCount);

        for (var frame = 0; frame < frameCount; frame += 1) {
            var sum = 0;
            var base = frame * windowSamples;
            for (var s = 0; s < windowSamples; s += 1) {
                var v = samples[base + s];
                sum += v < 0 ? -v : v;
            }
            envelope[frame] = sum / windowSamples;
        }

        return envelope;
    }

    // ─── Cross-correlation ────────────────────────────────────────────────────
    // Normalized (Pearson) cross-correlation between two envelopes. Returns the
    // best lag (in seconds, positive = target later than reference) or null when
    // no candidate has enough overlap or variance.
    //
    // opts: { maxShiftSec, minOverlapSec, envelopeRate } — defaults to the
    // fine-tune constants. The coarse auto-align pass passes a wider maxShiftSec
    // and a lower-resolution envelopeRate.
    function findBestLag(refEnvelope, targetEnvelope, opts) {
        opts = opts || {};
        var envelopeRate = opts.envelopeRate || ENVELOPE_RATE;
        var maxShiftSec = opts.maxShiftSec || FINE_TUNE_MAX_SHIFT_SEC;
        var minOverlapSec = (opts.minOverlapSec !== undefined && opts.minOverlapSec !== null)
            ? opts.minOverlapSec
            : FINE_TUNE_MIN_OVERLAP_SEC;

        var maxLagFrames = Math.round(maxShiftSec * envelopeRate);
        var minOverlapFrames = Math.round(minOverlapSec * envelopeRate);
        var best = null;

        for (var lag = -maxLagFrames; lag <= maxLagFrames; lag += 1) {
            var refStart = lag < 0 ? -lag : 0;
            var targetStart = lag > 0 ? lag : 0;
            var overlap = Math.min(refEnvelope.length - refStart, targetEnvelope.length - targetStart);
            if (overlap < minOverlapFrames) continue;

            var refSum = 0;
            var targetSum = 0;
            for (var i = 0; i < overlap; i += 1) {
                refSum += refEnvelope[refStart + i];
                targetSum += targetEnvelope[targetStart + i];
            }

            var refMean = refSum / overlap;
            var targetMean = targetSum / overlap;
            var dot = 0;
            var refEnergy = 0;
            var targetEnergy = 0;

            for (var j = 0; j < overlap; j += 1) {
                var rv = refEnvelope[refStart + j] - refMean;
                var tv = targetEnvelope[targetStart + j] - targetMean;
                dot += rv * tv;
                refEnergy += rv * rv;
                targetEnergy += tv * tv;
            }

            if (!refEnergy || !targetEnergy) continue;
            var score = dot / Math.sqrt(refEnergy * targetEnergy);
            if (!best || score > best.score) {
                best = {
                    score: score,
                    lagSec: lag / envelopeRate,
                    overlapSec: overlap / envelopeRate
                };
            }
        }

        // Flag a best lag pinned to the search boundary. A peak at ±maxShift is the
        // classic signature of a spurious match (the true peak is elsewhere or
        // absent), so callers can distrust it rather than apply a boundary guess.
        if (best) {
            var bestFrames = Math.round(best.lagSec * envelopeRate);
            var railTol = Math.max(1, Math.round(maxLagFrames * 0.05));
            best.atRail = Math.abs(bestFrames) >= (maxLagFrames - railTol);
        }

        return best;
    }

    // Slide a SHORT target envelope across a LONG reference envelope to find
    // where it best matches — used by the coarse pass to locate one clip inside
    // a whole reference recording when the offset between devices may be large
    // (minutes), far beyond findBestLag's symmetric ±maxShift window.
    //
    // Returns { score, lagSec, overlapSec } where lagSec is the offset of the
    // target's first frame relative to the reference's first frame (positive =
    // target starts later into the reference). Caller maps that to a timeline
    // shift. opts: { envelopeRate, minOverlapSec, maxLagSec }.
    function slideMatch(refEnvelope, targetEnvelope, opts) {
        opts = opts || {};
        var envelopeRate = opts.envelopeRate || ENVELOPE_RATE;
        var minOverlapSec = (opts.minOverlapSec !== undefined && opts.minOverlapSec !== null)
            ? opts.minOverlapSec
            : FINE_TUNE_MIN_OVERLAP_SEC;

        var refLen = refEnvelope.length;
        var tgtLen = targetEnvelope.length;
        var minOverlap = Math.max(1, Math.round(minOverlapSec * envelopeRate));

        var lagMin = -(tgtLen - minOverlap);
        var lagMax = refLen - minOverlap;
        if (opts.maxLagSec !== undefined && opts.maxLagSec !== null) {
            var maxLagFrames = Math.round(opts.maxLagSec * envelopeRate);
            if (lagMin < -maxLagFrames) lagMin = -maxLagFrames;
            if (lagMax > maxLagFrames) lagMax = maxLagFrames;
        }

        var best = null;
        for (var lag = lagMin; lag <= lagMax; lag += 1) {
            var start = lag > 0 ? lag : 0;             // first ref index in overlap
            var end = Math.min(refLen, lag + tgtLen);  // one past last ref index
            var overlap = end - start;
            if (overlap < minOverlap) continue;
            var tgtBase = start - lag;                 // matching target index at `start`

            var refSum = 0, tgtSum = 0;
            for (var i = 0; i < overlap; i += 1) {
                refSum += refEnvelope[start + i];
                tgtSum += targetEnvelope[tgtBase + i];
            }
            var refMean = refSum / overlap;
            var tgtMean = tgtSum / overlap;

            var dot = 0, refEnergy = 0, tgtEnergy = 0;
            for (var j = 0; j < overlap; j += 1) {
                var rv = refEnvelope[start + j] - refMean;
                var tv = targetEnvelope[tgtBase + j] - tgtMean;
                dot += rv * tv;
                refEnergy += rv * rv;
                tgtEnergy += tv * tv;
            }

            if (!refEnergy || !tgtEnergy) continue;
            var s = dot / Math.sqrt(refEnergy * tgtEnergy);
            if (!best || s > best.score) {
                best = {
                    score: s,
                    lagSec: lag / envelopeRate,
                    overlapSec: overlap / envelopeRate
                };
            }
        }

        return best;
    }

    // ─── Fine-tune anchor planning ────────────────────────────────────────────
    // Collapse timeline clip instances to one anchor per source FILE, then mark
    // which track is the reference. One anchor per file guarantees the coarse and
    // fine passes compute ONE shift per file, which the apply step gives to every
    // timeline instance of that file — a clip's video and its linked audio can
    // never be shifted apart. The reference is the track with the most total
    // recorded coverage (typically the continuous main-camera/program recording
    // or a field-recorder WAV) — everything else is aligned to it. This is chosen
    // by content, NOT by track position, so it works no matter which track the
    // main recording sits on. Anchors get layerOrder 0 (reference) or 1
    // (everything else); the coarse and fine passes align layer-1 clips to the
    // layer-0 reference.
    function buildFineTuneAnchors(clips) {
        var byKey = {};
        var order = [];

        for (var c = 0; c < clips.length; c += 1) {
            var clip = clips[c];
            if (!clip.filePath || !clip.startTicks) continue;
            var key = clip.filePath;

            if (!Object.prototype.hasOwnProperty.call(byKey, key)) {
                byKey[key] = {
                    key: key,
                    filePath: clip.filePath,
                    startTicks: clip.startTicks,
                    clipName: clip.clipName,
                    trackType: clip.trackType,
                    trackIndex: clip.trackIndex,
                    startSec: clip.startSec,
                    endSec: clip.endSec,
                    inPointSec: clip.inPointSec,
                    resolvedStartSec: clip.startSec,
                    resolvedEndSec: clip.endSec
                };
                order.push(key);
                continue;
            }

            // A file usually appears on both a video track and its linked audio
            // track(s); prefer the video instance so the anchor reflects the
            // video track, then the earliest instance of that type.
            var existing = byKey[key];
            var better =
                (clip.trackType === "video" && existing.trackType !== "video") ||
                (clip.trackType === existing.trackType && clip.startSec < existing.startSec);
            if (better) {
                existing.startTicks = clip.startTicks;
                existing.clipName = clip.clipName;
                existing.trackType = clip.trackType;
                existing.trackIndex = clip.trackIndex;
                existing.startSec = clip.startSec;
                existing.endSec = clip.endSec;
                existing.inPointSec = clip.inPointSec;
                existing.resolvedStartSec = clip.startSec;
                existing.resolvedEndSec = clip.endSec;
            }
        }

        var anchors = [];
        for (var k = 0; k < order.length; k += 1) anchors.push(byKey[order[k]]);

        // Reference track = the track (type + index) with the most total coverage.
        var coverage = {};
        for (var a = 0; a < anchors.length; a += 1) {
            var an = anchors[a];
            var tk = an.trackType + "_" + an.trackIndex;
            coverage[tk] = (coverage[tk] || 0) + (an.endSec - an.startSec);
        }
        var refTrackKey = null;
        var refCoverage = -1;
        for (var tkey in coverage) {
            if (Object.prototype.hasOwnProperty.call(coverage, tkey) && coverage[tkey] > refCoverage) {
                refCoverage = coverage[tkey];
                refTrackKey = tkey;
            }
        }

        for (var b = 0; b < anchors.length; b += 1) {
            var isRef = (anchors[b].trackType + "_" + anchors[b].trackIndex) === refTrackKey;
            anchors[b].layerOrder = isRef ? 0 : 1;
            anchors[b].isReference = isRef;
        }

        return anchors.sort(function (a, b) {
            if (a.layerOrder !== b.layerOrder) return a.layerOrder - b.layerOrder;
            if (a.startSec !== b.startSec) return a.startSec - b.startSec;
            return a.clipName.localeCompare(b.clipName);
        });
    }

    // Plan the overlapping comparison windows for a reference/target anchor pair.
    function buildCompareWindow(reference, target) {
        var compareStart = Math.max(reference.resolvedStartSec, target.resolvedStartSec);
        var compareEnd = Math.min(reference.resolvedEndSec, target.resolvedEndSec);
        var overlap = compareEnd - compareStart;
        if (overlap < FINE_TUNE_MIN_OVERLAP_SEC) return null;

        var compareDuration = Math.min(overlap, FINE_TUNE_MAX_COMPARE_SEC);
        var slack = overlap - compareDuration;
        var windows = [];
        var seenStarts = {};

        for (var p = 0; p < FINE_TUNE_WINDOW_POSITIONS.length; p += 1) {
            var position = FINE_TUNE_WINDOW_POSITIONS[p];
            var start = compareStart + (slack * position);
            var roundedStart = Number(start.toFixed(3));
            if (Object.prototype.hasOwnProperty.call(seenStarts, roundedStart)) continue;
            seenStarts[roundedStart] = true;

            windows.push({
                compareStartSec: start,
                compareDurationSec: compareDuration,
                compareEndSec: start + compareDuration,
                refSourceOffsetSec: reference.inPointSec + (start - reference.resolvedStartSec),
                targetSourceOffsetSec: target.inPointSec + (start - target.resolvedStartSec)
            });
        }

        return {
            overlapSec: overlap,
            compareDurationSec: compareDuration,
            windows: windows
        };
    }

    // Plan the two probe windows for a drift check on a reference/target pair:
    // one near the start of their overlap, one near the end. Returns null when
    // the overlap is too short for drift to be measurable (or matter). The
    // caller correlates each window (same shape as buildCompareWindow windows)
    // and compares the two lags: driftSec = lateLag − earlyLag over spanSec.
    function buildDriftProbe(reference, target) {
        var overlapStart = Math.max(reference.resolvedStartSec, target.resolvedStartSec);
        var overlapEnd = Math.min(reference.resolvedEndSec, target.resolvedEndSec);
        var overlap = overlapEnd - overlapStart;
        if (overlap < DRIFT_MIN_OVERLAP_SEC) return null;

        var windowDur = FINE_TUNE_MAX_COMPARE_SEC;
        var earlyStart = overlapStart + (overlap * DRIFT_EDGE_FRACTION);
        var lateStart = overlapStart + (overlap * (1 - DRIFT_EDGE_FRACTION)) - windowDur;
        var spanSec = lateStart - earlyStart;
        if (spanSec <= 0) return null;

        function windowAt(startSec) {
            return {
                compareStartSec: startSec,
                compareDurationSec: windowDur,
                refSourceOffsetSec: reference.inPointSec + (startSec - reference.resolvedStartSec),
                targetSourceOffsetSec: target.inPointSec + (startSec - target.resolvedStartSec)
            };
        }

        return {
            spanSec: spanSec,
            early: windowAt(earlyStart),
            late: windowAt(lateStart)
        };
    }

    // ─── Premiere .pek peak files ─────────────────────────────────────────────
    // Premiere pre-computes an audio peak cache (.pek) for every imported media
    // file — effectively the envelope the coarse pass spends minutes decoding,
    // already on disk. Format (validated against ffmpeg ground truth on real
    // stereo MP4 and 4-channel MXF footage, r ≥ 0.99 at exact offsets):
    //   header, 68 bytes: u32 magic 0x67235411 @0, u32 @4, u32 channelCount @8,
    //     f64le sampleRate @12, ids/hashes, u32 payloadBytes @64
    //   payload, CHANNEL-PLANAR: for each channel in order, `blocks` consecutive
    //     4-byte entries (int16 max, int16 min), each covering
    //     PEK_SAMPLES_PER_BLOCK source samples (187.5 Hz at 48 kHz).
    // The planar layout matters: interleaving reads one channel at double speed
    // and produces a self-consistent but WRONG envelope.
    var PEK_MAGIC = 0x67235411;
    var PEK_HEADER_BYTES = 68;
    var PEK_SAMPLES_PER_BLOCK = 256;

    // Byte-reader shim: Node Buffers pass through untouched; ArrayBuffers and
    // TypedArrays (UXP fs.readFile) get a DataView wrapper with the same reader
    // methods. Returns null for anything else.
    function bytesReader(src) {
        if (!src) return null;
        if (typeof src.readUInt32LE === "function") return src; // Node Buffer
        var view = null;
        if (typeof ArrayBuffer !== "undefined") {
            if (src instanceof ArrayBuffer) view = new DataView(src);
            else if (src.buffer instanceof ArrayBuffer) {
                view = new DataView(src.buffer, src.byteOffset || 0, src.byteLength);
            }
        }
        if (!view) return null;
        return {
            length: view.byteLength,
            readUInt32LE: function (o) { return view.getUint32(o, true); },
            readDoubleLE: function (o) { return view.getFloat64(o, true); },
            readInt16LE: function (o) { return view.getInt16(o, true); }
        };
    }

    // Parse and sanity-check a .pek header. Returns the layout info or null when
    // the buffer is not a plausible peak file (callers then fall back to decode).
    function parsePekInfo(buffer) {
        buffer = bytesReader(buffer);
        if (!buffer || buffer.length < PEK_HEADER_BYTES + 4) return null;
        if (buffer.readUInt32LE(0) !== PEK_MAGIC) return null;
        var channels = buffer.readUInt32LE(8);
        var sampleRate = buffer.readDoubleLE(12);
        var dataBytes = buffer.readUInt32LE(64);
        if (!(channels >= 1 && channels <= 32)) return null;
        if (!(sampleRate >= 8000 && sampleRate <= 384000)) return null;
        if (dataBytes <= 0 || PEK_HEADER_BYTES + dataBytes > buffer.length) return null;
        var blocks = Math.floor(dataBytes / 4 / channels);
        if (blocks < 1) return null;
        var blockRate = sampleRate / PEK_SAMPLES_PER_BLOCK;
        return {
            channels: channels,
            sampleRate: sampleRate,
            blocks: blocks,
            blockRate: blockRate,
            durationSec: blocks / blockRate
        };
    }

    // Build an envelope at `targetRate` Hz for [startSec, startSec + durSec)
    // from a parsed .pek: mean over channels of per-block (max − min) / 2,
    // aggregated per target frame. Pass durSec null/undefined for "to the end".
    // Returns a Float32Array (possibly empty when the slice is out of range).
    // `channel` selects ONE channel plane instead of averaging all of them. A
    // 4-channel camera file carries a different microphone on each channel, and
    // the average buries any single one under the other three — so a lav recorder
    // correlates weakly with the mix but almost perfectly with the channel that
    // recorded the same mic. Pass null/undefined for the averaged mix.
    function pekToEnvelope(buffer, info, targetRate, startSec, durSec, channel) {
        buffer = bytesReader(buffer);
        var single = (typeof channel === "number" && channel >= 0 && channel < info.channels);
        var firstChan = single ? channel : 0;
        var lastChan = single ? channel + 1 : info.channels;
        var chanCount = lastChan - firstChan;
        var startBlock = Math.max(0, Math.floor((startSec || 0) * info.blockRate));
        var endBlock = (durSec === null || durSec === undefined)
            ? info.blocks
            : Math.min(info.blocks, Math.ceil(((startSec || 0) + durSec) * info.blockRate));
        var span = endBlock - startBlock;
        if (span < 1) return new Float32Array(0);

        var frames = Math.floor(span * targetRate / info.blockRate);
        var env = new Float32Array(frames);
        for (var f = 0; f < frames; f += 1) {
            var b0 = startBlock + Math.floor(f * info.blockRate / targetRate);
            var b1 = Math.max(b0 + 1, startBlock + Math.floor((f + 1) * info.blockRate / targetRate));
            if (b1 > endBlock) b1 = endBlock;
            var sum = 0;
            for (var b = b0; b < b1; b += 1) {
                for (var c = firstChan; c < lastChan; c += 1) {
                    var base = PEK_HEADER_BYTES + ((c * info.blocks) + b) * 4;
                    var hi = buffer.readInt16LE(base);
                    var lo = buffer.readInt16LE(base + 2);
                    sum += (hi >= lo ? hi - lo : lo - hi) / 2;
                }
            }
            env[f] = sum / ((b1 - b0) * chanCount);
        }
        return env;
    }

    // ─── HTML escaping ────────────────────────────────────────────────────────
    // Sequence names, clip names and file paths are user-controlled and get
    // interpolated into innerHTML in the panel — escape them so a name like
    // "<b>Day 1" can't break (or script) the UI.
    function escapeHtml(value) {
        return String(value === null || value === undefined ? "" : value)
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#39;");
    }

    // ─── Display formatting ───────────────────────────────────────────────────
    function formatDuration(ms) {
        var totalSec = Math.round(ms / 1000);
        var h = Math.floor(totalSec / 3600);
        var m = Math.floor((totalSec % 3600) / 60);
        var s = totalSec % 60;
        if (h > 0) return h + "h " + m + "m " + s + "s";
        if (m > 0) return m + "m " + s + "s";
        return s + "s";
    }

    function formatTime(ms) {
        var d = new Date(ms);
        return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
    }

    function formatDate(ms) {
        var d = new Date(ms);
        return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
            " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
    }

    function formatSignedSeconds(seconds) {
        var rounded = Math.round(seconds * 1000) / 1000;
        return (rounded >= 0 ? "+" : "") + rounded + "s";
    }

    function formatRange(startSec, durationSec) {
        var endSec = startSec + durationSec;
        return startSec.toFixed(2) + "s-" + endSec.toFixed(2) + "s (" + durationSec.toFixed(2) + "s)";
    }

    function describeAnchor(anchor) {
        return anchor.clipName + " [" + anchor.trackType.toUpperCase() + " " +
            (anchor.trackIndex + 1) + ", t=" + anchor.startSec.toFixed(2) +
            "s, startTicks=" + anchor.startTicks + "]";
    }

    // ─── Timecode ─────────────────────────────────────────────────────────────
    // Parse an SMPTE timecode ("HH:MM:SS:FF", or drop-frame "HH:MM:SS;FF") into
    // seconds from midnight. The frame field is converted with `fps`; drop-frame is
    // treated as non-drop, a sub-second approximation that's fine because the coarse
    // pass only uses this as a search PREDICTION that audio then confirms. Returns
    // null for anything unparseable so callers can fall back to other signals.
    function parseTimecodeToSeconds(tc, fps) {
        if (typeof tc !== "string") return null;
        var m = tc.match(/^(\d{1,2}):([0-5]?\d):([0-5]?\d)[:;](\d{1,3})$/);
        if (!m) return null;
        var rate = (fps && fps > 0) ? fps : 25;
        var frames = parseInt(m[4], 10);
        if (frames >= Math.round(rate) && rate >= 1) frames = Math.round(rate) - 1; // clamp a stray frame index
        return (parseInt(m[1], 10) * 3600) +
            (parseInt(m[2], 10) * 60) +
            parseInt(m[3], 10) +
            (frames / rate);
    }

    // ─── Coarse search planning + selection (pure; host supplies the matcher) ──
    // The whole-track coarse align is just arithmetic over slide-match candidates,
    // but it lives behind the decoder in main.js where it can't be unit-tested.
    // These pure helpers carry all of the bug-prone window math and selection
    // policy so they CAN be tested; main.js only owns the async decode loop.
    //
    // geom: { refInPointSec, refDurationFull, refResolvedStartSec, targetInPointSec,
    //         targetResolvedStartSec, targetAvailSec, tcDelta }
    // cfg:  { minOverlapSec, targetMaxSec, tcConfirmSec, predictMarginSec, headSec,
    //         minScore, strongScore, confirmNearSec, learnedMarginSec }

    // Ordered list of coarse search windows for one reference/target pair, cheapest
    // and most-reliable first. Each plan: { label, winStart, winDur, probeDur,
    // predicts, predictedDeltaSec }. `predicts` plans are centered on a metadata/TC
    // prediction (trusted even when weak); head/full scan blind (acted on only when
    // strong). predictedDeltaSec is the whole-track delta the plan EXPECTS, so a
    // match can be judged "near its prediction" relative to that plan's own claim
    // (a timecode prediction may legitimately disagree with the timestamps).
    function planCoarseSearch(geom, cfg) {
        var refMinSrc = geom.refInPointSec;
        var refMaxSrc = geom.refInPointSec + geom.refDurationFull;
        var probeShort = Math.min(geom.targetAvailSec, cfg.targetMaxSec);

        var plans = [];
        var seen = {};
        function addPlan(label, start, dur, probeDur, predicts, predictedDeltaSec) {
            var s = Math.max(refMinSrc, start);
            var e = Math.min(refMaxSrc, start + dur);
            var winDur = e - s;
            if (winDur < cfg.minOverlapSec) return;
            var key = Math.round(s) + "|" + Math.round(winDur) + "|" + Math.round(probeDur);
            if (seen[key]) return;                   // don't re-decode an identical window/probe
            seen[key] = true;
            plans.push({
                label: label, winStart: s, winDur: winDur, probeDur: probeDur,
                predicts: predicts, predictedDeltaSec: predictedDeltaSec || 0
            });
        }

        if (geom.tcDelta !== null && geom.tcDelta !== undefined) {
            var predRefSrc = geom.targetInPointSec + geom.tcDelta;
            var tcPredictedDelta = geom.refResolvedStartSec +
                (predRefSrc - geom.refInPointSec) - geom.targetResolvedStartSec;
            addPlan("timecode", predRefSrc - cfg.tcConfirmSec, probeShort + (2 * cfg.tcConfirmSec), probeShort, true, tcPredictedDelta);
        }
        var predTs = geom.refInPointSec + (geom.targetResolvedStartSec - geom.refResolvedStartSec);
        addPlan("timestamp", predTs - cfg.predictMarginSec, probeShort + (2 * cfg.predictMarginSec), probeShort, true, 0);
        // Head: first headSec of each file, matched symmetrically (long target probe)
        // so a camera that started before OR after the reference is found.
        addPlan("head", refMinSrc, cfg.headSec, Math.min(geom.targetAvailSec, cfg.headSec), false);
        addPlan("full", refMinSrc, geom.refDurationFull, probeShort, false);
        return plans;
    }

    // ─── Probe window selection ───────────────────────────────────────────────
    // Choose WHERE in a clip to take the coarse probe. Probing the head of the
    // clip — the old behaviour — fails badly on recorders that were started early
    // and left running: the first minutes are room tone, and a flat probe has no
    // structure to match on, so the correlation locks onto whichever quiet stretch
    // of the reference best fits its noise floor. That produces a high-scoring
    // match at an arbitrary offset (observed: 0.85 landing ~3 minutes out).
    //
    // Instead, rank every candidate window by its mean frame-to-frame ACTIVITY —
    // the average absolute change between neighbouring envelope frames. Speech and
    // music change constantly, so activity stays high across the whole window;
    // room tone barely moves and scores near zero.
    //
    // Activity is used in preference to plain variance because variance peaks on a
    // window that merely STRADDLES the silence→content boundary: one enormous step
    // maximises the spread while half the probe is still useless. A single step
    // contributes almost nothing to a mean over ~1200 frames, so activity picks a
    // window that is dynamic throughout.
    //
    // `envelope` is a mean-amplitude envelope at `envelopeRate` Hz. Returns
    // [{ offsetSec, activity }], non-overlapping and best first, so callers can
    // take [0] to probe and [1] as an independent confirmation point. Empty when
    // the envelope is shorter than one window. Linear in the envelope length.
    function pickProbeWindows(envelope, envelopeRate, probeDurSec, count) {
        var n = envelope.length;
        var w = Math.max(2, Math.round(probeDurSec * envelopeRate));
        if (n < w) return [];
        count = count || 1;

        var act = new Float64Array(n - 1);
        for (var i = 0; i < n - 1; i += 1) {
            act[i] = Math.abs(envelope[i + 1] - envelope[i]);
        }

        var lastStart = n - w;
        var span = w - 1; // activity samples that fall inside one window
        var scores = new Float64Array(lastStart + 1);
        var sum = 0;
        for (var j = 0; j < span; j += 1) sum += act[j];
        scores[0] = sum / span;
        for (var s = 1; s <= lastStart; s += 1) {
            sum += act[s + span - 1] - act[s - 1];
            scores[s] = sum / span;
        }

        // Greedily take the most active windows that don't overlap each other.
        var picked = [];
        var taken = [];
        for (var k = 0; k < count; k += 1) {
            var bestIdx = -1;
            var bestScore = -1;
            for (var c = 0; c <= lastStart; c += 1) {
                if (scores[c] <= bestScore) continue;
                var clash = false;
                for (var t = 0; t < taken.length; t += 1) {
                    if (Math.abs(c - taken[t]) < w) { clash = true; break; }
                }
                if (clash) continue;
                bestScore = scores[c];
                bestIdx = c;
            }
            if (bestIdx < 0) break;
            taken.push(bestIdx);
            picked.push({ offsetSec: bestIdx / envelopeRate, activity: bestScore });
        }
        return picked;
    }

    // Best window per equal SEGMENT of the clip, sorted most-active first. The
    // single most-active window can sit in a stretch no other device recorded —
    // a recorder started 20 minutes before the cameras has its loudest audio
    // while nothing else was rolling, and a probe from there can never match
    // anything. Spreading candidate windows across the whole recording lets a
    // retry escape such a region. Falls back to the plain top-N pick when the
    // clip is too short to segment.
    function pickProbeWindowsSpread(envelope, envelopeRate, probeDurSec, segments) {
        var n = envelope.length;
        var w = Math.max(2, Math.round(probeDurSec * envelopeRate));
        if (n < w) return [];
        segments = Math.max(1, segments || 4);
        var segLen = Math.floor(n / segments);
        if (segLen < w) return pickProbeWindows(envelope, envelopeRate, probeDurSec, segments);

        var picked = [];
        for (var s = 0; s < segments; s += 1) {
            var start = s * segLen;
            var end = (s === segments - 1) ? n : (start + segLen);
            var seg = Array.prototype.slice.call(envelope, start, end);
            var best = pickProbeWindows(seg, envelopeRate, probeDurSec, 1)[0];
            if (best) picked.push({ offsetSec: best.offsetSec + (start / envelopeRate), activity: best.activity });
        }
        picked.sort(function (a, b) { return b.activity - a.activity; });
        return picked;
    }

    // Fallback half-width for the confirmation search when cfg omits it, so a
    // partial config can never silently produce NaN window bounds.
    var COARSE_VERIFY_MARGIN_DEFAULT_SEC = 120;

    // Plan the confirmation window for an already-chosen coarse offset, using a
    // SECOND probe taken from a different part of the same recording. A peak
    // driven by silence or room tone won't reproduce the same offset elsewhere in
    // the file; a genuine alignment will. `probe2RelSec` is the second probe's
    // position relative to geom's probe origin (geom.targetInPointSec), and may
    // be negative when the better window sits earlier. Returns { winStart, winDur,
    // expectedLagSec } — the reference window to search and the lag a correct
    // offset should produce — or null when the reference doesn't reach that far.
    function planCoarseVerify(geom, cfg, coarseDeltaSec, probe2RelSec, probe2DurSec) {
        var timelinePos = geom.targetResolvedStartSec + coarseDeltaSec + probe2RelSec;
        var refSrc = geom.refInPointSec + (timelinePos - geom.refResolvedStartSec);
        var refMin = geom.refInPointSec;
        var refMax = geom.refInPointSec + geom.refDurationFull;
        var margin = (cfg && cfg.verifyMarginSec) || COARSE_VERIFY_MARGIN_DEFAULT_SEC;

        var start = Math.max(refMin, refSrc - margin);
        var end = Math.min(refMax, refSrc + probe2DurSec + margin);
        if (end - start < probe2DurSec) return null; // not enough reference there
        return { winStart: start, winDur: end - start, expectedLagSec: refSrc - start };
    }

    // Plan a bounded confirm window around an offset LEARNED from another track.
    // Devices from one shoot usually share the same clock-error family (in a real
    // log, two cameras' true offsets were -641.8s and -619.7s — 22s apart), so once
    // one track has found its offset confidently, the others should look there
    // before paying for a blind head/full scan. Returns one plan (same shape as
    // planCoarseSearch's) or null when the predicted spot falls outside the
    // reference.
    function planLearnedSearch(geom, cfg, learnedDeltaSec) {
        var refMinSrc = geom.refInPointSec;
        var refMaxSrc = geom.refInPointSec + geom.refDurationFull;
        var probeShort = Math.min(geom.targetAvailSec, cfg.targetMaxSec);

        // Where the target's in-point would sit in the reference source if this
        // track shared the learned offset (inverse of coarseResolve's mapping).
        var predRefSrc = geom.refInPointSec +
            ((geom.targetResolvedStartSec + learnedDeltaSec) - geom.refResolvedStartSec);

        var start = Math.max(refMinSrc, predRefSrc - cfg.learnedMarginSec);
        var end = Math.min(refMaxSrc, predRefSrc + probeShort + cfg.learnedMarginSec);
        var winDur = end - start;
        if (winDur < cfg.minOverlapSec) return null;

        return {
            label: "learned", winStart: start, winDur: winDur, probeDur: probeShort,
            predicts: true, predictedDeltaSec: learnedDeltaSec
        };
    }

    function createCoarseState() {
        return { best: null, near: null, skipFull: false };
    }

    // Fold one plan's slide-match candidate ({score, lagSec} | null) into `state`.
    // Returns true when the search should stop early (a strong match was found).
    // Sets state.skipFull when a prediction is confirmed near ITS OWN expected
    // delta (plan.predictedDeltaSec) — a timecode or learned-offset prediction can
    // legitimately sit far from the timestamp position and still be confirmed.
    function coarseConsider(state, plan, candidate, geom, cfg) {
        if (!candidate) return false;
        var entry = { score: candidate.score, lagSec: candidate.lagSec, winStart: plan.winStart, label: plan.label };
        if (!state.best || entry.score > state.best.score) state.best = entry;
        if (plan.predicts && (!state.near || entry.score > state.near.score)) state.near = entry;
        if (entry.score >= cfg.strongScore) return true;       // strong — trust it, stop searching
        if (plan.predicts && entry.score >= cfg.minScore) {
            var impliedDelta = geom.refResolvedStartSec +
                (plan.winStart + candidate.lagSec - geom.refInPointSec) - geom.targetResolvedStartSec;
            var predicted = plan.predictedDeltaSec || 0;
            if (Math.abs(impliedDelta - predicted) <= cfg.confirmNearSec) state.skipFull = true;
        }
        return false;
    }

    // Resolve the final coarse decision. A strong match anywhere overrides the
    // metadata; else trust the best prediction-aligned match; else nothing. Returns
    // { chosen, best, coarseDelta } — chosen/coarseDelta null when nothing confident.
    function coarseResolve(state, geom, cfg) {
        var chosen = null;
        if (state.best && state.best.score >= cfg.strongScore) chosen = state.best;
        else if (state.near && state.near.score >= cfg.minScore) chosen = state.near;

        var coarseDelta = null;
        if (chosen) {
            var matchedRefSrc = chosen.winStart + chosen.lagSec;
            var desiredTargetStart = geom.refResolvedStartSec + (matchedRefSrc - geom.refInPointSec);
            coarseDelta = Math.round((desiredTargetStart - geom.targetResolvedStartSec) * 1000) / 1000;
        }
        return { chosen: chosen, best: state.best, coarseDelta: coarseDelta };
    }

    // Resolve a whole track's search across SEVERAL reference candidates, keeping
    // the most confident one. A track is matched against every clip on the
    // reference track, not just the longest: when a recording belongs to a
    // different session than the longest reference clip (a second recorder that
    // ran only in the afternoon vs. a morning reference), that one comparison has
    // no correct answer available and settles on the best noise peak. Scoring each
    // candidate independently lets the right session win on audio content.
    //
    // `cands` is [{ state, geom }] in candidate order. Returns { index, result,
    // best } — index -1 and result null when no candidate reached a confident
    // match; `best` is the highest score seen anywhere, for the failure message.
    function coarseResolveBest(cands, cfg) {
        var winner = null;
        var winnerIndex = -1;
        var best = null;
        for (var i = 0; i < cands.length; i += 1) {
            var r = coarseResolve(cands[i].state, cands[i].geom, cfg);
            if (r.best && (!best || r.best.score > best.score)) best = r.best;
            if (r.chosen && (!winner || r.chosen.score > winner.chosen.score)) {
                winner = r;
                winnerIndex = i;
            }
        }
        return { index: winnerIndex, result: winner, best: best };
    }

    return {
        TICKS_PER_SECOND: TICKS_PER_SECOND,
        MAX_SPAN_SEC: MAX_SPAN_SEC,
        AUDIO_SAMPLE_RATE: AUDIO_SAMPLE_RATE,
        ENVELOPE_WINDOW_SAMPLES: ENVELOPE_WINDOW_SAMPLES,
        ENVELOPE_RATE: ENVELOPE_RATE,
        FINE_TUNE_MAX_SHIFT_SEC: FINE_TUNE_MAX_SHIFT_SEC,
        FINE_TUNE_MIN_OVERLAP_SEC: FINE_TUNE_MIN_OVERLAP_SEC,
        FINE_TUNE_MAX_COMPARE_SEC: FINE_TUNE_MAX_COMPARE_SEC,
        FINE_TUNE_MIN_SCORE: FINE_TUNE_MIN_SCORE,
        FINE_TUNE_MIN_APPLY_SEC: FINE_TUNE_MIN_APPLY_SEC,
        FINE_TUNE_WINDOW_POSITIONS: FINE_TUNE_WINDOW_POSITIONS,
        FINE_TUNE_DECENT_SCORE: FINE_TUNE_DECENT_SCORE,
        DRIFT_MIN_OVERLAP_SEC: DRIFT_MIN_OVERLAP_SEC,
        DRIFT_EDGE_FRACTION: DRIFT_EDGE_FRACTION,
        DRIFT_MIN_REPORT_SEC: DRIFT_MIN_REPORT_SEC,
        DRIFT_IMPLAUSIBLE_PPM: DRIFT_IMPLAUSIBLE_PPM,
        PEK_MAGIC: PEK_MAGIC,
        PEK_HEADER_BYTES: PEK_HEADER_BYTES,
        PEK_SAMPLES_PER_BLOCK: PEK_SAMPLES_PER_BLOCK,
        parsePekInfo: parsePekInfo,
        pekToEnvelope: pekToEnvelope,
        buildEnvelope: buildEnvelope,
        findBestLag: findBestLag,
        slideMatch: slideMatch,
        buildFineTuneAnchors: buildFineTuneAnchors,
        buildCompareWindow: buildCompareWindow,
        buildDriftProbe: buildDriftProbe,
        escapeHtml: escapeHtml,
        formatDuration: formatDuration,
        formatTime: formatTime,
        formatDate: formatDate,
        formatSignedSeconds: formatSignedSeconds,
        formatRange: formatRange,
        describeAnchor: describeAnchor,
        parseTimecodeToSeconds: parseTimecodeToSeconds,
        planCoarseSearch: planCoarseSearch,
        planLearnedSearch: planLearnedSearch,
        pickProbeWindows: pickProbeWindows,
        pickProbeWindowsSpread: pickProbeWindowsSpread,
        planCoarseVerify: planCoarseVerify,
        createCoarseState: createCoarseState,
        coarseConsider: coarseConsider,
        coarseResolve: coarseResolve,
        coarseResolveBest: coarseResolveBest
    };
});
