project.js

import { GAP_THRESHOLD } from "./utilities/constants.js"
import { generateId, parseCSV, parseTranscriptJSON, mergeSegmentsToSentences, farthestColor } from './utilities/tools.js';
import { loadAudioFile, decodeSampleArrayBuffer } from "./utilities/audio.js";

/**
 * Holds transcript data and pre-computes paragraph and speaker-block groupings
 * from the flat segment list for efficient rendering.
 * @property {object[]} segments - Flat list of transcript segments ({start, end, speaker, text}).
 * @property {object[]} paragraphs - Contiguous runs of segments sharing the same speaker, split on long gaps.
 * @property {object[]} speakerBlocks - Contiguous runs of paragraphs sharing the same speaker.
 * @property {string} sourceFilename - Original filename of the transcript source.
 */
export class Transcript {
    segments = [];
    paragraphs = []; // larger chunks of segments where the speaker remains constant or no breaks
    speakerBlocks = [];
    sourceFilename = "";

    /**
     * @param {object[]} segments - Flat list of transcript segments ({start, end, speaker, text}).
     */
    constructor(segments) {
        this.segments = segments;
        this.buildTranscript();
    }

    /**
     * @param {boolean} [deep=false] - If true, returns a deep copy (segments are cloned); otherwise shallow.
     * @returns {Transcript}
     */
    clone(deep = false) {
        const segments = deep ? this.segments.map(s => ({ ...s })) : this.segments;
        return new Transcript(segments);
    }

    /**
    * Separates transcript segments into paragraphs a speaker blocks ahead of time for easy
    * transcript management.
    */
    buildTranscript() {
        // Build paragraphs
        const paragraphs = this.#buildParagraphs(this.segments);
        this.paragraphs = paragraphs;

        // build speaker blocks
        const blocks = this.#buildSpeakerBlocks(this.paragraphs);
        this.speakerBlocks = blocks;
    }

    /**
    * Separates transcript segments into paragraphs
    * @param {object[]} segments - flat list of transcript segment objects
    * @returns {object[]} array of paragraph objects, each with speaker and segments
    */
    #buildParagraphs(segments) {
        let paragraphs = [];
        let paragraph = null;

