import { DefaultObservableValue, isDefined, Notifier, Option, Terminable, Terminator } from "@opendaw/lib-std";
import { Events } from "@opendaw/lib-dom";
import { MidiData } from "@opendaw/lib-midi";
import { Promises } from "@opendaw/lib-runtime";
import { NoteSignal } from "@opendaw/studio-adapters";
import { PPQN } from "@opendaw/lib-dsp";
import { MidiDevices } from "../midi";
import { Capture } from "./Capture";
import { RecordMidi } from "./RecordMidi";
const MIN_NOTE_DURATION = PPQN.fromSignature(1, 128);
export class CaptureMidi extends Capture {
    #streamGenerator;
    #notifier = new Notifier();
    #captureNoteOnCount = new DefaultObservableValue(0);
    #captureSubscriptions = new Terminator();
    #filterChannel = Option.None;
    #stream = Option.None;
    #captureEvents = [];
    #captureMode = "stopped";
    #captureOrigin = 0;
    #captureOriginSet = false;
    #capturePendingReset = false;
    #captureBpmAtOrigin = 120.0;
    #captureLoopOffset = 0;
    #captureLastPosition = 0;
    constructor(manager, audioUnitBox, captureMidiBox) {
        super(manager, audioUnitBox, captureMidiBox);
        this.#streamGenerator = Promises.sequentialize(() => this.#updateStream());
        this.ownAll(captureMidiBox.channel.catchupAndSubscribe(async (owner) => {
            const channel = owner.getValue();
            this.#filterChannel = channel >= 0 ? Option.wrap(channel) : Option.None;
            if (this.armed.getValue()) {
                await this.#streamGenerator();
            }
        }), captureMidiBox.deviceId.subscribe(async () => {
            if (this.armed.getValue()) {
                await this.#streamGenerator();
            }
        }), this.armed.catchupAndSubscribe(async (owner) => {
            const armed = owner.getValue();
            if (armed) {
                this.#startCapture();
                await this.#streamGenerator();
            }
            else {
                this.#stopCapture();
                this.#stopStream();
            }
        }), this.#notifier.subscribe((signal) => manager.project.engine.noteSignal(signal)), this.#notifier.subscribe((signal) => this.#bufferNote(signal)), Terminable.create(() => {
            this.#stopStream();
            this.#stopCapture();
        }));
    }
    get captureNoteOnCount() { return this.#captureNoteOnCount; }
    resolveCapture() {
        const notes = this.#resolveNotes();
        if (notes.length === 0) {
            return Option.None;
        }
        return Option.wrap({ mode: this.#captureMode, origin: this.#captureOrigin, notes });
    }
    resetCapture() {
        this.#captureReset();
        this.#captureMode = this.manager.project.engine.isPlaying.getValue() ? "playing" : "stopped";
    }
    notify(signal) { this.#notifier.notify(signal); }
    subscribeNotes(observer) { return this.#notifier.subscribe(observer); }
    get label() {
        return MidiDevices.get().mapOr(() => this.deviceId.getValue().match({
            none: () => this.armed.getValue() ? this.#filterChannel.match({
                none: () => `Listening to all devices`,
                some: channel => `Listening to all devices on channel '${channel}'`
            }) : "Arm to listen to MIDI device...",
            some: id => {
                const device = MidiDevices.findInputDeviceById(id);
                if (device.isEmpty()) {
                    return `Could not find device with id '${id}'`;
                }
                const deviceName = device.unwrapOrUndefined()?.name ?? "Unknown device";
                return this.#filterChannel.match({
                    none: () => `Listening to ${deviceName}`,
                    some: channel => `Listening to ${deviceName} on channel #${channel + 1}`
                });
            }
        }), "MIDI not available");
    }
    get deviceLabel() {
        return this.deviceId.getValue()
            .flatMap(deviceId => MidiDevices.findInputDeviceById(deviceId)
            .map(device => device.name));
    }
    async prepareRecording() { }
    startRecording() {
        return RecordMidi.start({ notifier: this.#notifier, project: this.manager.project, capture: this });
    }
    #startCapture() {
        this.#captureReset();
        this.#captureSubscriptions.terminate();
        const { engine, timelineBox } = this.manager.project;
        const { loopArea } = timelineBox;
        this.#captureSubscriptions.ownAll(engine.isPlaying.catchupAndSubscribe(owner => {
            const playing = owner.getValue();
            if (playing) {
                this.#captureReset();
                this.#captureMode = "playing";
            }
            else if (this.#captureEvents.length > 0) {
                this.#capturePendingReset = true;
            }
            else {
                this.#captureReset();
                this.#captureMode = "stopped";
            }
        }), engine.isRecording.catchupAndSubscribe(owner => {
            if (owner.getValue()) {
                this.#captureReset();
            }
        }), engine.position.subscribe(owner => {
            if (this.#captureMode !== "playing") {
                return;
            }
            const currentPosition = owner.getValue();
            if (!this.#captureOriginSet) {
                this.#captureOrigin = currentPosition;
                this.#captureLastPosition = currentPosition;
                this.#captureOriginSet = true;
                return;
            }
            if (currentPosition < this.#captureLastPosition && loopArea.enabled.getValue()) {
                this.#captureLoopOffset += loopArea.to.getValue() - loopArea.from.getValue();
            }
            this.#captureLastPosition = currentPosition;
        }));
    }
    #stopCapture() {
        this.#captureSubscriptions.terminate();
        this.#captureReset();
    }
    #bufferNote(signal) {
        if (!this.armed.getValue()) {
            return;
        }
        if (this.manager.project.engine.isRecording.getValue()) {
            return;
        }
        if (NoteSignal.isOn(signal)) {
            if (this.#capturePendingReset) {
                this.#captureReset();
                this.#captureMode = "stopped";
                this.#capturePendingReset = false;
            }
            if (!this.#captureOriginSet) {
                if (this.#captureMode === "playing") {
                    const currentPosition = this.manager.project.engine.position.getValue();
                    this.#captureOrigin = currentPosition;
                    this.#captureLastPosition = currentPosition;
                }
                else {
                    this.#captureOrigin = performance.now();
                    this.#captureBpmAtOrigin = this.manager.project.timelineBox.bpm.getValue();
                }
                this.#captureOriginSet = true;
            }
            const delta = this.#computeCaptureDelta();
            this.#captureEvents.push({ type: "on", pitch: signal.pitch, velocity: signal.velocity, delta });
            this.#captureNoteOnCount.setValue(this.#captureNoteOnCount.getValue() + 1);
        }
        else if (NoteSignal.isOff(signal)) {
            const delta = this.#computeCaptureDelta();
            this.#captureEvents.push({ type: "off", pitch: signal.pitch, delta });
        }
    }
    #computeCaptureDelta() {
        if (this.#captureMode === "playing") {
            return this.#captureLastPosition - this.#captureOrigin + this.#captureLoopOffset;
        }
        return performance.now() - this.#captureOrigin;
    }
    #captureReset() {
        this.#captureEvents.length = 0;
        this.#captureNoteOnCount.setValue(0);
        this.#captureOrigin = 0;
        this.#captureOriginSet = false;
        this.#capturePendingReset = false;
        this.#captureBpmAtOrigin = 120.0;
        this.#captureLoopOffset = 0;
        this.#captureLastPosition = 0;
    }
    #resolveNotes() {
        const openNotes = new Map();
        const resolved = [];
        const toPosition = (delta) => {
            if (this.#captureMode === "playing" || this.#capturePendingReset) {
                return delta;
            }
            return PPQN.secondsToPulses(delta / 1000, this.#captureBpmAtOrigin);
        };
        const commitDelta = this.#computeCaptureDelta();
        for (const event of this.#captureEvents) {
            if (event.type === "on") {
                openNotes.set(event.pitch, { velocity: event.velocity, delta: event.delta });
            }
            else {
                const open = openNotes.get(event.pitch);
                if (!isDefined(open)) {
                    continue;
                }
                openNotes.delete(event.pitch);
                const position = toPosition(open.delta);
                const duration = Math.max(MIN_NOTE_DURATION, toPosition(event.delta) - position);
                resolved.push({ pitch: event.pitch, velocity: open.velocity, position, duration });
            }
        }
        for (const [pitch, open] of openNotes) {
            const position = toPosition(open.delta);
            const duration = Math.max(MIN_NOTE_DURATION, toPosition(commitDelta) - position);
            resolved.push({ pitch, velocity: open.velocity, position, duration });
        }
        return resolved;
    }
    async #updateStream() {
        const inputs = MidiDevices.inputDevices();
        const explicit = this.deviceId.getValue().match({
            none: () => inputs,
            some: id => {
                const filtered = inputs.filter(device => id === device.id);
                if (filtered.length === 0 && inputs.length > 0) {
                    console.warn(`Requested MIDI device '${id}' unavailable, listening to all devices`);
                    return inputs;
                }
                return filtered;
            }
        });
        const activeNotes = new Int8Array(128);
        this.#stream.ifSome(terminable => terminable.terminate());
        this.#stream = Option.wrap(Terminable.many(...explicit.map(input => Events.subscribe(input, "midimessage", (event) => {
            const data = event.data;
            if (isDefined(data) &&
                this.#filterChannel.mapOr(channel => MidiData.readChannel(data) === channel, true)) {
                const pitch = MidiData.readPitch(data);
                if (MidiData.isNoteOn(data)) {
                    activeNotes[pitch]++;
                    this.#notifier.notify(NoteSignal.fromEvent(event, this.uuid));
                }
                else if (MidiData.isNoteOff(data) && activeNotes[pitch] > 0) {
                    activeNotes[pitch]--;
                    this.#notifier.notify(NoteSignal.fromEvent(event, this.uuid));
                }
            }
        })), Terminable.create(() => activeNotes.forEach((count, index) => {
            if (count > 0) {
                for (let channel = 0; channel < 16; channel++) {
                    const event = new MessageEvent("midimessage", { data: MidiData.noteOff(channel, index) });
                    const signal = NoteSignal.fromEvent(event, this.uuid);
                    for (let i = 0; i < count; i++) {
                        this.#notifier.notify(signal);
                    }
                }
            }
        }))));
    }
    #stopStream() {
        this.#stream.ifSome(terminable => terminable.terminate());
        this.#stream = Option.None;
    }
}
