/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/

import { isEqual } from "lodash";
import { Optional } from "matrix-events-sdk";
import { logger } from "matrix-js-sdk/src/logger";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";

import { getChunkLength } from "..";
import { IRecordingUpdate, VoiceRecording } from "../../audio/VoiceRecording";
import { concat } from "../../utils/arrays";
import { IDestroyable } from "../../utils/IDestroyable";
import { Singleflight } from "../../utils/Singleflight";

export enum VoiceBroadcastRecorderEvent {
    ChunkRecorded = "chunk_recorded",
    CurrentChunkLengthUpdated = "current_chunk_length_updated",
}

interface EventMap {
    [VoiceBroadcastRecorderEvent.ChunkRecorded]: (chunk: ChunkRecordedPayload) => void;
    [VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated]: (length: number) => void;
}

export interface ChunkRecordedPayload {
    buffer: Uint8Array;
    length: number;
}

// char sequence of "OpusHead"
const OpusHead = [79, 112, 117, 115, 72, 101, 97, 100];

// char sequence of "OpusTags"
const OpusTags = [79, 112, 117, 115, 84, 97, 103, 115];

/**
 * This class provides the function to seamlessly record fixed length chunks.
 * Subscribe with on(VoiceBroadcastRecordingEvents.ChunkRecorded, (payload: ChunkRecordedPayload) => {})
 * to retrieve chunks while recording.
 */
export class VoiceBroadcastRecorder
    extends TypedEventEmitter<VoiceBroadcastRecorderEvent, EventMap>
    implements IDestroyable
{
    private opusHead?: Uint8Array;
    private opusTags?: Uint8Array;
    private chunkBuffer = new Uint8Array(0);
    // position of the previous chunk in seconds
    private previousChunkEndTimePosition = 0;
    // current chunk length in seconds
    private currentChunkLength = 0;

    public constructor(
        private voiceRecording: VoiceRecording,
        public readonly targetChunkLength: number,
    ) {
        super();
        this.voiceRecording.onDataAvailable = this.onDataAvailable;
    }

    public async start(): Promise<void> {
        await this.voiceRecording.start();
        this.voiceRecording.liveData.onUpdate((data: IRecordingUpdate) => {
            this.setCurrentChunkLength(data.timeSeconds - this.previousChunkEndTimePosition);
        });
    }

    /**
     * Stops the recording and returns the remaining chunk (if any).
     */
    public async stop(): Promise<Optional<ChunkRecordedPayload>> {
        try {
            await this.voiceRecording.stop();
        } catch (e) {
            // Ignore if the recording raises any error.
        }

        // forget about that call, so that we can stop it again later
        Singleflight.forgetAllFor(this.voiceRecording);
        const chunk = this.extractChunk();
        this.currentChunkLength = 0;
        this.previousChunkEndTimePosition = 0;
        return chunk;
    }

    public get contentType(): string {
        return this.voiceRecording.contentType;
    }

    private setCurrentChunkLength(currentChunkLength: number): void {
        if (this.currentChunkLength === currentChunkLength) return;

        this.currentChunkLength = currentChunkLength;
        this.emit(VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated, currentChunkLength);
    }

    public getCurrentChunkLength(): number {
        return this.currentChunkLength;
    }

    private onDataAvailable = (data: ArrayBuffer): void => {
        const dataArray = new Uint8Array(data);

        // extract the part, that contains the header type info
        const headerType = Array.from(dataArray.slice(28, 36));

        if (isEqual(OpusHead, headerType)) {
            // data seems to be an "OpusHead" header
            this.opusHead = dataArray;
            return;
        }

        if (isEqual(OpusTags, headerType)) {
            // data seems to be an "OpusTags" header
            this.opusTags = dataArray;
            return;
        }

        this.setCurrentChunkLength(this.voiceRecording.recorderSeconds! - this.previousChunkEndTimePosition);
        this.handleData(dataArray);
    };

    private handleData(data: Uint8Array): void {
        this.chunkBuffer = concat(this.chunkBuffer, data);
        this.emitChunkIfTargetLengthReached();
    }

    private emitChunkIfTargetLengthReached(): void {
        if (this.getCurrentChunkLength() >= this.targetChunkLength) {
            this.emitAndResetChunk();
        }
    }

    /**
     * Extracts the current chunk and resets the buffer.
     */
    private extractChunk(): Optional<ChunkRecordedPayload> {
        if (this.chunkBuffer.length === 0) {
            return null;
        }

        if (!this.opusHead || !this.opusTags) {
            logger.warn("Broadcast chunk cannot be extracted. OpusHead or OpusTags is missing.");
            return null;
        }

        const currentRecorderTime = this.voiceRecording.recorderSeconds!;
        const payload: ChunkRecordedPayload = {
            buffer: concat(this.opusHead!, this.opusTags!, this.chunkBuffer),
            length: this.getCurrentChunkLength(),
        };
        this.chunkBuffer = new Uint8Array(0);
        this.setCurrentChunkLength(0);
        this.previousChunkEndTimePosition = currentRecorderTime;
        return payload;
    }

    private emitAndResetChunk(): void {
        if (this.chunkBuffer.length === 0) {
            return;
        }

        this.emit(VoiceBroadcastRecorderEvent.ChunkRecorded, this.extractChunk()!);
    }

    public destroy(): void {
        this.removeAllListeners();
        this.voiceRecording.destroy();
    }
}

export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => {
    const voiceRecording = new VoiceRecording();
    voiceRecording.disableMaxLength();
    return new VoiceBroadcastRecorder(voiceRecording, getChunkLength());
};