        segments.forEach((segment, index) => {
            // attempt to get previous segment
            const prev = index > 0 ? segments[index - 1] : null;
            // manualParaBreak: true = force break, false = force merge, undefined = use gap logic
            let paraBreak;
            if (segment.manualParaBreak === true) {
                paraBreak = true;
            } else if (segment.manualParaBreak === false) {
                paraBreak = false;
            } else {
                paraBreak = prev != null && (segment.start - prev.end > GAP_THRESHOLD);
            }

            // if the speaker changes, or there is a paragraph break, end the paragraph
            if (!paragraph || segment.speaker !== paragraph.speaker || paraBreak) {
                paragraph = {
                    speaker: segment.speaker,
                    segments: [segment]
                };
                paragraphs.push(paragraph);
            // otherwise, just add a new segment to the paragraph
            } else {
                paragraph.segments.push(segment);
            }
        });
        return paragraphs;
    }

    /**
    * Separates transcript paragraphs in speaker blocks
    * @param {object[]} paragraphs - array of paragraph objects from #buildParagraphs
    * @returns {object[]} array of speaker block objects, each with speaker and paragraphs
    */
    #buildSpeakerBlocks(paragraphs) {
        // if there are no paragraphs, return empty list
        if (!paragraphs.length) {
            return [];
        }

        const blocks = [];
        let currentSpeaker = paragraphs[0].speaker;
        let currentBlock = {speaker: currentSpeaker, paragraphs: []};

        for (let i = 0; i < paragraphs.length; i++) {
            const paragraph = paragraphs[i];
            // if the speaker changes, push the block and create a new one
            if (paragraph.speaker !== currentSpeaker) {
                // push the old block
                blocks.push(currentBlock);
                // create new block with the new speaker and new paragraph
                currentBlock = {
                    speaker: paragraph.speaker,
                    paragraphs: [paragraph]
                }
                // update the current speaker
                currentSpeaker = paragraph.speaker;
            // otherwise, just add it to the current block
            } else {
                currentBlock.paragraphs.push(paragraph);
            }
        }
        // make sure the last block was pushed
        blocks.push(currentBlock);

        return blocks;
    }

    /**
    * Serializes segments to a CSV string.
    * Speaker ids are used as-is; fields with commas, quotes, or newlines are RFC 4180 escaped.
    * @returns {string}
    */
    compileCSV() {
        const header = 'start,end,speaker,text';
        const escape = s => /[\x22,\n]/.test(s) ? `"${s.replace(/\x22/g, '\x22\x22')}"` : s;
        const rows = this.segments.map(s =>
            `${s.start},${s.end},${escape(s.speaker)},${escape(s.text)}`
        );
        return [header, ...rows].join('\n');
    }

    /**
    * Serializes loadedSegments to CSV and triggers a browser file download.
    * Speaker ids are replaced with their display names so renames are preserved.
    * Fields containing commas, quotes, or newlines are RFC 4180 escaped.
    * Clears the dirty flag after saving.
    */
    saveTranscriptCSV() {
        // Serialize segments back to CSV (start, end, speaker, text)
        // Use display name for speaker so renames are preserved
        const header = 'start,end,speaker,text';
        // RFC 4180 CSV escaping: wrap in quotes if value contains comma, quote, or newline
        const escape = s => /[\x22,\n]/.test(s) ? `"${s.replace(/\x22/g, '\x22\x22')}"` : s;
        const rows = this.transcript().segments.map(s =>
            `${s.start},${s.end},${escape(this.speakerDisplayName(s.speaker))},${escape(s.text)}`
        );
        const csv = [header, ...rows].join('\n');
        const blob = new Blob([csv], { type: 'text/csv' });
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        // Append "_edited" suffix to avoid overwriting the original file
        a.download = this.transcript().sourceFilename.replace(/\.csv$/i, '') + '_edited.csv';
        a.click();
        URL.revokeObjectURL(a.href);

        this.markTranscriptDirty(false);

    }

    /**
    * Replaces the segment at segmentIndex with two new segments and rebuilds groupings.
    * @param {number} segmentIndex - index of the segment to replace
    * @param {object} newSegmentA - first replacement segment (earlier portion)
    * @param {object} newSegmentB - second replacement segment (later portion)
    */
    splitSegment(segmentIndex, newSegmentA, newSegmentB) {
        this.segments.splice(segmentIndex, 1, newSegmentA, newSegmentB);
        this.buildTranscript();
    }

    /**
    * Merges two adjacent segments into one and rebuilds groupings.
    * @param {number} segmentIndexA - index of the earlier segment
    * @param {number} segmentIndexB - index of the later segment
    */
    mergeSegments(segmentIndexA, segmentIndexB) {
        const segments = this.segments;

        const a = segments[segmentIndexA];
        const b = segments[segmentIndexB];

        if (a.speaker !== b.speaker) return;

        // create the new segment, preserving A's paragraph-break flag
        const mergedWords = (a.words?.length && b.words?.length)
            ? [...a.words, ...b.words]
            : null;
        const newSegment = {
            start: a.start,
            end: b.end,
            speaker: a.speaker,
            text: (a.text + ' ' + b.text).trim(),
            words: mergedWords,
        }
        if (a.manualParaBreak !== undefined) newSegment.manualParaBreak = a.manualParaBreak;

        // splice the new segment at the position of segment A, replacing both segments A and B
        segments.splice(segmentIndexA, 2, newSegment);
        this.buildTranscript();
    }

    /**
    * Reassigns a segment to a different speaker, marks the transcript dirty,
    * and fully re-renders the transcript and regions.
    * @param {number} segIdx - index into loadedSegments
    * @param {string} newSpeakerId - id of the speaker to assign to the segment
    */
    changeSpeaker(segIdx, newSpeakerId) {
        if (segIdx < 0 || segIdx >= this.segments.length) return;
        this.segments[segIdx].speaker = newSpeakerId;
        this.buildTranscript();
    }

    /**
    * Reassigns all segments in a paragraph to a different speaker and rebuilds groupings.
    * @param {object} paragraph - paragraph object (with .segments array)
    * @param {string} newSpeakerId - id of the speaker to assign
    */
    changeParagraphSpeaker(paragraph, newSpeakerId) {
        paragraph.segments.forEach(segment => { segment.speaker = newSpeakerId; });
        this.buildTranscript();
    }

    /**
    * Returns the index of the segment that contains the given time, or null if none.
    * @param {number} time - time in seconds
    * @returns {number|null}
    */
    segmentAtTime(time) {
        if (!this.segments.length) {
            return -1;
        }

        for (let i = 0; i < this.segments.length; i++) {
            const seg = this.segments[i];

            if (seg.start <= time && time <= seg.end) {
                return i;
            }
        }

        return null;
    }

    /**
    * Returns the index of the paragraph that contains the segment at segIdx.
    * @param {number} segIdx - index into this.segments
    * @returns {number} paragraph index, or -1 if not found
    */
    #paragraphIndexForSegment(segIdx) {
        const seg = this.segments[segIdx];
        return this.paragraphs.findIndex(p => p.segments.includes(seg));
    }

    /**
    * Forces a paragraph break before the segment at segIdx by setting manualParaBreak = true.
    * Returns the previous flag value so the caller can undo.
    * @param {number} segIdx - index of the segment that will start the new paragraph
    * @returns {boolean|undefined|null} previous manualParaBreak value, or null if segIdx is invalid
    */
    splitParagraphAt(segIdx) {
        if (segIdx <= 0 || segIdx >= this.segments.length) return null;
        const seg = this.segments[segIdx];
        const prevFlag = seg.manualParaBreak;
        seg.manualParaBreak = true;
        this.buildTranscript();
        return prevFlag;
    }

    /**
    * Merges the paragraph containing segIdx with the previous paragraph.
    * Only works when the two paragraphs share the same speaker.
    * Returns {segIdx, prevFlag} for undo, or null if the merge is not possible.
    * @param {number} segIdx - index of any segment in the paragraph to merge upward
    * @returns {{segIdx: number, prevFlag: boolean|undefined}|null}
    */
    mergeParagraphBefore(segIdx) {
        const paraIdx = this.#paragraphIndexForSegment(segIdx);
        if (paraIdx <= 0) return null;
        const para = this.paragraphs[paraIdx];
        const prevPara = this.paragraphs[paraIdx - 1];
        if (para.speaker !== prevPara.speaker) return null;
        const firstSeg = para.segments[0];
        const firstSegIdx = this.segments.indexOf(firstSeg);
        const prevFlag = firstSeg.manualParaBreak;
        firstSeg.manualParaBreak = false;
        this.buildTranscript();
        return { segIdx: firstSegIdx, prevFlag };
    }

    /**
    * Restores a manualParaBreak flag for undo/redo.
    * @param {number} segIdx - index into this.segments
    * @param {boolean|undefined} flag - the flag value to restore; undefined removes the flag
    */
    restoreParaBreak(segIdx, flag) {
        const seg = this.segments[segIdx];
        if (flag === undefined) {
            delete seg.manualParaBreak;
        } else {
            seg.manualParaBreak = flag;
        }
        this.buildTranscript();
    }

    /**
    * Serializes the transcript to the canonical paragraph > sentence > word JSON format.
    * Uses the pre-computed paragraphs array so manual paragraph breaks are preserved.
    * @returns {{paragraphs: object[]}}
    */
    compileJSON() {
        return {
            paragraphs: this.paragraphs.map(para => ({
                speaker: para.speaker,
                start: para.segments[0].start,
                end: para.segments[para.segments.length - 1].end,
                sentences: para.segments.map(seg => {
                    const s = { start: seg.start, end: seg.end, text: seg.text, words: seg.words ?? [] };
                    if (seg.wordsStale) s.wordsStale = true;
                    return s;
                })
            }))
        };
    }
}

