server.js


import { _fetch,
         _getProjectList, _getSharedProjects, _getProject, _createProject, _deleteProject, _duplicateProject, _updateProject,
         _getWaveform, _getSpeakers, _getSpeakerSample, _getTranscript,
         _listDirectory, _createFolder, _renameFolder, _deleteFolder, _moveProjectToFolder, _getFolderCount,
         _checkAudioReady, _transcribeProject, _retranscribeSegments, _deleteTranscript, _deleteAudio, _getUserMe,
         _getSharedFolders, _saveWaveform, _getAnnotations, _saveAnnotations,
         _getCompressInfo, _compressProject,
} from "./utilities/server_access.js"
import { firebaseAuth, signOut } from "./firebase.js"

/**
 * Manages the connection to the backend server, including Firebase-based
 * authentication and all server-side data operations.
 */
export class Server {
    /**
     * @param {object} callbacks - callback functions for server connection events
     * @param {function} callbacks.onStatusChanged - Fired whenever `serverStatus` changes.
     * @param {function} callbacks.onConnect - Fired after a successful login.
     * @param {function} callbacks.onDisconnect - Fired after a successful logout.
     */
    constructor({ onStatusChanged, onConnect, onDisconnect }) {
        this.isConnected = false;   // true after successful login
        this.baseUrl = null;        // e.g. "http://localhost:5000"
        this.serverStatus = "Disconnected";

        /** Firebase user object — set when signed in via Firebase Auth.
         *  @type {object|null}
         */
        this.firebaseUser = null;

        /** Backend user profile returned by /api/users/me.
         *  @type {object|null}
         */
        this.backendUser = null;

        /** Cached Firebase ID token — refreshed on each getToken() call */
        this._cachedToken = null;

        this._onStatusChanged = onStatusChanged ?? (() => {})
        this._onConnect = onConnect ?? (() => {})
        this._onDisconnect = onDisconnect ?? (() => {})
    }

    // ── Auth ────────────────────────────────────────────────────────────────

    /**
     * Returns a fresh Firebase ID token (auto-refreshes if near expiry).
     * @returns {Promise<string|null>}
     */
    async getToken() {
        if (!this.firebaseUser) return null;
        this._cachedToken = await this.firebaseUser.getIdToken();
        return this._cachedToken;
    }

    /**
     * Connects to the server using a Firebase-authenticated user.
     * Gets a fresh ID token, verifies backend access via /api/users/me,
     * then fires onConnect.
     * @param {string} serverUrl - Base URL of the server (trailing slash is stripped).
     * @param {object} firebaseUser - Signed-in Firebase user.
     * @returns {Promise<void>}
     */
    async connectWithFirebaseUser(serverUrl, firebaseUser) {
        const serverUrlFixed = serverUrl.replace(/\/$/, '');

        let token;
        try {
            token = await firebaseUser.getIdToken();
        } catch (e) {
            this.serverStatus = e.message || 'Failed to get auth token';
            this._onStatusChanged();
            return;
        }

        // Verify backend access
        let backendUser;
        try {
            backendUser = await _getUserMe(serverUrlFixed, token);
        } catch (e) {
            this.serverStatus = e.message || 'Server rejected the login';
            this._onStatusChanged();
            throw e;
        }

        this.firebaseUser = firebaseUser;
        this._cachedToken = token;
        this.backendUser = backendUser;
        this.isConnected = true;
        this.baseUrl = serverUrlFixed;
        this.serverStatus = 'Connected';
        this._onConnect();
        this._onStatusChanged();
    }

    /**
     * Signs out of Firebase and disconnects from the server.
     * @returns {Promise<void>}
     */
    /**
     * Connects to the local embedded server without Firebase authentication.
     * Used in LOCAL_MODE where the backend accepts requests with no token.
     * @returns {Promise<void>}
     */
    async connectLocal() {
        const baseUrl = window.location.origin;
        let backendUser;
        const delays = [500, 1000, 2000, 4000, 8000];
        for (let attempt = 0; attempt <= delays.length; attempt++) {
            try {
                backendUser = await _getUserMe(baseUrl, null);
                break;
            } catch (e) {
                if (attempt === delays.length) {
                    this.serverStatus = e.message || 'Failed to connect to local server';
                    this._onStatusChanged();
                    return;
                }
                this.serverStatus = 'Reconnecting…';
                this._onStatusChanged();
                await new Promise(r => setTimeout(r, delays[attempt]));
            }
        }
        this.firebaseUser = null;
        this._cachedToken = null;
        this.backendUser = backendUser;
        this.isConnected = true;
        this.baseUrl = baseUrl;
        this.serverStatus = 'Connected';
        this._onConnect();
        this._onStatusChanged();
    }

