workspace.js

import { SplitPopup } from "./components/split_popup.js"
import { SegmentContextMenu } from "./components/segment_context_menu.js"
import { ConfirmDialog } from "./components/confirm_dialog.js"
import { EmbedDialog } from "./components/embed_dialog.js"
import { LiveQuotesDialog } from "./components/live_quotes_dialog.js"
import { StartPage } from "./start_page.js"
import { SpeakersPanel } from "./workspace_panels/speakers_panel.js"
import { TranscriptPanel } from "./workspace_panels/transcript_panel.js"
import { WaveformPanel } from "./workspace_panels/waveform_panel.js"
import { LOCAL_MODE } from "./utilities/constants.js"
import { HistoryManager } from "./utilities/history_manager.js"


/**
 * Central workspace controller. Owns the three editing panels (speakers,
 * transcript, waveform) and mediates all cross-panel interactions through
 * callbacks. Manages the active project, selection state, context menus,
 * and the split popup.
 */
export class Workspace {
    speakersPanel;
    transcriptPanel;
    waveformPanel;

    /**
     * @param {object} callbacks - callback functions for workspace actions
     * @param {function} callbacks.onNewProject - called when the user requests a new project
     * @param {function} callbacks.onOpenProject - called when the user requests to open a local project
     * @param {function} callbacks.onUpload - called with the active project when the upload button is clicked
     * @param {function} callbacks.onProjectModified - called with the project when it becomes dirty and is a server project
     * @param {function} callbacks.isServerConnected - returns true if the server is currently connected
     * @param {function} [callbacks.onPresentation] - called when the user opens a project's presentation
     * @param {function} [callbacks.getToken] - async function returning the current auth token string, or null
     * @param {function} [callbacks.onRenderSidebar] - called when the sidebar should be re-rendered
     */
    constructor({ onNewProject, onOpenProject, onUpload, onProjectModified, isServerConnected, onPresentation, getToken, onRenderSidebar }) {
        this._onNewProject = onNewProject ?? (() => {});
        this._onOpenProject = onOpenProject ?? (() => {});
        this._onUpload = onUpload ?? (() => {});
        this._onProjectModified = onProjectModified ?? null;
        this._isServerConnected = isServerConnected ?? (() => false);
        this.getToken = getToken ?? (() => Promise.resolve(null));
        this._onRenderSidebar = onRenderSidebar ?? null;
        this._onPresentation = onPresentation ?? ((project) => {
            // Default: just open the presentation in a new tab if it's a server project
            if (project?.projectId) window.open(`/presentation/${project.projectId}`, '_blank');
        });
        // Get all the document elements as members
        this.#getElements();

        // set the workspace empty, as nothing has been loaded
        this.workspaceEmpty.style.display = "flex";
        this.workspaceApp.style.display = "none";

        // Initialize any primitive members
        this.#initializeMembers();
        // Initialize the workspace panels and their callback
        this.initializeWorkspacePanels();
        // Setup all event listeners
        this.#setupListeners();
    }

    /** Constructs the three workspace panels and wires their cross-panel callbacks. */
    initializeWorkspacePanels() {
        // Initialize speakers panel with callbacks
        this.speakersPanel = new SpeakersPanel(this, {
            onSpeakerHover: (speakerId) => {
                this.hoveredSpeakerId = speakerId
                this.waveformPanel.drawRegions();
            },
            onSpeakerModified: (speaker) => {
                this.waveformPanel.drawRegions();
//                this.transcriptPanel.renderTranscript();
            },
        });

        // Initialize transcript panel with callbacks
        this.transcriptPanel = new TranscriptPanel(this, {
            onSegmentHover: (segmentIdx) => {
                // Only update if its changed for efficiency
                if (this.hoveredSegmentIdx != segmentIdx) {
                    this.hoveredSegmentIdx = segmentIdx;
                    this.waveformPanel.setHoveredRegion(this.hoveredSegmentIdx);
                }
            },
            onSegmentSelect: (segmentIdx) => {
                // Only update if its changed for efficiency
                if (this.selectedSegmentIdx != segmentIdx) {
                    this.selectedSegmentIdx = segmentIdx;
                    this.waveformPanel.setSelectedRegion(this.selectedSegmentIdx);
                    if (this.splitPopup) this.closeSplitPopup();
                }
            },
            onSegmentZoom: (segmentIdx) => {
                this.waveformPanel.zoomToRegion(segmentIdx);
            },
            onSearchChanged: (matchSet) => {
                this.searchMatchSet = matchSet;
                this.waveformPanel.drawRegions();
            },
            onParagraphHover: (paragraph) => {
                this.hoveredParagraphIdx = paragraph
                    ? this.activeProject.transcript().paragraphs.indexOf(paragraph)
                    : -1;
                this.waveformPanel.drawRegions();
            },
            onParagraphZoom: (paragraph) => {
                const idx = this.activeProject.transcript().paragraphs.indexOf(paragraph);
                if (idx >= 0) this.waveformPanel.zoomToParagraph(idx);
            },
        });

        // Initialize waveform panel with callbacks
        this.waveformPanel = new WaveformPanel(this, {
            onRegionHover: (regionIdx) => {
                // Only update if its changed for efficiency
                if (this.hoveredSegmentIdx != regionIdx) {
                    this.hoveredSegmentIdx = regionIdx;
                    this.transcriptPanel.setHoveredSegment(this.hoveredSegmentIdx);
                }
            },
            onRegionSelect: (regionIdx) => {
                // Only update if its changed for efficiency
                if (this.selectedSegmentIdx != regionIdx) {
                    this.selectedSegmentIdx = regionIdx;
                    this.transcriptPanel.setSelectedSegment(this.selectedSegmentIdx)
                    if (this.splitPopup) this.closeSplitPopup();
                }
            },
            onRegionActivate: (regionIdx) => {
                // Only update if its changed for efficiency
                if (this.activeSegmentIdx != regionIdx) {
                    this.activeSegmentIdx = regionIdx;
                    this.transcriptPanel.setActiveSegment(this.activeSegmentIdx)
                }
            },
            onWordActivate: (time) => {
                this.transcriptPanel.setActiveWord(time);
            },
        });
    }