/**
 * Container class for waveform audio data and metadata.
 * @property {string} url - Streaming URL, blob URL, or object URL for the audio.
 * @property {number} sampleRate - Sample rate of the audio in Hz; -1 if unknown.
 * @property {number} duration - Duration of the audio in seconds; -1 if unknown.
 * @property {number[]|null} peaks - Pre-computed amplitude peaks for fast waveform rendering.
 * @property {string} filename - Original filename of the audio source.
 * @property {File|null} file - Original File reference for large audio (chunked path); null for small files or server audio.
 */
export class Waveform {
    url = "";        // Streaming URL, blob URL, or object URL for the audio
    sampleRate = -1;
    duration = -1;   // Duration of the audio in seconds
    peaks = null;    // Pre-computed peaks for fast waveform rendering
    filename = "";   // Original filename of the audio source
    file = null;     // Original File reference (large local audio only) — use directly for upload to avoid re-read
    hasAudioMp3 = false; // Whether a converted audio.mp3 copy exists on the server

    /**
     * @param {object} [options] - waveform data options
     * @param {string} [options.url=""] - Streaming URL, blob URL, or object URL for the audio.
     * @param {number} [options.sampleRate=-1] - Sample rate in Hz; -1 if unknown.
     * @param {number} [options.duration=-1] - Duration in seconds; -1 if unknown.
     * @param {number[]|null} [options.peaks=null] - Pre-computed amplitude peaks for waveform rendering.
     * @param {string} [options.filename=""] - Original filename of the audio source.
     * @param {File|null} [options.file=null] - Original File reference for direct upload (large files only).
     * @param {boolean} [options.hasAudioMp3=false] - Whether a converted audio.mp3 copy exists on the server.
     */
    constructor({ url = "", sampleRate = -1, duration = -1, peaks = null, filename = "", file = null, hasAudioMp3 = false } = {}) {
        this.url = url;
        this.sampleRate = sampleRate;
        this.duration = duration;
        this.peaks = peaks;
        this.filename = filename;
        this.file = file;
        this.hasAudioMp3 = hasAudioMp3;
    }

    /**
     * @param {boolean} [deep=false] - If true, peaks arrays are cloned; otherwise copied by reference.
     * @returns {Waveform}
     */
    clone(deep = false) {
        return new Waveform({
            url:          this.url,
            sampleRate:   this.sampleRate,
            duration:     this.duration,
            peaks:        deep && this.peaks ? this.peaks.map(ch => ch.slice()) : this.peaks,
            filename:     this.filename,
            hasAudioMp3:  this.hasAudioMp3,
        });
    }
}

/**
 * Container class for defined speakers in a transcript.
 * @property {string} id - Unique identifier (raw UID) for the speaker.
 * @property {string} name - Display name shown in the UI.
 * @property {string} hue - Hex color string used to visually distinguish this speaker.
 * @property {{audioBuffer: AudioBuffer, blobUrl: string, peaks: number[]}}} sample - Voice sample assigned to the speaker.
 */
export class Speaker {
    id = null; // The raw UID of the speaker
    name = null; // The display name of the speaker
    hue = null; // The display color of the speaker
    // {audioBuffer, blobUrl, peaks}
    sample = {}; // The audio sample assigned to the speaker

    /**
     * @param {string} id - Unique identifier (raw UID) for the speaker.
     * @param {string} name - Display name shown in the UI.
     * @param {string} hue - Hex color string used to visually distinguish this speaker.
     * @param {{audioBuffer: AudioBuffer, blobUrl: string, peaks: number[]}} [sample={}] - Voice sample assigned to the speaker.
     */
    constructor(id, name, hue, sample = {}) {
        this.id = id;
        this.name = name;
        this.hue = hue;
        this.sample = sample;
    }

    /**
     * @param {boolean} [deep=false] - If true, peaks in sample are cloned; otherwise sample is copied by reference.
     * @returns {Speaker}
     */
    clone(deep = false) {
        const sample = deep && this.sample?.peaks
            ? { ...this.sample, peaks: this.sample.peaks.slice() }
            : this.sample;
        return new Speaker(this.id, this.name, this.hue, sample);
    }
}

/**
 * Container class for data owned by a project. Allows for having differing server and local copies.
 * @property {Transcript|null} transcript - The transcript object for this project.
 * @property {Waveform|null} waveform - Waveform object containing url, sampleRate, duration, and peaks.
 * @property {Object.<string, Speaker>} speakers - Dictionary of Speaker objects keyed by speaker id.
 */
export class ProjectData {
    transcript = null;
    waveform = null; // A Waveform object containing url, sampleRate, duration, and peaks
    speakers = {}; // A dictionary of Speaker class objects with speaker ids as the key

    /**
     * @param {boolean} [deep=false] - If true, returns a deep copy; otherwise shallow (waveform and speakers copied by reference).
     * @returns {ProjectData}
     */
    clone(deep = false) {
        let clone = new ProjectData();
        if (this.transcript) {
            clone.transcript = this.transcript.clone(deep);
        }

        if (this.waveform) {
            clone.waveform = this.waveform.clone(deep);
        }

        if (deep) {
            clone.speakers = {};
            for (const [id, spk] of Object.entries(this.speakers)) {
                clone.speakers[id] = spk.clone(deep);
            }
        } else {
            clone.speakers = this.speakers;
        }

        return clone;
    }
}

