import { Errors, isDefined, Option, panic, SyncStream, Terminable, Terminator, TimeSpan, UUID } from "@opendaw/lib-std";
import { AudioData } from "@opendaw/lib-dsp";
import { ApparatDeviceBox, SpielwerkDeviceBox, WerkstattDeviceBox } from "@opendaw/studio-boxes";
import { Communicator, Messenger, Wait } from "@opendaw/lib-runtime";
import { AnimationFrame } from "@opendaw/lib-dom";
import { EngineStateSchema, ExportStemsConfiguration } from "@opendaw/studio-adapters";
import { AudioWorklets } from "./AudioWorklets";
import { MIDIReceiver } from "./midi";
let workerUrl = Option.None;
export class OfflineEngineRenderer {
    static install(url) {
        console.debug(`OfflineEngineWorkerUrl: '${url}'`);
        workerUrl = Option.wrap(url);
    }
    static getWorkerUrl() {
        return workerUrl.unwrap("OfflineEngineWorkerUrl is missing (call 'install' first)");
    }
    static async create(source, optExportConfiguration, sampleRate = 48_000) {
        const numStems = ExportStemsConfiguration.countStems(optExportConfiguration);
        if (numStems === 0) {
            return panic("Nothing to export");
        }
        const numberOfChannels = numStems * 2;
        const worker = new Worker(this.getWorkerUrl(), { type: "module" });
        const messenger = Messenger.for(worker);
        const protocol = Communicator.sender(messenger.channel("offline-engine"), dispatcher => new class {
            initialize(enginePort, config) {
                return dispatcher.dispatchAndReturn(this.initialize, enginePort, config);
            }
            addModule(code) {
                return dispatcher.dispatchAndReturn(this.addModule, code);
            }
            render(config) {
                return dispatcher.dispatchAndReturn(this.render, config);
            }
            step(samples) {
                return dispatcher.dispatchAndReturn(this.step, samples);
            }
            stop() { dispatcher.dispatchAndForget(this.stop); }
        });
        const channel = new MessageChannel();
        const engineStateIO = EngineStateSchema();
        const reader = SyncStream.reader(engineStateIO, () => { });
        const controlFlagsBuffer = new SharedArrayBuffer(4);
        const terminator = new Terminator();
        const engineMessenger = Messenger.for(channel.port2);
        Communicator.executor(engineMessenger.channel("engine-to-client"), {
            log: (message) => console.log("OFFLINE-ENGINE", message),
            error: (reason) => console.error("OFFLINE-ENGINE", reason),
            ready: () => { },
            fetchAudio: (uuid) => new Promise((resolve, reject) => {
                const handler = source.sampleManager.getOrCreate(uuid);
                const subscription = handler.subscribe(state => {
                    if (state.type === "error") {
                        reject(new Error(state.reason));
                        subscription.terminate();
                    }
                    else if (state.type === "loaded") {
                        resolve(handler.data.unwrap());
                        subscription.terminate();
                    }
                });
            }),
            fetchSoundfont: (uuid) => new Promise((resolve, reject) => {
                const handler = source.soundfontManager.getOrCreate(uuid);
                const subscription = handler.subscribe(state => {
                    if (state.type === "error") {
                        reject(new Error(state.reason));
                        subscription.terminate();
                    }
                    else if (state.type === "loaded") {
                        resolve(handler.soundfont.unwrap());
                        subscription.terminate();
                    }
                });
            }),
            fetchNamWasm: async () => {
                const url = new URL("@opendaw/nam-wasm/nam.wasm", import.meta.url);
                const response = await fetch(url);
                return response.arrayBuffer();
            },
            notifyClipSequenceChanges: () => { },
            switchMarkerState: () => { },
            deviceMessage: (uuid, message) => {
                console.warn(`OFFLINE-ENGINE device(${uuid}): ${message}`);
            }
        });
        const engineCommands = Communicator.sender(engineMessenger.channel("engine-commands"), dispatcher => new class {
            play() { dispatcher.dispatchAndForget(this.play); }
            stop(reset) { dispatcher.dispatchAndForget(this.stop, reset); }
            setPosition(position) { dispatcher.dispatchAndForget(this.setPosition, position); }
            prepareRecordingState(countIn) { dispatcher.dispatchAndForget(this.prepareRecordingState, countIn); }
            stopRecording() { dispatcher.dispatchAndForget(this.stopRecording); }
            queryLoadingComplete() { return dispatcher.dispatchAndReturn(this.queryLoadingComplete); }
            panic() { dispatcher.dispatchAndForget(this.panic); }
            noteSignal(signal) { dispatcher.dispatchAndForget(this.noteSignal, signal); }
            ignoreNoteRegion(uuid) { dispatcher.dispatchAndForget(this.ignoreNoteRegion, uuid); }
            scheduleClipPlay(clipIds) { dispatcher.dispatchAndForget(this.scheduleClipPlay, clipIds); }
            scheduleClipStop(trackIds) { dispatcher.dispatchAndForget(this.scheduleClipStop, trackIds); }
            setupMIDI(port, buffer) { dispatcher.dispatchAndForget(this.setupMIDI, port, buffer); }
            updateMonitoringMap(map) { dispatcher.dispatchAndForget(this.updateMonitoringMap, map); }
            loadClickSound(index, data) { dispatcher.dispatchAndForget(this.loadClickSound, index, data); }
            setFrozenAudio(uuid, audioData) { dispatcher.dispatchAndForget(this.setFrozenAudio, uuid, audioData); }
            terminate() { dispatcher.dispatchAndForget(this.terminate); }
        });
        channel.port2.start();
        terminator.own(source.liveStreamReceiver.connect(engineMessenger.channel("engine-live-data")));
        const { port, sab } = terminator.own(MIDIReceiver.create(() => 0, (deviceId, data, relativeTimeInMs) => source.receivedMIDIFromEngine(deviceId, data, relativeTimeInMs)));
        const loadScriptDevice = async (code, headerPattern, registryName, functionName, uuid) => {
            const match = code.match(headerPattern);
            if (match === null) {
                return;
            }
            const userCode = code.slice(match[0].length);
            const update = parseInt(match[3]);
            await protocol.addModule(`
                if (typeof globalThis.openDAW === "undefined") { globalThis.openDAW = {} }
                if (typeof globalThis.openDAW.${registryName} === "undefined") { globalThis.openDAW.${registryName} = {} }
                globalThis.openDAW.${registryName}["${uuid}"] = {
                    update: ${update},
                    create: (function ${functionName}() {
                        ${userCode}
                        return Processor
                    })()
                }
            `);
        };
        for (const box of source.boxGraph.boxes()) {
            if (box instanceof WerkstattDeviceBox) {
                await loadScriptDevice(box.code.getValue(), /^\/\/ @werkstatt (\w+) (\d+) (\d+)\n/, "werkstattProcessors", "werkstatt", UUID.toString(box.address.uuid));
            }
            else if (box instanceof SpielwerkDeviceBox) {
                await loadScriptDevice(box.code.getValue(), /^\/\/ @spielwerk (\w+) (\d+) (\d+)\n/, "spielwerkProcessors", "spielwerk", UUID.toString(box.address.uuid));
            }
            else if (box instanceof ApparatDeviceBox) {
                await loadScriptDevice(box.code.getValue(), /^\/\/ @apparat (\w+) (\d+) (\d+)\n/, "apparatProcessors", "apparat", UUID.toString(box.address.uuid));
            }
        }
        await protocol.initialize(channel.port1, {
            sampleRate,
            numberOfChannels,
            processorsUrl: AudioWorklets.processorsUrl,
            syncStreamBuffer: reader.buffer,
            controlFlagsBuffer,
            project: source.toArrayBuffer(),
            exportConfiguration: optExportConfiguration.unwrapOrUndefined()
        });
        engineCommands.setupMIDI(port, sab);
        return new OfflineEngineRenderer(worker, protocol, engineCommands, terminator, reader, engineStateIO, sampleRate, numberOfChannels);
    }
    static async start(source, optExportConfiguration, progress, abortSignal, sampleRate = 48_000) {
        const { timelineBox: { loopArea: { enabled } }, boxGraph } = source;
        const wasEnabled = enabled.getValue();
        boxGraph.beginTransaction();
        enabled.setValue(false);
        boxGraph.endTransaction();
        const endPosition = source.lastRegionAction();
        const maxDurationSeconds = source.tempoMap.ppqnToSeconds(endPosition) + 30;
        const renderer = await this.create(source, optExportConfiguration, sampleRate);
        const result = await renderer.render({ maxDurationSeconds }, endPosition, progress, abortSignal);
        boxGraph.beginTransaction();
        enabled.setValue(wasEnabled);
        boxGraph.endTransaction();
        return result;
    }
    #worker;
    #protocol;
    #engineCommands;
    #terminator;
    #reader;
    #engineStateIO;
    #sampleRate;
    #numberOfChannels;
    #totalFrames = 0;
    constructor(worker, protocol, engineCommands, terminator, reader, engineStateIO, sampleRate, numberOfChannels) {
        this.#worker = worker;
        this.#protocol = protocol;
        this.#engineCommands = engineCommands;
        this.#terminator = terminator;
        this.#reader = reader;
        this.#engineStateIO = engineStateIO;
        this.#sampleRate = sampleRate;
        this.#numberOfChannels = numberOfChannels;
    }
    get sampleRate() { return this.#sampleRate; }
    get numberOfChannels() { return this.#numberOfChannels; }
    get totalFrames() { return this.#totalFrames; }
    play() {
        this.#engineCommands.play();
    }
    stop() {
        this.#engineCommands.stop(true);
        this.#protocol.stop();
    }
    setPosition(position) {
        this.#engineCommands.setPosition(position);
    }
    async waitForLoading() {
        while (!await this.#engineCommands.queryLoadingComplete()) {
            await Wait.timeSpan(TimeSpan.millis(100));
        }
    }
    terminate() {
        this.#terminator.terminate();
        this.#worker.terminate();
    }
    async step(samples) {
        const channels = await this.#protocol.step(samples);
        this.#totalFrames += samples;
        return channels;
    }
    async render(config, endPosition, progress, abortSignal) {
        const { promise, reject, resolve } = Promise.withResolvers();
        let cancelled = false;
        const polling = endPosition > 0
            ? AnimationFrame.add(() => {
                this.#reader.tryRead();
                progress.setValue(Math.min(1.0, this.#engineStateIO.object.position / endPosition));
            })
            : Terminable.Empty;
        if (isDefined(abortSignal)) {
            abortSignal.onabort = () => {
                polling.terminate();
                this.stop();
                this.terminate();
                cancelled = true;
                reject(Errors.AbortError);
            };
        }
        while (!await this.#engineCommands.queryLoadingComplete()) {
            await Wait.timeSpan(TimeSpan.millis(100));
        }
        this.play();
        this.#protocol.render(config).then(channels => {
            polling.terminate();
            if (cancelled) {
                return;
            }
            progress.setValue(1.0);
            this.terminate();
            const numberOfFrames = channels[0].length;
            const audioData = AudioData.create(this.#sampleRate, numberOfFrames, this.#numberOfChannels);
            for (let channelIndex = 0; channelIndex < this.#numberOfChannels; channelIndex++) {
                audioData.frames[channelIndex].set(channels[channelIndex]);
            }
            resolve(audioData);
        }).catch(reason => {
            polling.terminate();
            if (!cancelled) {
                this.terminate();
                reject(reason);
            }
        });
        return promise;
    }
}