    /** Signs out of Firebase and resets all connection state. */
    async disconnectFromServer() {
        if (firebaseAuth) {
            try {
                await signOut(firebaseAuth);
            } catch (e) {
                console.error('Firebase sign-out error:', e);
            }
        }
        this.firebaseUser = null;
        this._cachedToken = null;
        this.backendUser = null;
        this.isConnected = false;
        this.baseUrl = null;
        this.serverStatus = 'Disconnected';
        this._onDisconnect();
        this._onStatusChanged();
    }

    // ----- SERVER PROJECT LIFECYCLE ----- //

    /**
     * Retrieves the list of all projects from the server.
     * @returns {Promise<object[]>} Array of project metadata objects.
     */
    async getProjectList() {
        return await _getProjectList(this.baseUrl, await this.getToken());
    }

    /**
     * Retrieves projects shared with the current user.
     * @returns {Promise<object[]>} Array of shared project metadata objects.
     */
    async getSharedProjects() {
        return await _getSharedProjects(this.baseUrl, await this.getToken());
    }

    /**
     * Retrieves folders shared with the current user.
     * @returns {Promise<object[]>} Array of shared folder metadata objects.
     */
    async getSharedFolders() {
        return await _getSharedFolders(this.baseUrl, await this.getToken());
    }

    /**
     * Retrieves a single project by ID.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<object>} The project object.
     */
    async getProject(projectId) {
        return await _getProject(this.baseUrl, await this.getToken(), projectId);
    }

    /**
     * Deletes a project by ID.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<boolean>} True if deletion was successful.
     */
    async deleteProject(projectId) {
        return await _deleteProject(this.baseUrl, await this.getToken(), projectId)
    }

    /**
     * Returns storage size info for a project before and after compression.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<object>} { can_compress, before_bytes?, after_bytes?, audio_format?, reason? }
     */
    async getCompressInfo(projectId) {
        return await _getCompressInfo(this.baseUrl, await this.getToken(), projectId);
    }

    /**
     * Compresses a project by deleting its original non-MP3 audio file.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<object>} { ok, audio_format }
     */
    async compressProject(projectId) {
        return await _compressProject(this.baseUrl, await this.getToken(), projectId);
    }

    /**
     * Duplicates a project on the server and returns the new project object.
     * @param {string} projectId - The project's unique identifier.
     * @param {string} [folderPath] - Destination folder path for the duplicate.
     * @returns {Promise<object>} The newly created duplicate project object.
     */
    async duplicateProject(projectId, folderPath) {
        return await _duplicateProject(this.baseUrl, await this.getToken(), projectId, folderPath);
    }

    /**
     * Creates a new project on the server.
     * @param {object} metadata - Project metadata (name, description, etc.).
     * @param {object} waveformData - Pre-computed waveform data.
     * @param {File|null} audioFile - The audio file to upload, or null to create an empty project.
     * @param {File} transcriptFile - The transcript CSV file to upload.
     * @param {File[]} sampleFiles - Speaker voice sample files to upload.
     * @param {function(number):void} [onProgress] - upload progress callback, fraction 0–1.
     * @param {AbortSignal} [signal] - Optional signal to abort the request.
     * @returns {Promise<object>} The newly created project object.
     */
    async createProject(metadata, waveformData, audioFile, transcriptFile, sampleFiles, onProgress, signal) {
        const data = {waveformData, audioFile, transcriptFile, sampleFiles, onProgress, signal};
        return await _createProject(this.baseUrl, await this.getToken(), metadata, data);
    }