/**
 * Represents a single transcription project. Stores both a server-side and
 * a local working copy of the project data (transcript, speakers, waveform),
 * tracks dirty state per data category, and fires callbacks when data changes.
 * @property {string} projectId - UUID of the project; assigned by the server on first push.
 * @property {string} projectName - Human-readable name of the project.
 * @property {string} createdDate - ISO 8601 timestamp of when the project was created.
 * @property {string} modifiedDate - ISO 8601 timestamp of the last modification.
 * @property {boolean} synced - True if the local copy is up to date with the server.
 * @property {boolean} localOnly - True if the project has never been pushed to the server.
 * @property {boolean} speakersDirty - True if speaker data has unsaved local changes.
 * @property {boolean} transcriptDirty - True if transcript data has unsaved local changes.
 * @property {boolean} waveformDirty - True if waveform data has unsaved local changes.
 * @property {boolean} hasTranscript - True if a transcript has been loaded.
 * @property {boolean} hasSpeakers - True if speakers have been loaded.
 * @property {boolean} hasWaveform - True if a waveform has been loaded.
 * @property {ProjectData} local - Local working copy of the project data.
 * @property {ProjectData} server - Server-authoritative copy of the project data.
 * @property {callback} onStateChange - Callback fired whenever any state changes.
 * @property {callback} onSpeakersModified - Callback fired when speakers are marked dirty.
 * @property {callback} onTranscriptModified - Callback fired when the transcript is marked dirty.
 * @property {callback} onWaveformModified - Callback fired when the waveform is marked dirty.
 * @property {callback} onSpeakersSaved - Callback fired when speakers are marked clean.
 * @property {callback} onTranscriptSaved - Callback fired when the transcript is marked clean.
 * @property {callback} onWaveformSaved - Callback fired when the waveform is marked clean.
 */
export class Project {

    /**
     * @param {string} [projectId=""] - UUID of the project; assigned by the server on first push.
     * @param {string} [projectName=""] - Human-readable name of the project.
     * @param {object} callbacks - callback functions for project state changes
     * @param {callback} [callbacks.onStateChange] - Callback fired whenever any state changes.
     * @param {Server} [callbacks.server=null] - Reference to the App Server instance.
     */
    constructor(projectId = "", projectName = "", { onStateChange, server = null }) {
//        this.dirty = false; // true, if the project has unsaved changes
        this.synced = false; // true, if the project is up to date with the server
        this.localOnly = true; // true, if the project has never been pushed to the server

        this.speakersDirty = false;
        this.waveformDirty = false;
        this.transcriptDirty = false;

        this.hasTranscript = false;
        this.hasSpeakers = false;
        this.hasWaveform = false;

        // Current time as string
        const now = new Date().toISOString();

        // A project's uid will change when uploaded to the server for the first time
        this.projectId = projectId; // UUID of the project.  Will be generated on server project creation.
        this.projectName = projectName;
        this.createdDate = now;
        this.modifiedDate = now;

        this.local = new ProjectData(); // The local copy of the project data
        this.server = new ProjectData(); // The server copy of the project data (truth)

        this.annotations = { hyperlinks: {} }; // Annotation data (hyperlinks, etc.)

        this.activeServer = server; // Reference to the App Server instance

        this._onStateChange = onStateChange ?? (() => {});

        this._onSpeakersModified = (() => {});
        this._onTranscriptModified = (() => {});
        this._onWaveformModified = (() => {});

        this._onSpeakersSaved = (() => {});
        this._onTranscriptSaved = (() => {});
        this._onWaveformSaved = (() => {});
    }

    /**
    * Registers callbacks that are invoked when each data category is marked dirty or clean.
    * @param {object} callbacks - callback functions for data modification events
    * @param {callback} [callbacks.onSpeakersModified] - called when speakers are marked dirty
    * @param {callback} [callbacks.onTranscriptModified] - called when the transcript is marked dirty
    * @param {callback} [callbacks.onWaveformModified] - called when the waveform is marked dirty
    * @param {callback} [callbacks.onSpeakersSaved] - called when speakers are marked clean
    * @param {callback} [callbacks.onTranscriptSaved] - called when the transcript is marked clean
    * @param {callback} [callbacks.onWaveformSaved] - called when the waveform is marked clean
    */
    registerModifyCallbacks({onSpeakersModified, onTranscriptModified, onWaveformModified,
                             onSpeakersSaved, onTranscriptSaved, onWaveformSaved}) {
        this._onSpeakersModified = onSpeakersModified ?? (() => {});
        this._onTranscriptModified = onTranscriptModified ?? (() => {});
        this._onWaveformModified = onWaveformModified ?? (() => {});
        this._onSpeakersSaved = onSpeakersSaved ?? (() => {});
        this._onTranscriptSaved = onTranscriptSaved ?? (() => {});
        this._onWaveformSaved = onWaveformSaved ?? (() => {});
    }

    /**
     * Loads a local audio file, extracts metadata and peaks, and stores the
     * results in the local ProjectData. The returned object URL is owned by this
     * project; revoke it (URL.revokeObjectURL(this.local.waveformUrl)) when the
     * project is closed or replaced.
     * @param {File} file - audio file to load; any format supported by the browser
     * @param {function(number):void} [onProgress] - progress callback, fraction 0–1 (large files only)
     */
    async loadLocalAudio(file, onProgress) {
        const { url, file: audioFile, sampleRate, peaks, duration, filename } = await loadAudioFile(file, { onProgress });

        this.local.waveform = new Waveform({ url, file: audioFile ?? null, sampleRate, duration, peaks, filename });

        this.hasWaveform = true;
        this.markWaveformDirty();
    }