    /** Initializes all primitive instance variables to their default values. */
    #initializeMembers() {
        this.activeProject = null; // the currently loaded project
        this.history = new HistoryManager({ maxSize: parseInt(localStorage.getItem('undo-queue-size') || '100', 10) });

        this.isUploading = false;

        this.activeCtxMenu = null;
        this.ctxSpeakerListOpen = false;
        this.ctxMenuSegIdx = -1;
        // Handles splitting control flow, while the splitting popup itself is in its own class
        this.splitPopup = null;

        this.selectedSegmentIdx = -1, // index into cachedSegments; -1 = none
        this.hoveredSegmentIdx = -1, // index under mouse cursor in region lane or transcript
        this.activeSegmentIdx = -1, // index of the segment currently under the playhead
        this.hoveredSpeakerId = null;
        this.hoveredParagraphIdx = -1; // index into transcript().paragraphs whose handle is hovered

        this.searchMatchSet = null; // Set of matching segment indices when search is active, null otherwise

        // Resize handle variables
        this.vHandleDragging = false,
        this.vHandleStartX = 0,
        this.vHandleStartW = 0;

        this.hHandleDragging = false
        this.hHandleStartY = 0
        this.hHandleStartH = 0;
    }

    /** Binds DOM elements to instance properties. */
    #getElements() {
        this.root = document.getElementById('workspace');

        // build the start page
        this.startPage = new StartPage({ onNewProject: this._onNewProject, onOpenProject: this._onOpenProject });

        this.workspaceEmpty = this.root.querySelector("#workspaceEmpty");
        this.workspaceApp = this.root.querySelector("#workspaceApp");
        this.workspaceLoading = this.root.querySelector("#workspaceLoading");

        this.projectTitle = this.root.querySelector('#projectTitle');
        this.projectNameInput = this.root.querySelector('#projectNameInput');

        this.projectServerStatus = this.root.querySelector('#projectServerStatus');

        this.undoBtn = this.root.querySelector("#undoBtn");
        this.redoBtn = this.root.querySelector("#redoBtn");
        this.revertBtn = this.root.querySelector("#revertBtn");
        this.saveLocalBtn = this.root.querySelector("#saveLocalBtn");
        this.uploadServerBtn = this.root.querySelector("#uploadServerBtn");
        this.liveQuotesBtn  = this.root.querySelector("#liveQuotesBtn");
        this.presentationBtn = this.root.querySelector("#presentationBtn");

        // Workspace resize handles
        this.verticalHandle = this.root.querySelector('#handleV');
        this.horizontalHandle = this.root.querySelector('#handleH');
        this.leftCol = this.root.querySelector('#leftCol');
    }

    /** Sets up resize handle drag logic, keyboard shortcuts, and project title editing. */
    #setupListeners() {
        window.addEventListener('mousemove', (e) => {
          if (!this.vHandleDragging) return;
          const maxW = this.leftCol.parentElement.clientWidth - 374;
          const w = Math.max(500, Math.min(maxW, this.vHandleStartW + e.clientX - this.vHandleStartX));
          this.leftCol.style.flex = `0 0 ${w}px`;
          if (!this.vResizeRafPending) {
            this.vResizeRafPending = true;
            requestAnimationFrame(() => {
              this.vResizeRafPending = false;
              const scrollEl = this.waveformPanel.getScrollEl();
              const anchorFrac = scrollEl && scrollEl.scrollWidth > 0
                ? scrollEl.scrollLeft / scrollEl.scrollWidth
                : null;
              this.waveformPanel.resizeMinimap();
              this.waveformPanel.applyZoom(this.waveformPanel.currentZoomLevel, anchorFrac);
            });
          }
        });
        window.addEventListener('mouseup', () => {
          if (!this.vHandleDragging) return;
          this.vHandleDragging = false;
          this.verticalHandle.classList.remove('dragging');
          document.body.style.cursor = ''; document.body.style.userSelect = '';
        });

        window.addEventListener('mousemove', (e) => {
          if (!this.hHandleDragging) return;
          this.pendingHeight = this.hHandleStartH + e.clientY - this.hHandleStartY;
          if (!this.hResizeRafPending) {
            this.hResizeRafPending = true;
            requestAnimationFrame(() => {
              this.hResizeRafPending = false;
              this.waveformPanel.setWaveformHeight(this.pendingHeight);
            });
          }
        });
        window.addEventListener('mouseup', () => {
          if (!this.hHandleDragging) return;
          this.hHandleDragging = false;
          this.horizontalHandle.classList.remove('dragging');
          document.body.style.cursor = ''; document.body.style.userSelect = '';
        });

        // Keyboard shortcuts
        document.addEventListener('keydown', (e) => {
            // If the target is an input or selection dialog, don't handle the key press
            if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
            // If the target is an editable dialog, don't handle the keypress
            if (e.target.isContentEditable) return;

            // Undo / Redo
            if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
                e.preventDefault();
                this.history.undo().then(cmd => this._applyHistoryResult(cmd));
                return;
            }
            if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
                e.preventDefault();
                this.history.redo().then(cmd => this._applyHistoryResult(cmd));
                return;
            }

            // Tab / Shift+Tab — navigate segments and enter edit mode
            if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey) {
                const segs = this.activeProject?.transcript()?.segments;
                if (segs?.length) {
                    e.preventDefault();
                    const cur = this.selectedSegmentIdx;
                    let next;
                    if (cur < 0) {
                        next = e.shiftKey ? segs.length - 1 : 0;
                    } else {
                        next = e.shiftKey ? cur - 1 : cur + 1;
                    }
                    if (next >= 0 && next < segs.length) {
                        this.setSelectedSeg(next);
                        if (!this.isReadOnly()) {
                            this.transcriptPanel.makeSegmentEditable(next);
                        }
                    }
                }
                return;
            }

            // If escape is pressed, close any open dialogs and clear selection
            if (e.code === 'Escape') {
                this.closeCtxMenu();
                this.closeSplitPopup();
                this.setSelectedSeg(-1);
            }

            // Segment shortcuts — only when a segment is selected and no menu/popup open
            const hasSel = this.selectedSegmentIdx >= 0;
            // S — split (allowed even when a split popup is open; closes the existing one first)
            if (hasSel && !this.activeCtxMenu && e.code === 'KeyS' && !e.shiftKey && !this.isReadOnly()) {
              e.preventDefault();
              const seg = this.activeProject.transcript().segments[this.selectedSegmentIdx];
              if (seg && seg.text.trim().split(/\s+/).length > 1) {
                const anchor = document.querySelector(`.t-seg[data-idx="${this.selectedSegmentIdx}"]`);
                this.enterSplitMode(this.selectedSegmentIdx, anchor);
              }
            }
            if (hasSel && !this.activeCtxMenu && !this.splitPopup && !this.isReadOnly()) {
              // E — edit text
              if (e.code === 'KeyE') {
                e.preventDefault();
                this.transcriptPanel.makeSegmentEditable(this.selectedSegmentIdx);
              }
              // C — change speaker (open ctx menu at segment position)
              if (e.code === 'KeyC' && !e.ctrlKey && !e.metaKey) {
                e.preventDefault();
                const anchor = document.querySelector(`.t-seg[data-idx="${this.selectedSegmentIdx}"]`);
                if (anchor) {
                  const rect = anchor.getBoundingClientRect();
                  this.openSegmentCtxMenu(rect.left, rect.bottom + 4, this.selectedSegmentIdx);
                  // Auto-expand speaker list
                  requestAnimationFrame(() => {
                    const item = this.activeCtxMenu?.root?.querySelector('.ctx-item');
                    if (item) item.click();
                  });
                }
              }
              // Shift+A — merge with previous
              if (e.code === 'KeyA' && e.shiftKey) {
                e.preventDefault();
                const segs = this.activeProject.transcript().segments;
                const idx = this.selectedSegmentIdx;
                if (idx > 0 && segs[idx - 1].speaker === segs[idx].speaker) {
                  this._mergeWithHistory(idx - 1, idx);
                  this.setSelectedSeg(idx - 1);
                }
              }
              // Shift+D — merge with next
              if (e.code === 'KeyD' && e.shiftKey) {
                e.preventDefault();
                const segs = this.activeProject.transcript().segments;
                const idx = this.selectedSegmentIdx;
                if (idx < segs.length - 1 && segs[idx].speaker === segs[idx + 1].speaker) {
                  this._mergeWithHistory(idx, idx + 1);
                  this.setSelectedSeg(idx);
                }
              }
            }
        });

        this.projectTitle.addEventListener('click', () => {
            if (this.isReadOnly()) return;
            this.projectTitle.style.display = 'none';
            this.projectNameInput.style.display = '';
            this.projectNameInput.focus();
            this.projectNameInput.select();
        });
        this.projectNameInput.addEventListener('blur', () => this.commitProjectName());
        this.projectNameInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter')  { 
                e.preventDefault(); 
                this.projectNameInput.blur();
            }
            if (e.key === 'Escape') {
                this.projectNameInput.value = this.activeProject.projectName || 'Untitled Project';
                this.projectNameInput.blur();
            }
            e.stopPropagation();
        });

        this.undoBtn.addEventListener('click', () => {
            this.history.undo().then(cmd => this._applyHistoryResult(cmd));
        });
        this.redoBtn.addEventListener('click', () => {
            this.history.redo().then(cmd => this._applyHistoryResult(cmd));
        });

        this.revertBtn.addEventListener('click', () => {
            if (!this.activeProject?.isDirty()) return;
            new ConfirmDialog('Discard all changes since the last save?', {
                onConfirm: () => {
                    this.activeProject.revertToLastSave();
                    this.history.clear();
                    this._updateUndoRedoButtons();
                }
            });
        });
        this.saveLocalBtn.addEventListener('click', async () => { await this.saveLocalCopy(this.activeProject); });
        this.uploadServerBtn.addEventListener('click', () => {
            if (!this.uploadServerBtn.classList.contains('uploading')) {
                this._onUpload(this.activeProject);
            }
        });
        this.liveQuotesBtn.addEventListener('click', () => {
            new LiveQuotesDialog(this.activeProject.projectId, this.getToken);
        });
        this.presentationBtn.addEventListener('click', () => {
            this._onPresentation(this.activeProject);
        });

        // Workspace resize handles
        this.verticalHandle.addEventListener('mousedown', (e) => {
            this.vHandleDragging = true;
            this.vHandleStartX = e.clientX;
            this.vHandleStartW = this.leftCol.getBoundingClientRect().width;
            this.verticalHandle.classList.add('dragging');
            document.body.style.cssText += ';cursor:col-resize;user-select:none';
        });
        
        this.horizontalHandle.addEventListener('mousedown', (e) => {
            this.hHandleDragging = true;
            this.hHandleStartY = e.clientY;
            this.hHandleStartH = this.waveformPanel.waveformHeightPx;
            this.horizontalHandle.classList.add('dragging');
            document.body.style.cssText += ';cursor:row-resize;user-select:none';
        });
    }

    /**
    * Loads project data from the server uplink and populates the workspace
    * @param {object} project - the project to be loaded
    */
    async loadProject(project) {
        this.activeProject = project;

        // Show loading overlay while pulling data; keep workspace panels hidden
        this.workspaceEmpty.style.display = "none";
        this.workspaceLoading.style.display = "flex";
        this.workspaceApp.style.display = "none";

        try {
            // If its a not a local only server, pull it from the server
            if (!this.activeProject.localOnly) {
                // Reset any stale callbacks before pulling — server project objects are reused
                // across sessions, so old callbacks may still be registered from a prior load.
                // The real callbacks are re-registered below after the pull completes.
                project.registerModifyCallbacks({});
                await project.pullFromServer();
            }
        } catch(e) {
            // On failure, go back to the empty state and re-throw
            this.workspaceLoading.style.display = "none";
            this.workspaceEmpty.style.display = "flex";
            this.activeProject = null;
            throw e;
        }

        // Hide the loading overlay and reveal the workspace
        this.workspaceLoading.style.display = "none";
        this.workspaceApp.style.display = "flex";

        // Set project name label
        this.projectNameInput.value = this.activeProject.projectName;
        this.projectTitle.textContent = this.activeProject.projectName;
        this.projectTitle.title = this.isReadOnly() ? '' : 'Click to rename';

        // Load the project into the three workspace panels
        this.speakersPanel.loadFromProject(project);
        this.waveformPanel.loadFromProject(project);
        this.transcriptPanel.loadFromProject(project);

        // Mark the project to all clean
        this.activeProject.markClean();

        // Clear undo/redo history when a new project is loaded
        this.history.clear();
        this._updateUndoRedoButtons();

        this.updateProjectServerStatus();

        this.activeProject.registerModifyCallbacks({
            onSpeakersModified: () => {
                if(this.activeProject.hasTranscript && this.activeProject.transcript()) {
                    this.transcriptPanel.renderTranscript();
                }
                if(this.activeProject.hasWaveform) { this.waveformPanel.drawRegions(); }
                if(this.activeProject.hasSpeakers) { this.speakersPanel.renderSpeakersPanel(); }

                this.updateProjectServerStatus();
            },
            onTranscriptModified: () => {
                if(this.activeProject.hasTranscript) { this.transcriptPanel.renderTranscript(); }
                if(this.activeProject.hasWaveform) { this.waveformPanel.drawRegions(); }
                this.updateProjectServerStatus();
            },
            onWaveformModified: () => {
                this.waveformPanel.loadWaveform()
                if(this.activeProject.hasTranscript) { this.waveformPanel.drawRegions(); }
                this.updateProjectServerStatus();
            },
            onSpeakersSaved: () => {
                this.updateProjectServerStatus();
            },
            onTranscriptSaved: () => {
                this.updateProjectServerStatus();
            },
            onWaveformSaved: () => {
                this.updateProjectServerStatus();
            },
        });
    }

    /**
    * Unloads the currently active project, alerting user to unsaved changes
    */
    unloadActiveProject() {
        this.updateProjectServerStatus();
    }

    /**
     * Prompts the user for a save location and packages the project as a .wfs archive.
     * @param {object} project - the project to save
     * @returns {Promise<FileSystemFileHandle|undefined>} the file handle on success, or undefined if cancelled
     */
    async saveLocalCopy(project) {
        const suggestedName = `${project.projectName.replace(/[^a-z0-9_\-]/gi, '_')}.wfs`;
        try {
            const fileHandle = await window.showSaveFilePicker({
                suggestedName,
                types: [{
                    description: 'Workflow Save File',
                    accept: { 'application/octet-stream': ['.wfs'] },
                }],
            });
            await project.packageProject(fileHandle);
            project.markClean();
            this.updateProjectServerStatus();
            return fileHandle;

        } catch (err) {
            if (err.name !== 'AbortError') {
                console.error('Save failed:', err);
            }
        }
    }

    // ── Project name editing ───────────────────────────────────────────────────

    /**
    * Commits the project name from the inline input back to the title span,
    * updates the active project's name in state, and refreshes the sidebar.
    * Called on blur and Enter keydown of the title input.
    */
    commitProjectName() {
        const newName = this.projectNameInput.value.trim() || 'Untitled Project';
        this.projectNameInput.value = newName;
        this.projectTitle.textContent = newName;
        this.projectTitle.style.display = '';
        this.projectNameInput.style.display = 'none';
        if (!this.activeProject.projectId) {
            return;
        }
        const oldName = this.activeProject.projectName;
        this.activeProject.setName(newName);
        if (oldName !== newName) {
            this.history.push({
                label: 'Rename project', dirtyFlags: ['projectName'],
                undo: () => { this.activeProject.setName(oldName); },
                redo: () => { this.activeProject.setName(newName); },
            });
            this._updateUndoRedoButtons();
        }
    }


    // ── Access mode ───────────────────────────────────────────────────────────

    /**
     * Returns true if the active project is in read-only mode.
     * @returns {boolean}
     */
    isReadOnly() {
        return this.activeProject?.readOnly ?? false;
    }

    // ── Selection ─────────────────────────────────────────────────────────────

    /**
    * Sets the selected segment, syncing workspace state, transcript panel, and waveform panel.
    * @param {number} idx - segment index, or -1 to clear selection
    */
    setSelectedSeg(idx) {
        this.selectedSegmentIdx = idx;
        this.transcriptPanel.setSelectedSegment(idx);
        this.waveformPanel.setSelectedRegion(idx);
    }

    // ── Split segment popup ───────────────────────────────────────────────────

    /**
     * Returns true if a split popup is currently active.
     * @returns {boolean}
     */
    isSplitting() { return this.splitPopup !== null; }

    /**
    * Close the active split popup if there is one
    */
    closeSplitPopup() {
        if (this.splitPopup) {
            this.splitPopup.destroy();
            this.splitPopup = null;
            this.waveformPanel.drawRegions();
        }
    }

    /**
    * Opens the split popup for the given segment, closing any existing one first.
    * @param {number} segIdx - index of the segment to split
    * @param {HTMLElement} anchorEl - element to anchor the popup near
    */
    enterSplitMode(segIdx, anchorEl) {
        // If there is already a split popup, close the current one
        if (this.splitPopup) {
            this.closeSplitPopup();
        }
        // Open a new SplitPopup
        this.splitPopup = new SplitPopup(segIdx, anchorEl, this.activeProject, this, {
            onSplit: (newSegmentA, newSegmentB) => {
                const project = this.activeProject;
                const transcript = project.transcript();
                const originalSeg = { ...transcript.segments[segIdx] };
                const savedAnnotations = JSON.parse(JSON.stringify(project.annotations ?? {}));
                project.splitSegment(segIdx, newSegmentA, newSegmentB);
                const segA = { ...transcript.segments[segIdx] };
                const segB = { ...transcript.segments[segIdx + 1] };
                this.history.push({
                    label: 'Split segment', dirtyFlags: ['transcript'],
                    undo: () => {
                        transcript.segments.splice(segIdx, 2, originalSeg);
                        transcript.buildTranscript();
                        project.annotations = savedAnnotations;
                    },
                    redo: () => {
                        transcript.segments.splice(segIdx, 1, { ...segA }, { ...segB });
                        transcript.buildTranscript();
                    },
                });
                this._updateUndoRedoButtons();
                this.splitPopup = null;
            },

            onCancel: () => {
                this.splitPopup = null;
                this.waveformPanel.drawRegions();
            }
        });
    }


    // ── Segment context menu ──────────────────────────────────────────────────────────

    /**
     * Returns true if there is a segment before segIdx.
     * @param {number} segIdx - the segment index to check
     * @returns {boolean}
     */
    hasPrev(segIdx) { return segIdx > 0; }
    /**
     * Returns true if there is a segment after segIdx.
     * @param {number} segIdx - the segment index to check
     * @returns {boolean}
     */
    hasNext(segIdx) { return segIdx < this.activeProject.transcript().segments.length - 1; }

    /**
     * Returns true when the app is running in local (desktop) mode.
     * @returns {boolean}
     */
    isLocalMode() {
        return LOCAL_MODE;
    }

    /**
     * Opens the EmbedDialog for the given text selection target.
     * @param {object} selTarget - selection target from TranscriptPanel#getNativeSelection()
     */
    openEmbedDialog(selTarget) {
        new EmbedDialog(selTarget, this.activeProject, { getToken: this.getToken, wavesurfer: this.wavesurferInstance() });
    }

    /**
    * Closes the active context menu
    */
    closeCtxMenu() {
        if (this.activeCtxMenu) {
            this.activeCtxMenu.close();
            this.activeCtxMenu = null;
        }
    }

    /**
    * Opens a SegmentContextMenu for the given segment index.
    * @param {number} x - left position in viewport pixels
    * @param {number} y - top position in viewport pixels
    * @param {number} segmentIndex - index into the active project's transcript segments
    * @param {Element|{left:number,top:number,bottom:number}|null} splitAnchor - anchor for the split popup; defaults to the transcript segment element
    * @param {object|null} linkInfo - when opening from a link span, provides onEdit/onRemove/onCopy callbacks; null otherwise
    */
    openSegmentCtxMenu(x, y, segmentIndex, splitAnchor = null, linkInfo = null) {
        // If there is already a context menu open, close it
        this.closeCtxMenu();
        this.ctxMenuSegIdx = segmentIndex;

        const transcript = this.activeProject.transcript();
        const segments = transcript.segments;
        const segment = segments[segmentIndex];
        const hasPrev = segmentIndex > 0 && segments[segmentIndex - 1].speaker === segment.speaker;
        const hasNext = segmentIndex < segments.length - 1 && segments[segmentIndex + 1].speaker === segment.speaker;

        const wordCount = segment?.text.trim().split(/\s+/).length; // number of words in the segment text

        // Paragraph split/merge availability
        const paraIdx = transcript.paragraphs.findIndex(p => p.segments.includes(segment));
        const para = transcript.paragraphs[paraIdx];
        const prevPara = paraIdx > 0 ? transcript.paragraphs[paraIdx - 1] : null;
        const isFirstInPara = para && para.segments[0] === segment;
        const canSplitParagraph = segmentIndex > 0 && !isFirstInPara;
        const canMergeParagraphPrev = !!(prevPara && prevPara.speaker === para?.speaker);

        // Create a new context menu with its callbacks
        this.activeCtxMenu = new SegmentContextMenu(x, y, segmentIndex, this.activeProject, {
            readOnly: this.isReadOnly(),
            // these callbacks are never null
            onChangeSpeaker: (speakerId) => {
                const segs = this.activeProject.transcript().segments;
                const oldSpeakerId = segs[segmentIndex].speaker;
                this.activeProject.changeSpeaker(segmentIndex, speakerId);
                this.history.push({
                    label: 'Change segment speaker', dirtyFlags: ['transcript'],
                    undo: () => {
                        this.activeProject.transcript().changeSpeaker(segmentIndex, oldSpeakerId);
                    },
                    redo: () => {
                        this.activeProject.transcript().changeSpeaker(segmentIndex, speakerId);
                    },
                });
                this._updateUndoRedoButtons();
                this.closeCtxMenu();
            },
            onEdit: () => {
                this.closeCtxMenu();
                this.transcriptPanel.makeSegmentEditable(segmentIndex);
            },
            onDismiss: () => { this.closeCtxMenu(); },
            onEditLink:   linkInfo?.onEdit   ?? null,
            onRemoveLink: linkInfo?.onRemove ?? null,
            onCopyLink:   linkInfo?.onCopy   ?? null,
            onAddLink: this.isReadOnly() ? null : () => {
                this.closeCtxMenu();
                this.transcriptPanel.openAddLinkDialog(segmentIndex);
            },

            // if wordCount is less than one, function is nullified
            onSplit: wordCount > 1 ? () => {
              const anchor = splitAnchor ?? document.querySelector(`.t-seg[data-idx="${segmentIndex}"]`);
              this.closeCtxMenu();
              this.enterSplitMode(segmentIndex, anchor);
            } : null,
            // if segment has no previous, function is nullified
            onMergePrev: hasPrev ? () => {
                this.closeCtxMenu();
                this._mergeWithHistory(segmentIndex - 1, segmentIndex);
            } : null,
            // if segment has no next, function is nullified
            onMergeNext: hasNext ? () => {
                this.closeCtxMenu();
                this._mergeWithHistory(segmentIndex, segmentIndex + 1);
            } : null,
            onSplitParagraph: canSplitParagraph ? () => {
                this.closeCtxMenu();
                this._splitParagraphWithHistory(segmentIndex);
            } : null,
            onMergeParagraphPrev: canMergeParagraphPrev ? () => {
                this.closeCtxMenu();
                this._mergeParagraphWithHistory(segmentIndex);
            } : null,
            onZoom: () => {
                this.closeCtxMenu();
                this.waveformPanel.zoomToRegion(segmentIndex);
                this.waveformPanel.setSelectedRegion(segmentIndex);
            },
            onRetranscribe: segment.wordsStale ? () => {
                this.closeCtxMenu();
                this.transcriptPanel.retranscribeSegments([segmentIndex]);
            } : null,
        });
    }

    /**
    * Shows or hides the upload spinner on the workspace header upload button.
    * @param {boolean} busy - true to show spinner, false to restore the icon
    */
    setUploadBusy(busy) {
        this.isUploading = busy;
        this.uploadServerBtn.classList.toggle('uploading', busy);
        this.uploadServerBtn.disabled = busy;
        this.updateProjectServerStatus();
    }

    /**
    * Updates the badge and optional push button in the project title bar
    * to reflect whether the active project is local-only, synced, or has
    * unsynced changes.
    */
    updateProjectServerStatus() {
        const el = this.projectServerStatus;

        if (!el) {
            return;
        }

        el.innerHTML = '';

        // If there is no active project, don't display the badges
        if (!this.activeProject) {
            this.revertBtn.style.display = 'none';
            this.saveLocalBtn.classList.remove('active');
            this.uploadServerBtn.classList.remove('active');
            this.liveQuotesBtn.style.display  = 'none';
            this.presentationBtn.style.display = 'none';
            return;
        }

//        const connected = AppState.server.isConnected;

        const localOnlyBadge = document.createElement('span');
        localOnlyBadge.className = 'project-cloud-badge';

        const projectDirtyBadge = document.createElement('span');
        projectDirtyBadge.className = 'project-cloud-badge';

        const connected = this._isServerConnected();

        // set the local only / server badge
        if (this.activeProject.localOnly) {
            localOnlyBadge.className += ' modified';
            localOnlyBadge.textContent = 'offline';
            localOnlyBadge.title = 'Project not pushed to a server';
        } else if (!connected) {
            localOnlyBadge.className += ' server';
            localOnlyBadge.textContent = 'on server ';
            localOnlyBadge.title = 'Project has been pushed to a server';
        }
        // When connected and on server, omit the "on server" badge — it's implied

        // set the save status badge
        if (this.isUploading) {
            projectDirtyBadge.className += ' uploading';
            projectDirtyBadge.textContent = 'uploading';
            projectDirtyBadge.title = 'Uploading to server…';
        } else if (this.activeProject.localOnly) {
            if (this.activeProject.isDirty()) {
                projectDirtyBadge.className += ' modified';
                projectDirtyBadge.textContent = 'unsaved';
                projectDirtyBadge.title = 'Project has unsaved changes';
            } else {
                projectDirtyBadge.className += ' server';
                projectDirtyBadge.textContent = 'saved  ';
                projectDirtyBadge.title = 'Project has been saved locally';
            }
        } else {
            if (this.activeProject.isDirty()) {
                projectDirtyBadge.className += ' modified';
                projectDirtyBadge.textContent = 'unsaved';
                projectDirtyBadge.title = 'Project has unsaved changes';
            } else {
                projectDirtyBadge.className += ' server';
                projectDirtyBadge.textContent = 'saved  ';
                projectDirtyBadge.title = 'Project has been saved to the server';
            }
        }

        // Trigger auto-save when a server project becomes dirty (skip while an upload is already running)
        if (this.activeProject.isDirty() && !this.activeProject.localOnly && !this.isUploading) {
            this._onProjectModified?.(this.activeProject);
        }

        el.appendChild(projectDirtyBadge);
        if (!LOCAL_MODE && (this.activeProject.localOnly || !connected)) {
            el.appendChild(localOnlyBadge);
        }

        // Access mode badge
        const modeBadge = document.createElement('span');
        modeBadge.className = 'project-cloud-badge';
        const accessLevel = this.activeProject.accessLevel;
        if (accessLevel === 'viewer') {
            modeBadge.textContent = 'Read Only';
            modeBadge.title = 'You have read-only access to this project';
            modeBadge.classList.add('mode-readonly');
        } else if (accessLevel === 'editor') {
            modeBadge.textContent = 'Edit';
            modeBadge.title = 'You have edit access to this project';
            modeBadge.classList.add('mode-editor');
        } else {
            modeBadge.textContent = 'Owner';
            modeBadge.title = 'You own this project';
            modeBadge.classList.add('mode-owner');
        }
        el.appendChild(modeBadge);

        // Highlight download button when local project has unsaved changes
        const localDirty = this.activeProject.localOnly && this.activeProject.isDirty();
        this.saveLocalBtn.classList.toggle('active', localDirty);

        // Highlight upload button when server project has unsynced changes
        const serverDirty = !this.activeProject.localOnly && this.activeProject.isDirty();
        this.uploadServerBtn.classList.toggle('active', serverDirty);

        // Show revert button when there are unsaved changes but not while uploading
        this.revertBtn.style.display = (this.activeProject.isDirty() && !this.isUploading) ? '' : 'none';

        // Show live quotes and presentation buttons for server projects only
        const showServerBtns = !this.activeProject.localOnly && !LOCAL_MODE;
        this.liveQuotesBtn.style.display  = showServerBtns ? '' : 'none';
        this.presentationBtn.style.display = showServerBtns ? '' : 'none';
    }

    // ── Undo / Redo ───────────────────────────────────────────────────────────

    /**
     * Fires re-render callbacks based on the command's dirtyFlags after undo/redo.
     * @param {object|null} command - the command returned by history.undo() or history.redo()
     */
    _applyHistoryResult(command) {
        if (!command) return;
        if (command.dirtyFlags.includes('transcript'))
            this.activeProject.markTranscriptDirty();
        if (command.dirtyFlags.includes('speakers'))
            this.activeProject.markSpeakersDirty();
        if (command.dirtyFlags.includes('annotations'))
            this.transcriptPanel.renderTranscript();
        if (command.dirtyFlags.includes('projectName')) {
            this.projectTitle.textContent = this.activeProject.projectName;
            this.projectNameInput.value = this.activeProject.projectName;
            this._onRenderSidebar?.();
        }
        if (command.dirtyFlags.includes('projectFolder'))
            this._onRenderSidebar?.();
        this._updateUndoRedoButtons();
    }

    /** Updates the enabled/disabled state of the undo and redo buttons. */
    _updateUndoRedoButtons() {
        this.undoBtn.disabled = !this.history.canUndo;
        this.redoBtn.disabled = !this.history.canRedo;
    }

    /**
     * Merges two adjacent segments and pushes an undo/redo command to history.
     * Use this instead of calling activeProject.mergeSegments() directly.
     * @param {number} idxA - index of the earlier segment
     * @param {number} idxB - index of the later segment (must equal idxA + 1)
     */
    _mergeWithHistory(idxA, idxB) {
        const project = this.activeProject;
        const transcript = project.transcript();
        const segs = transcript.segments;
        const segA = { ...segs[idxA] };
        const segB = { ...segs[idxB] };
        const savedAnnotations = JSON.parse(JSON.stringify(project.annotations ?? {}));
        project.mergeSegments(idxA, idxB);
        this.history.push({
            label: 'Merge segments', dirtyFlags: ['transcript'],
            undo: () => {
                transcript.segments.splice(idxA, 1, { ...segA }, { ...segB });
                transcript.buildTranscript();
                project.annotations = savedAnnotations;
            },
            redo: () => { project.mergeSegments(idxA, idxB); },
        });
        this._updateUndoRedoButtons();
    }

    /**
    * Inserts a paragraph break before the segment at segIdx and pushes an undo/redo command.
    * @param {number} segIdx - index of the segment that will start the new paragraph
    */
    _splitParagraphWithHistory(segIdx) {
        const transcript = this.activeProject.transcript();
        const prevFlag = transcript.splitParagraphAt(segIdx);
        if (prevFlag === null) return;
        this.activeProject.markTranscriptDirty();
        this.history.push({
            label: 'Split paragraph', dirtyFlags: ['transcript'],
            undo: () => { transcript.restoreParaBreak(segIdx, prevFlag); },
            redo: () => { transcript.splitParagraphAt(segIdx); },
        });
        this._updateUndoRedoButtons();
    }

    /**
    * Merges the paragraph containing segIdx with the previous paragraph and pushes undo/redo.
    * @param {number} segIdx - index of any segment in the paragraph to merge upward
    */
    _mergeParagraphWithHistory(segIdx) {
        const transcript = this.activeProject.transcript();
        const result = transcript.mergeParagraphBefore(segIdx);
        if (!result) return;
        this.activeProject.markTranscriptDirty();
        const { segIdx: firstSegIdx, prevFlag } = result;
        this.history.push({
            label: 'Merge paragraph', dirtyFlags: ['transcript'],
            undo: () => { transcript.restoreParaBreak(firstSegIdx, prevFlag); },
            redo: () => { transcript.mergeParagraphBefore(firstSegIdx); },
        });
        this._updateUndoRedoButtons();
    }

    /**
    * Tears down the current WaveSurfer instance and clears all audio, transcript,
    * speaker, and UI state back to their initial values. Called when switching
    * projects or when a project is deleted/closed.
    */
    clearWorkspace() {
        this.activeProject?.registerModifyCallbacks({});
        this.waveformPanel.clearWaveformPanel();
        this.transcriptPanel.clearTranscriptPanel();
        this.speakersPanel.clearSpeakersPanel();
        this.leftCol.style.flex = '';

        this.selectedSegmentIdx = -1;
        this.hoveredSegmentIdx  = -1;
        this.activeSegmentIdx   = -1;
        this.activeProject = null;
        this.searchMatchSet = null;
        this.hoveredParagraphIdx = -1;

        this.history.clear();
        this._updateUndoRedoButtons();

        this.workspaceLoading.style.display = "none";
        this.workspaceApp.style.display = "none";
        this.workspaceEmpty.style.display = "flex";

        this.updateProjectServerStatus();
    }

    /**
     * Returns the WaveSurfer instance from the waveform panel.
     * @returns {object} the WaveSurfer instance
     */
    wavesurferInstance() {
        return this.waveformPanel.wavesurferInstance;
    }

}