    /**
     * Updates an existing project on the server.
     * @param {string} projectId - The project's unique identifier.
     * @param {object} metadata - Updated project metadata (name, created, modified).
     * @param {object|null} speakersData - Full speakers dict if speakers section is dirty, or null.
     * @param {File|null} audioFile - Replacement audio file, or null to leave unchanged.
     * @param {File|null} transcriptFile - Replacement transcript file, or null to leave unchanged.
     * @param {Object.<string, File>} sampleFiles - Replacement sample files keyed by speaker id.
     * @param {function(number):void} [onProgress] - upload progress callback, fraction 0–1.
     * @param {AbortSignal} [signal] - Optional signal to abort the request.
     * @returns {Promise<object>} The updated project object.
     */
    async updateProject(projectId, metadata, speakersData, audioFile, transcriptFile, sampleFiles, onProgress, signal) {
        const data = {speakersData, audioFile, transcriptFile, sampleFiles, onProgress, signal};
        return await _updateProject(this.baseUrl, await this.getToken(), projectId, metadata, data);
    }

    // ----- DATA ACCESS ----- //

    /**
     * Retrieves the waveform data for a project.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<object>} The waveform data object.
     */
    async getWaveform(projectId) {
        return await _getWaveform(this.baseUrl, await this.getToken(), projectId);
    }

    /**
     * Saves waveform metadata (peaks, duration, sampleRate) to the server.
     * @param {string} projectId - The project's unique identifier.
     * @param {{peaks: number[], duration: number, sampleRate: number}} waveformData - Waveform peaks and audio metadata.
     * @returns {Promise<void>}
     */
    async saveWaveform(projectId, waveformData) {
        await _saveWaveform(this.baseUrl, await this.getToken(), projectId, waveformData);
    }

    /**
     * Retrieves the speakers dictionary for a project.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<object>} Dictionary of speaker objects keyed by speaker id.
     */
    async getSpeakers(projectId) {
        return await _getSpeakers(this.baseUrl, await this.getToken(), projectId);
    }

    /**
     * Retrieves a speaker's voice sample as an ArrayBuffer.
     * @param {string} projectId - The project's unique identifier.
     * @param {string} speakerId - The speaker's unique identifier.
     * @returns {Promise<ArrayBuffer>} The raw audio data.
     */
    async getSpeakerSample(projectId, speakerId) {
        return await _getSpeakerSample(this.baseUrl, await this.getToken(), projectId, speakerId);
    }

    /**
     * Retrieves the transcript for a project.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<{contentType: string, text: string}>} The content type and raw text body.
     */
    async getTranscript(projectId) {
        return await _getTranscript(this.baseUrl, await this.getToken(), projectId);
    }

    /**
     * Retrieves the annotations (hyperlinks, etc.) for a project.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<object>} Annotations object with a 'hyperlinks' key.
     */
    async getAnnotations(projectId) {
        return await _getAnnotations(this.baseUrl, await this.getToken(), projectId);
    }

    /**
     * Saves annotations (hyperlinks, etc.) for a project.
     * @param {string} projectId - The project's unique identifier.
     * @param {object} data - Annotations object with a 'hyperlinks' key.
     * @returns {Promise<void>}
     */
    async saveAnnotations(projectId, data) {
        await _saveAnnotations(this.baseUrl, await this.getToken(), projectId, data);
    }

    // ----- FOLDER OPERATIONS ----- //

    /**
     * Lists the immediate contents of a server folder.
     * @param {string} folderPath - relative folder path ('' for root)
     * @returns {Promise<{folders: object[], projects: object[]}>}
     */
    async listDirectory(folderPath) {
        return await _listDirectory(this.baseUrl, await this.getToken(), folderPath);
    }

    /**
     * Creates a new folder on the server.
     * @param {string} parentPath - parent folder path ('' for root)
     * @param {string} name - name for the new folder
     * @returns {Promise<{name: string, path: string}>}
     */
    async createFolder(parentPath, name) {
        return await _createFolder(this.baseUrl, await this.getToken(), parentPath, name);
    }

    /**
     * Renames a folder on the server.
     * @param {string} folderPath - current path of the folder
     * @param {string} newName - new name
     * @returns {Promise<{name: string, path: string}>}
     */
    async renameFolder(folderPath, newName) {
        return await _renameFolder(this.baseUrl, await this.getToken(), folderPath, newName);
    }