    /**
    * Pulls data from the server and stores it in memory
    */
    async pullFromServer() {
        // ----- WAVEFORM LOAD ----- //
        // get the waveform streaming url and metadata (including pre-computed peaks)
        const wfMeta = await this.activeServer.getWaveform(this.projectId).catch(() => null);
        if (wfMeta && Object.keys(wfMeta).length > 0) {
            const peaks = wfMeta.peaks?.length ? [new Float32Array(wfMeta.peaks)] : null;
            this.server.waveform = new Waveform({
                url:         this.activeServer.audioUrl(this.projectId),
                sampleRate:  wfMeta.sampleRate   ?? -1,
                duration:    wfMeta.duration     ?? -1,
                filename:    wfMeta.filename     ?? '',
                hasAudioMp3: wfMeta.hasAudioMp3  ?? false,
                peaks,
            });
            this.hasWaveform = true;
        }

        // ----- SPEAKERS LOAD ----- //
        // Fetch saved speaker data
        const data = await this.activeServer.getSpeakers(this.projectId);
        if (Object.keys(data).length > 0) {
            await Promise.all(Object.values(data).map(async (speaker) => {
                let sample = {};
                if (speaker.has_sample) {
                    try {
                        const arrayBuffer = await this.activeServer.getSpeakerSample(this.projectId, speaker.id);
                        sample = await decodeSampleArrayBuffer(arrayBuffer);
                    } catch(e) {
                        console.warn(`Could not load sample for speaker ${speaker.id}:`, e);
                    }
                }
                // Use local=false so these end up in server copy, preserved by the clone below
                this.addSpeaker(speaker.id, speaker.name, speaker.hue, sample, false);
            }));
            this.hasSpeakers = true;
        }

        // ----- TRANSCRIPT LOAD ----- //
        // if there is an associated transcript, get it
        if (this.hasTranscript) {
            let transcriptResult = null;

            try {
                transcriptResult = await this.activeServer.getTranscript(this.projectId);
            } catch(e) {
                console.warn('Could not load transcript from server:', e);
            }

            if (transcriptResult) {
                if (transcriptResult.contentType?.includes('application/json')) {
                    this.loadTranscriptJSON(JSON.parse(transcriptResult.text), false);
                } else {
                    this.loadTranscriptCSV(transcriptResult.text, false);
                }
            }
        }

        // ----- ANNOTATIONS LOAD ----- //
        try {
            const annotations = await this.activeServer.getAnnotations(this.projectId);
            if (annotations) this.annotations = annotations;
        } catch(e) {
            // annotations.json may not exist for older projects — that's fine
        }

        // Clone the server data to the local working copy
        this.local = this.server.clone(true);
    }

    /**
     * Parses a CSV string and loads the resulting segments into the project's transcript.
     * @param {string} transcript_text - CSV text with columns: start, end, speaker, text
     * @param {boolean} [local=true] - if true, loads into the local copy; otherwise the server copy
     */
    loadTranscriptCSV(transcript_text, local = true) {
        let projectData = null;
        if(local){
            projectData = this.local;
        } else {
            projectData = this.server;
        }

        let segments = mergeSegmentsToSentences(parseCSV(transcript_text));
        // assign any missing speakers
        const uniqueSpeakers = [...new Set(segments.map(segment => segment.speaker))];

        uniqueSpeakers.forEach(speakerId => {
            // If there is no registered speaker with this ID
            if (!projectData.speakers.hasOwnProperty(speakerId)) {
                this.addSpeaker(speakerId, speakerId, null, {}, local);
            }
        });

        projectData.transcript = new Transcript([...segments]);

        this.hasSpeakers = true;
        this.hasTranscript = true;

        this.markTranscriptDirty();
        this.markSpeakersDirty();
    }

    /**
    * Parses a transcript JSON object and loads the resulting segments into the project's transcript.
    * Flattens the paragraph > sentence > word hierarchy into a flat segments list.
    * @param {object} json - transcript JSON with a `paragraphs` array
    * @param {boolean} [local=true] - if true, loads into the local copy; otherwise the server copy
    */
    loadTranscriptJSON(json, local = true) {
        const projectData = local ? this.local : this.server;
        const segments = parseTranscriptJSON(json);
        const uniqueSpeakers = [...new Set(segments.map(s => s.speaker))];
        uniqueSpeakers.forEach(speakerId => {
            if (!projectData.speakers.hasOwnProperty(speakerId)) {
                this.addSpeaker(speakerId, speakerId, null, {}, local);
            }
        });
        projectData.transcript = new Transcript([...segments]);
        this.hasSpeakers = true;
        this.hasTranscript = true;
        this.markTranscriptDirty();
        this.markSpeakersDirty();
    }

    /**
    * Adds a new speaker to the project. Auto-assigns a hue if none is provided.
    * @param {string} id - speaker id
    * @param {string} name - display name
    * @param {string|null} [hue=null] - hex color; auto-generated if null
    * @param {object} [sample={}] - voice sample data
    * @param {boolean} [local=true] - if true, adds to the local copy; else server copy
    */
    addSpeaker(id, name, hue = null, sample = {}, local = true) {
        // If there is no assigned hue, generate a new one
        if (!hue) {
            let colors = []

            if (local) {
                colors = Object.values(this.local.speakers).map(speaker => speaker.hue);
            } else {
                colors = Object.values(this.server.speakers).map(speaker => speaker.hue);
            }

            hue = farthestColor(colors);
        }

        const newSpeaker = new Speaker(id, name, hue, sample);
        if (local) {
            this.local.speakers[id] = newSpeaker;
        } else {
            this.server.speakers[id] = newSpeaker;
        }

        this.hasSpeakers = true;
        this.markSpeakersDirty();
    }

    /**
    * Removes a speaker from the active speakers dict and marks speakers dirty.
    * @param {string} speakerId - the id of the speaker to remove
    */
    removeSpeaker(speakerId) {
        delete this.speakers()[speakerId];
        this.markSpeakersDirty();
    }

    /**
    * Reassigns all transcript segments from one speaker to another.
    * @param {string} speakerId - id of the speaker to reassign from
    * @param {string} targetId - id of the speaker to assign to
    */
    reassignSegments(speakerId, targetId) {
        // Reassign all segments
        this.transcript().segments.forEach(s => {
            if (s.speaker === speakerId) {
                s.speaker = targetId;
            }
        });
        this.transcript().buildTranscript();
        this.markTranscriptDirty();
    }

    /**
    * Marks all dirty flags as clean after a successful server upload.
    * Also snapshots the current local state as the new save baseline so
    * revertToLastSave() can restore it later.
    * Call App.pushProjectToServer() to perform the actual upload.
    */
    markClean() {
        this.server = this.local.clone(true);
        this.markAllDirty(false);
    }

    /**
    * Discards all local changes since the last save by restoring the local
    * working copy from the saved server snapshot, then re-renders all panels.
    */
    revertToLastSave() {
        const waveformWasDirty = this.waveformDirty;
        this.local = this.server.clone(true);
        // Fire modified callbacks to trigger panel re-renders
        this._onSpeakersModified();
        this._onTranscriptModified();
        if (waveformWasDirty) this._onWaveformModified();
        // Clear all dirty flags without firing re-render again
        this.speakersDirty = false;
        this.transcriptDirty = false;
        this.waveformDirty = false;
        this._onSpeakersSaved();
        this._onTranscriptSaved();
        this._onWaveformSaved();
        this._onStateChange();
    }

    /**
    * Creates a new Project that is a copy of this one with a different id and name.
    * @param {string} newId - unique identifier for the new copy
    * @param {string} newName - display name for the new copy
    * @param {object} callbacks - callback functions for the new project
    * @param {callback} callbacks.onStateChange - fired whenever state changes in the copy
    * @returns {Project}
    */
    createCopy(newId, newName, { onStateChange }) {
        const copy = new Project(newId, newName, { onStateChange, server: this.activeServer });
        copy.local = this.local.clone(true);
        return copy;
    }

    /**
    * Updates the project name and notifies listeners via the state-change callback.
    * @param {string} name - the new display name for the project
    */
    setName(name) {
        this.projectName = name;
        this._onStateChange();
    }

    // ----- TRANSCRIPT FUNCTIONS ----- //