    /**
     * Deletes a folder on the server.
     * @param {string} folderPath - folder to delete
     * @param {boolean} merge - if true, move contents to parent first
     * @returns {Promise<{ok: boolean}>}
     */
    async deleteFolder(folderPath, merge) {
        return await _deleteFolder(this.baseUrl, await this.getToken(), folderPath, merge);
    }

    /**
     * Moves a project to a different folder.
     * @param {string} projectId - the project's unique identifier
     * @param {string} targetFolderPath - destination folder path ('' for root)
     * @returns {Promise<object>} updated project summary
     */
    async moveProjectToFolder(projectId, targetFolderPath) {
        return await _moveProjectToFolder(this.baseUrl, await this.getToken(), projectId, targetFolderPath);
    }

    /**
     * Gets the count of projects and subfolders within a folder.
     * @param {string} folderPath - folder to inspect
     * @returns {Promise<{projects: number, folders: number}>}
     */
    async getFolderCount(folderPath) {
        return await _getFolderCount(this.baseUrl, await this.getToken(), folderPath);
    }

    // ----- TRANSCRIPTION ----- //

    /**
     * Checks whether audio.mp3 is ready for a project.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<boolean>} True if audio.mp3 is available.
     */
    async checkAudioReady(projectId) {
        return await _checkAudioReady(this.baseUrl, await this.getToken(), projectId);
    }

    /**
     * Starts transcription and streams progress events via a callback.
     * @param {string} projectId - The project's unique identifier.
     * @param {object} options - { modelSize, speakerCount }
     * @param {function} onEvent - called with each SSE event object
     * @param {AbortSignal} [signal] - optional abort signal
     * @returns {Promise<void>}
     */
    async transcribeProject(projectId, options, onEvent, signal) {
        return await _transcribeProject(this.baseUrl, await this.getToken(), projectId, options, onEvent, signal);
    }

    /**
     * Retranscribes specific time-range segments and returns updated word-level timestamps.
     * @param {string} projectId - The project ID
     * @param {Array<{start: number, end: number, idx: number}>} segments - Segments to retranscribe
     * @param {string} [modelSize] - Whisper model size to use
     * @returns {Promise<{results: Array<{idx: number, words: Array}>}>}
     */
    async retranscribeSegments(projectId, segments, modelSize = 'medium') {
        return await _retranscribeSegments(this.baseUrl, await this.getToken(), projectId, segments, modelSize);
    }

    /**
     * Deletes the transcript for a project.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<void>}
     */
    async deleteTranscript(projectId) {
        return await _deleteTranscript(this.baseUrl, await this.getToken(), projectId);
    }

    /**
     * Deletes the audio file for a project.
     * @param {string} projectId - The project's unique identifier.
     * @returns {Promise<void>}
     */
    async deleteAudio(projectId) {
        return await _deleteAudio(this.baseUrl, await this.getToken(), projectId);
    }

    // ----- USER ----- //

    /**
     * Re-fetches /api/users/me and updates backendUser in place.
     * Call this after operations that change storage usage so that
     * subsequent file-size checks use current data.
     * @returns {Promise<void>}
     */
    async refreshUser() {
        try {
            this.backendUser = await _getUserMe(this.baseUrl, await this.getToken());
        } catch (e) {
            console.warn('Could not refresh user profile:', e);
        }
    }

    // ----- URL HELPERS ----- //

    /**
     * Returns a URL that streams the project's audio.
     * Uses the most recently cached token for <audio src> compatibility.
     * @param {string} id - The project's unique identifier.
     * @returns {string} The audio stream URL.
     */
    audioUrl(id) {
        return `${this.baseUrl}/api/projects/${id}/audio?token=${this._cachedToken}`;
    }

    /**
     * Returns the URL to download the project's transcript CSV.
     * @param {string} id - The project's unique identifier.
     * @returns {string} The transcript download URL.
     */
    transcriptUrl(id) {
        return `${this.baseUrl}/api/projects/${id}/transcript`;
    }

    /**
     * Returns a URL for a specific speaker's voice sample file.
     * Uses the most recently cached token for direct link compatibility.
     * @param {string} id - The project's unique identifier.
     * @param {string} spkId - The speaker's unique identifier.
     * @returns {string} The sample file URL.
     */
    sampleUrl(id, spkId) {
        return `${this.baseUrl}/api/projects/${id}/samples/${spkId}?token=${this._cachedToken}`;
    }

}