    /**
    * Merges two adjacent segments and marks the transcript dirty.
    * @param {number} segmentIndexA - index of the earlier segment
    * @param {number} segmentIndexB - index of the later segment
    */
    mergeSegments(segmentIndexA, segmentIndexB) {
        this.transcript().mergeSegments(segmentIndexA, segmentIndexB);
        // Remap annotation segmentIdx values: links on segmentIndexB merge into
        // segmentIndexA (dropped), links above segmentIndexB shift down by one.
        this.#remapAnnotationIndices((idx) => {
            if (idx === segmentIndexB) return null; // segment removed — drop link
            if (idx > segmentIndexB)  return idx - 1;
            return idx;
        });
        this.markTranscriptDirty();
    }

    /**
    * Splits a segment into two and marks the transcript dirty.
    * @param {number} segmentIndex - index of the segment to split
    * @param {object} newSegmentA - first replacement segment (earlier portion)
    * @param {object} newSegmentB - second replacement segment (later portion)
    */
    splitSegment(segmentIndex, newSegmentA, newSegmentB) {
        this.transcript().splitSegment(segmentIndex, newSegmentA, newSegmentB);
        // Remap annotation segmentIdx values: links above the split point shift
        // up by one. Links on the split segment itself are dropped because the
        // original character offsets are no longer valid after the split.
        this.#remapAnnotationIndices((idx) => {
            if (idx === segmentIndex) return null; // offsets invalidated — drop link
            if (idx > segmentIndex)  return idx + 1;
            return idx;
        });
        this.markTranscriptDirty();
    }

    /**
     * Applies a remapping function to every hyperlink's segmentIdx.
     * If the function returns null the link is deleted; otherwise its
     * segmentIdx is updated to the returned value.
     * @param {function(number): number|null} remap - receives the current segmentIdx; return the new index or null to delete the link
     */
    #remapAnnotationIndices(remap) {
        const hyperlinks = this.annotations?.hyperlinks;
        if (!hyperlinks) return;
        for (const [id, link] of Object.entries(hyperlinks)) {
            const next = remap(link.segmentIdx);
            if (next === null) delete hyperlinks[id];
            else link.segmentIdx = next;
        }
    }

    /**
    * Reassigns a segment to a different speaker, marks the transcript dirty,
    * and fully re-renders the transcript and regions.
    * @param {number} segIdx - index into loadedSegments
    * @param {string} newSpeakerId - id of the speaker to assign the segment to
    */
    changeSpeaker(segIdx, newSpeakerId) {
        this.transcript().changeSpeaker(segIdx, newSpeakerId);
        this.markTranscriptDirty();
    }

    /**
    * Reassigns all segments in a paragraph to a different speaker, marks the transcript dirty.
    * @param {object} paragraph - paragraph object from the transcript
    * @param {string} newSpeakerId - id of the speaker to assign
    */
    changeParagraphSpeaker(paragraph, newSpeakerId) {
        this.transcript().changeParagraphSpeaker(paragraph, newSpeakerId);
        this.markTranscriptDirty();
    }

    /**
    * Forces a paragraph break before the segment at segIdx.
    * @param {number} segIdx - index of the segment that will start the new paragraph
    * @returns {boolean|undefined|null} previous manualParaBreak value for undo
    */
    splitParagraphAt(segIdx) {
        const prevFlag = this.transcript().splitParagraphAt(segIdx);
        if (prevFlag !== null) this.markTranscriptDirty();
        return prevFlag;
    }

    /**
    * Merges the paragraph containing segIdx with the previous same-speaker paragraph.
    * @param {number} segIdx - index of any segment in the paragraph to merge upward
    * @returns {{segIdx: number, prevFlag: boolean|undefined}|null} undo info, or null if not possible
    */
    mergeParagraphBefore(segIdx) {
        const result = this.transcript().mergeParagraphBefore(segIdx);
        if (result) this.markTranscriptDirty();
        return result;
    }

    // ----- HELPERS ----- //
    /**
    * Returns the index of the local transcript segment at the given time.
    * @param {number} time - seconds
    * @returns {number|null}
    */
    segmentAtTime(time) {
        return this.local.transcript.segmentAtTime(time);
    }
    
    /**
    * Gets the general project metadata as a dictionary (version, id, name, dates, speakers).
    * Waveform details live in waveformMetadata() instead.
    * @returns {object} metadata dictionary with version, id, name, created, modified, and speakers
    */
    metadata() {
        const speakersDict = this.speakers();
        const speakersMeta = {};
        for (const [id, spk] of Object.entries(speakersDict)) {
            speakersMeta[id] = {
                id:         spk.id,
                name:       spk.name,
                hue:        spk.hue,
                has_sample: !!(spk.sample && spk.sample.blobUrl),
            };
        }

        return {
            version:  '1.0',
            id:       this.projectId,
            name:     this.projectName,
            created:  this.createdDate,
            modified: this.modifiedDate,
            speakers: speakersMeta,
        };
    }

    /**
    * Gets all waveform data — sampleRate, duration, filename, and peaks — for server upload.
    * peaks is stored locally as Float32Array[] (one per channel from the Web Audio API).
    * This converts the mono channel (index 0) to a plain Array so JSON.stringify produces
    * a compact JSON number array instead of a keyed object.
    * @returns {{sampleRate: number, duration: number, filename: string, peaks: number[]}} waveform metadata object
    */
    waveformMetadata() {
        const waveform = this.waveform();
        const peakChannel = waveform?.peaks?.[0]; // mono mix is always channel 0
        return {
            sampleRate: waveform?.sampleRate ?? -1,
            duration:   waveform?.duration   ?? -1,
            filename:   waveform?.filename   ?? '',
            peaks:      peakChannel ? Array.from(peakChannel) : [],
        };
    }

    /**
    * Gets the speaker object for the speaker with the requested id
    * @param {string} speakerId - The ID of the speaker to get
    * @param {bool} local - True, if the function should get a local copy, false if it should get a server copy
    * @returns {Speaker|undefined} the speaker object, or undefined if not found
    */
    getSpeaker(speakerId, local = true) {
        return this.speakers(local)[speakerId]
    }

    /**
    * Helper function to get the transcript from the project data
    * @param {bool} local - True, if the function should get a local copy, false if it should get a server copy
    * @returns {Transcript|null} the transcript object
    */
    transcript(local = true) {
        if(local) {
            return this.local.transcript;
        }
        return this.server.transcript;
    }

    /**
    * Helper function to get the speakers from the project data
    * @param {bool} local - True, if the function should get a local copy, false if it should get a server copy
    * @returns {Object.<string, Speaker>} dictionary of Speaker objects keyed by speaker id
    */
    speakers(local = true) {
        if(local) {
            return this.local.speakers;
        }
        return this.server.speakers;
    }

    /**
    * Helper function to get the waveform from the project data
    * @param {bool} local - True, if the function should get a local copy, false if it should get a server copy
    * @returns {Waveform|null} the waveform object, or null if none loaded
    */
    waveform(local = true) {
        return local ? this.local.waveform : this.server.waveform;
    }
    
    /**
    * Returns whether any project sector is dirty
    * @returns {boolean} true if any sector has unsaved changes
    */
    isDirty() {
        return this.speakersDirty || this.transcriptDirty || this.waveformDirty;
    }

    /**
    * Sets the dirty (or edited) status of all sectors
    * @param {boolean} [dirty=true] - true to mark dirty, false to mark clean
    */
    markAllDirty(dirty = true) {
        this.markSpeakersDirty(dirty);
        this.markTranscriptDirty(dirty);
        this.markWaveformDirty(dirty);
    }

    /**
    * Sets the dirty (or edited) status of the speakers sector
    * @param {boolean} [dirty=true] - true to mark dirty, false to mark clean
    */
    markSpeakersDirty(dirty = true) {
        this.speakersDirty = dirty;
        // If it is being changed, call the callback
        if(dirty) {
            this._onSpeakersModified();
            this._onStateChange();
        } else {
            this._onSpeakersSaved();
            this._onStateChange();
        }
    }

    /**
    * Sets the dirty (or edited) status of the transcript sector
    * @param {boolean} [dirty=true] - true to mark dirty, false to mark clean
    */
    markTranscriptDirty(dirty = true) {
        this.transcriptDirty = dirty;
        // If it is being changed, call the callback
        if(dirty) {
            this._onTranscriptModified();
            this._onStateChange();
        } else {
            this._onTranscriptSaved();
            this._onStateChange();
        }
    }

    /**
    * Sets the dirty (or edited) status of the waveform sector
    * @param {boolean} [dirty=true] - true to mark dirty, false to mark clean
    */
    markWaveformDirty(dirty = true) {
        this.waveformDirty = dirty;
        // If it is being changed, call the callback
        if(dirty) {
            this._onWaveformModified();
            this._onStateChange();
        } else {
            this._onWaveformSaved();
            this._onStateChange();
        }
    }

    /**
    * Packages the project into a downloadable ZIP archive containing:
    * - project.json   — all project, waveform, and speaker metadata
    * - peaks.json     — waveform amplitude peaks
    * - transcript.csv — recompiled transcript segments
    * - <filename>.wav — the main audio file
    * - samples/<id>.wav — speaker audio samples
    * @param {FileSystemFileHandle} filepath - file handle to write the archive to
    * @returns {Promise<void>}
    */
    async packageProject(filepath) {
        const zip = new window.JSZip();
        const waveform = this.waveform();
        const speakersDict = this.speakers();

        // --- project.json ---
        const speakersMeta = {};
        for (const [id, spk] of Object.entries(speakersDict)) {
            speakersMeta[id] = {
                id:         spk.id,
                name:       spk.name,
                hue:        spk.hue,
                has_sample: !!(spk.sample && spk.sample.blobUrl),
            };
        }

        // If saving a server project locally, give it a new local id.  Otherwise it can keep its id.
        let projId = this.projectId;
        if (!this.localOnly) {
            projId = generateId()
        }

        const projectMeta = {
            version:  '1.0',
            id:       projId,
            name:     this.projectName,
            created:  this.createdDate,
            modified: this.modifiedDate,
            waveform: {
                sampleRate: waveform?.sampleRate ?? -1,
                duration:   waveform?.duration   ?? -1,
                filename:   waveform?.filename   ?? '',
            },
            speakers: speakersMeta,
        };
        zip.file('project.json', JSON.stringify(projectMeta, null, 2));

        // --- peaks.json ---
        const peaks = waveform?.peaks ?? [];
        zip.file('peaks.json', JSON.stringify(peaks));

        // --- transcript.csv ---
        if (this.hasTranscript) {
            const csv = this.transcript().compileCSV();
            zip.file('transcript.csv', csv);
        }

        // --- speaker audio samples ---
        const samplesFolder = zip.folder('samples');
        for (const [id, spk] of Object.entries(speakersDict)) {
            if (spk.sample?.blobUrl) {
                try {
                    const resp = await fetch(spk.sample.blobUrl);
                    const buf  = await resp.arrayBuffer();
                    samplesFolder.file(`${id}.wav`, buf);
                } catch (e) {
                    console.warn(`Could not bundle sample for speaker ${id}:`, e);
                }
            }
        }

        // --- waveform audio ---
        if (waveform?.url) {
            try {
                const resp = await fetch(waveform.url);
                const buf  = await resp.arrayBuffer();
                const filename = waveform.filename || 'audio.wav';
                zip.file(filename, buf);
            } catch (e) {
                console.warn('Could not bundle waveform audio:', e);
            }
        }

        // --- Generate and save ---
        // Use default compression level as no meaningful difference between default and 9
        const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });

        if (!(filepath && typeof filepath.createWritable === 'function')) {
            filepath = await window.showSaveFilePicker({
                suggestedName: `${this.projectName.replace(/[^a-z0-9_\-]/gi, '_')}.wfs`,
                types: [{ description: 'Transcriber Project', accept: { 'application/octet-stream': ['.wfs'] } }],
            });
        }

        const writable = await filepath.createWritable();
        await writable.write(blob);
        await writable.close();
    }

    /**
     * Factory function — the inverse of Project.packageProject().
     * Reads a packaged .wfs ZIP file (File, Blob, or ArrayBuffer) and reconstructs
     * a fully-populated Project from its contents.
     * @param {File|Blob|ArrayBuffer} zipFile - The packaged project archive.
     * @param {object} [callbacks] - callback functions for the restored project
     * @param {callback} [callbacks.onStateChange] - fired whenever state changes in the restored project
     * @returns {Promise<Project>}
     */
    static async unpackageProject(zipFile, { onStateChange } = {}) {
        const zip = await window.JSZip.loadAsync(zipFile);

        // --- project.json ---
        const projectMeta = JSON.parse(await zip.file('project.json').async('string'));

        const project = new Project(projectMeta.id, projectMeta.name, { onStateChange: onStateChange ?? (() => {}) });
        project.createdDate = projectMeta.created;
        project.modifiedDate = projectMeta.modified;

        // --- peaks.json ---
        const peaks = JSON.parse(await zip.file('peaks.json').async('string'));

        // --- waveform audio ---
        const waveformMeta = projectMeta.waveform;
        const audioFilename = waveformMeta.filename || 'audio.wav';
        let waveformUrl = '';

        const audioEntry = zip.file(audioFilename);
        if (audioEntry) {
            const audioBuf = await audioEntry.async('arraybuffer');
            waveformUrl = URL.createObjectURL(new Blob([audioBuf], { type: 'audio/wav' }));
        }

        project.local.waveform = new Waveform({
            url:        waveformUrl,
            sampleRate: waveformMeta.sampleRate,
            duration:   waveformMeta.duration,
            peaks,
            filename:   audioFilename,
        });
        project.hasWaveform = !!(waveformUrl || peaks.length);

        // --- speakers ---
        for (const [id, spkMeta] of Object.entries(projectMeta.speakers)) {
            let sample = {};
            if (spkMeta.has_sample) {
                const sampleEntry = zip.file(`samples/${id}.wav`);
                if (sampleEntry) {
                    const sampleBuf = await sampleEntry.async('arraybuffer');
                    sample = await decodeSampleArrayBuffer(sampleBuf);
                }
            }
            project.addSpeaker(id, spkMeta.name, spkMeta.hue, sample);
        }

        // --- transcript.csv ---
        const transcriptEntry = zip.file('transcript.csv');
        if (transcriptEntry) {
            const csvText = await transcriptEntry.async('string');
            project.loadTranscriptCSV(csvText);
        }

        return project;
    }

}