/* *
 *
 *  (c) 2009-2026 Highsoft AS
 *  Author: Øystein Moseng
 *
 *  Class representing a Timeline with sonification events to play.
 *
 *  A commercial license may be required depending on use.
 *  See www.highcharts.com/license
 *
 *
 * */
'use strict';
import TimelineChannel from './TimelineChannel.js';
import toMIDI from './MIDI.js';
import { downloadURL } from '../../Shared/DownloadURL.js';
import { defined, find, merge } from '../../Shared/Utilities.js';
/**
 * Get filtered channels. Timestamps are compensated, so that the first
 * event starts immediately.
 * @internal
 */
function filterChannels(filter, channels) {
    const filtered = channels.map((channel) => {
        channel.cancel();
        return {
            channel,
            filteredEvents: channel.muted ?
                [] : channel.events.filter(filter)
        };
    }), minTime = filtered.reduce((acc, cur) => Math.min(acc, cur.filteredEvents.length ?
        cur.filteredEvents[0].time : Infinity), Infinity);
    return filtered.map((c) => (new TimelineChannel(c.channel.type, c.channel.engine, c.channel.showPlayMarker, c.filteredEvents.map((e) => merge(e, { time: e.time - minTime })), c.channel.muted)));
}
/**
 * The SonificationTimeline class. This class represents a timeline of
 * audio events scheduled to play. It provides functionality for manipulating
 * and navigating the timeline.
 * @internal
 */
class SonificationTimeline {
    constructor(options, chart) {
        this.chart = chart;
        this.isPaused = false;
        this.isPlaying = false;
        this.channels = [];
        this.scheduledCallbacks = [];
        this.playTimestamp = 0;
        this.resumeFromTime = 0;
        this.options = options || {};
    }
    // Add a channel, optionally with events, to be played.
    // Note: Only one speech channel is supported at a time.
    addChannel(type, engine, showPlayMarker = false, events) {
        if (type === 'instrument' &&
            !engine.scheduleEventAtTime ||
            type === 'speech' &&
                !engine.sayAtTime) {
            throw new Error('Highcharts Sonification: Invalid channel engine.');
        }
        const channel = new TimelineChannel(type, engine, showPlayMarker, events);
        this.channels.push(channel);
        return channel;
    }
    // Play timeline, optionally filtering out only some of the events to play.
    // Note that if not all instrument parameters are updated on each event,
    // parameters may update differently depending on the events filtered out,
    // since some of the events that update parameters can be filtered out too.
    // The filterPersists argument determines whether or not the filter persists
    // after e.g. pausing and resuming. Usually this should be true.
    play(filter, filterPersists = true, resetAfter = true, onEnd) {
        if (this.isPlaying) {
            this.cancel();
        }
        else {
            this.clearScheduledCallbacks();
        }
        this.onEndArgument = onEnd;
        this.playTimestamp = Date.now();
        this.resumeFromTime = 0;
        this.isPaused = false;
        this.isPlaying = true;
        const skipThreshold = this.options.skipThreshold || 2, onPlay = this.options.onPlay, showTooltip = this.options.showTooltip, showCrosshair = this.options.showCrosshair, channels = filter ?
            filterChannels(filter, this.playingChannels || this.channels) :
            this.channels, getEventKeysSignature = (e) => Object.keys(e.speechOptions || {})
            .concat(Object.keys(e.instrumentEventOptions || {}))
            .join(), pointsPlayed = [];
        if (filterPersists) {
            this.playingChannels = channels;
        }
        if (onPlay) {
            onPlay({ chart: this.chart, timeline: this });
        }
        let maxTime = 0;
        channels.forEach((channel) => {
            if (channel.muted) {
                return;
            }
            const numEvents = channel.events.length;
            let lastCallbackTime = -Infinity, lastEventTime = -Infinity, lastEventKeys = '';
            maxTime = Math.max(channel.events[numEvents - 1] &&
                channel.events[numEvents - 1].time || 0, maxTime);
            for (let i = 0; i < numEvents; ++i) {
                const e = channel.events[i], keysSig = getEventKeysSignature(e);
                // Optimize by skipping extremely close events (<2ms apart by
                // default), as long as they don't introduce new event options
                if (keysSig === lastEventKeys &&
                    e.time - lastEventTime < skipThreshold) {
                    continue;
                }
                lastEventKeys = keysSig;
                lastEventTime = e.time;
                if (channel.type === 'instrument') {
                    channel.engine
                        .scheduleEventAtTime(e.time / 1000, e.instrumentEventOptions || {});
                }
                else {
                    channel.engine.sayAtTime(e.time, e.message || '', e.speechOptions || {});
                }
                const point = e.relatedPoint, chart = point && point.series && point.series.chart, needsCallback = e.callback ||
                    point && (showTooltip || showCrosshair) &&
                        channel.showPlayMarker !== false &&
                        (e.time - lastCallbackTime > 50 || i === numEvents - 1);
                if (point) {
                    pointsPlayed.push(point);
                }
                if (needsCallback) {
                    this.scheduledCallbacks.push(setTimeout(() => {
                        if (e.callback) {
                            e.callback();
                        }
                        if (point) {
                            if (showCrosshair) {
                                const s = point.series;
                                if (s && s.xAxis && s.xAxis.crosshair) {
                                    s.xAxis.drawCrosshair(void 0, point);
                                }
                                if (s && s.yAxis && s.yAxis.crosshair) {
                                    s.yAxis.drawCrosshair(void 0, point);
                                }
                            }
                            if (showTooltip && !(
                            // Don't re-hover if shared tooltip
                            chart && chart.hoverPoints &&
                                chart.hoverPoints.length > 1 &&
                                find(chart.hoverPoints, (p) => p === point) &&
                                // Stock issue w/Navigator
                                point.onMouseOver)) {
                                point.onMouseOver();
                            }
                        }
                    }, e.time));
                    lastCallbackTime = e.time;
                }
            }
        });
        const onEndOpt = this.options.onEnd, onStop = this.options.onStop;
        this.scheduledCallbacks.push(setTimeout(() => {
            const chart = this.chart, context = { chart, timeline: this, pointsPlayed };
            this.isPlaying = false;
            if (resetAfter) {
                this.resetPlayState();
            }
            if (onStop) {
                onStop(context);
            }
            if (onEndOpt) {
                onEndOpt(context);
            }
            if (onEnd) {
                onEnd(context);
            }
            if (chart) {
                if (chart.tooltip) {
                    chart.tooltip.hide(0);
                }
                if (chart.hoverSeries) {
                    chart.hoverSeries.onMouseOut();
                }
                chart.axes.forEach((a) => a.hideCrosshair());
            }
        }, maxTime + 250));
        this.resumeFromTime = filterPersists ? maxTime : this.getLength();
    }
    // Pause for later resuming. Returns current timestamp to resume from.
    pause() {
        this.isPaused = true;
        this.cancel();
        this.resumeFromTime = Date.now() - this.playTimestamp - 10;
        return this.resumeFromTime;
    }
    // Get current time
    getCurrentTime() {
        return this.isPlaying ?
            Date.now() - this.playTimestamp :
            this.resumeFromTime;
    }
    // Get length of timeline in milliseconds
    getLength() {
        return this.channels.reduce((maxTime, channel) => {
            const lastEvent = channel.events[channel.events.length - 1];
            return lastEvent ? Math.max(lastEvent.time, maxTime) : maxTime;
        }, 0);
    }
    // Resume from paused
    resume() {
        if (this.playingChannels) {
            const resumeFrom = this.resumeFromTime - 50;
            this.play((e) => e.time > resumeFrom, false, false, this.onEndArgument);
            this.playTimestamp -= resumeFrom;
        }
        else {
            this.play(void 0, false, false, this.onEndArgument);
        }
    }
    // Play a short moment, then pause, setting the cursor to the final
    // event's time.
    anchorPlayMoment(eventFilter, onEnd) {
        if (this.isPlaying) {
            this.pause();
        }
        let finalEventTime = 0;
        this.play((e, ix, arr) => {
            // We have to keep track of final event time ourselves, since
            // play() messes with the time internally upon filtering.
            const res = eventFilter(e, ix, arr);
            if (res && e.time > finalEventTime) {
                finalEventTime = e.time;
            }
            return res;
        }, false, false, onEnd);
        this.playingChannels = this.playingChannels || this.channels;
        this.isPaused = true;
        this.isPlaying = false;
        this.resumeFromTime = finalEventTime;
    }
    // Play event(s) occurring next/prev from paused state.
    playAdjacent(next, onEnd, onBoundaryHit, eventFilter) {
        if (this.isPlaying) {
            this.pause();
        }
        const fromTime = this.resumeFromTime, closestTime = this.channels.reduce((time, channel) => {
            // Adapted binary search since events are sorted by time
            const events = eventFilter ?
                channel.events.filter(eventFilter) : channel.events;
            let s = 0, e = events.length, lastValidTime = time;
            while (s < e) {
                const mid = (s + e) >> 1, t = events[mid].time, cmp = t - fromTime;
                if (cmp > 0) { // Ahead
                    if (next && t < lastValidTime) {
                        lastValidTime = t;
                    }
                    e = mid;
                }
                else if (cmp < 0) { // Behind
                    if (!next && t > lastValidTime) {
                        lastValidTime = t;
                    }
                    s = mid + 1;
                }
                else { // Same as from time
                    if (next) {
                        s = mid + 1;
                    }
                    else {
                        e = mid;
                    }
                }
            }
            return lastValidTime;
        }, next ? Infinity : -Infinity), margin = 0.02;
        if (closestTime === Infinity || closestTime === -Infinity) {
            if (onBoundaryHit) {
                onBoundaryHit({
                    chart: this.chart, timeline: this, attemptedNext: next
                });
            }
            return;
        }
        this.anchorPlayMoment((e, ix, arr) => {
            const withinTime = next ?
                e.time > fromTime && e.time <= closestTime + margin :
                e.time < fromTime && e.time >= closestTime - margin;
            return eventFilter ? withinTime && eventFilter(e, ix, arr) :
                withinTime;
        }, onEnd);
    }
    // Play event with related point, where the value of a prop on the
    // related point is closest to a target value.
    // Note: not very efficient.
    playClosestToPropValue(prop, targetVal, onEnd, onBoundaryHit, eventFilter) {
        const filter = (e, ix, arr) => !!(eventFilter ?
            eventFilter(e, ix, arr) && e.relatedPoint :
            e.relatedPoint);
        let closestValDiff = Infinity, closestEvent = null;
        (this.playingChannels || this.channels).forEach((channel) => {
            const events = channel.events;
            let i = events.length;
            while (i--) {
                if (!filter(events[i], i, events)) {
                    continue;
                }
                const val = events[i].relatedPoint[prop], diff = defined(val) && Math.abs(targetVal - val);
                if (diff !== false && diff < closestValDiff) {
                    closestValDiff = diff;
                    closestEvent = events[i];
                }
            }
        });
        if (closestEvent) {
            this.play((e) => !!(closestEvent &&
                e.time < closestEvent.time + 1 &&
                e.time > closestEvent.time - 1 &&
                e.relatedPoint === closestEvent.relatedPoint), false, false, onEnd);
            this.playingChannels = this.playingChannels || this.channels;
            this.isPaused = true;
            this.isPlaying = false;
            this.resumeFromTime = closestEvent.time;
        }
        else if (onBoundaryHit) {
            onBoundaryHit({ chart: this.chart, timeline: this });
        }
    }
    // Get timeline events that are related to a certain point.
    // Note: Point grouping may cause some points not to have a
    //  related point in the timeline.
    getEventsForPoint(point) {
        return this.channels.reduce((events, channel) => {
            const pointEvents = channel.events
                .filter((e) => e.relatedPoint === point);
            return events.concat(pointEvents);
        }, []);
    }
    // Divide timeline into 100 parts of equal time, and play one of them.
    // Used for scrubbing.
    // Note: Should be optimized?
    playSegment(segment, onEnd) {
        const numSegments = 100;
        const eventTimes = {
            first: Infinity,
            last: -Infinity
        };
        this.channels.forEach((c) => {
            if (c.events.length) {
                eventTimes.first = Math.min(c.events[0].time, eventTimes.first);
                eventTimes.last = Math.max(c.events[c.events.length - 1].time, eventTimes.last);
            }
        });
        if (eventTimes.first < Infinity) {
            const segmentSize = (eventTimes.last - eventTimes.first) / numSegments, fromTime = eventTimes.first + segment * segmentSize, toTime = fromTime + segmentSize;
            // Binary search, do we have any events within time range?
            if (!this.channels.some((c) => {
                const events = c.events;
                let s = 0, e = events.length;
                while (s < e) {
                    const mid = (s + e) >> 1, t = events[mid].time;
                    if (t < fromTime) { // Behind
                        s = mid + 1;
                    }
                    else if (t > toTime) { // Ahead
                        e = mid;
                    }
                    else {
                        return true;
                    }
                }
                return false;
            })) {
                return; // If not, don't play - avoid cancelling current play
            }
            this.play((e) => e.time >= fromTime && e.time <= toTime, false, false, onEnd);
            this.playingChannels = this.playingChannels || this.channels;
            this.isPaused = true;
            this.isPlaying = false;
            this.resumeFromTime = toTime;
        }
    }
    // Get last played / current point
    // Since events are scheduled we can't just store points as we play them
    getLastPlayedPoint(filter) {
        const curTime = this.getCurrentTime(), channels = this.playingChannels || this.channels;
        let closestDiff = Infinity, closestPoint = null;
        channels.forEach((c) => {
            const events = c.events.filter((e, ix, arr) => !!(e.relatedPoint && e.time <= curTime &&
                (!filter || filter(e, ix, arr)))), closestEvent = events[events.length - 1];
            if (closestEvent) {
                const closestTime = closestEvent.time, diff = Math.abs(closestTime - curTime);
                if (diff < closestDiff) {
                    closestDiff = diff;
                    closestPoint = closestEvent.relatedPoint;
                }
            }
        });
        return closestPoint;
    }
    // Reset play/pause state so that a later call to resume() will start over
    reset() {
        if (this.isPlaying) {
            this.cancel();
        }
        this.resetPlayState();
    }
    cancel() {
        const onStop = this.options.onStop;
        if (onStop) {
            onStop({ chart: this.chart, timeline: this });
        }
        this.isPlaying = false;
        this.channels.forEach((c) => c.cancel());
        if (this.playingChannels && this.playingChannels !== this.channels) {
            this.playingChannels.forEach((c) => c.cancel());
        }
        this.clearScheduledCallbacks();
        this.resumeFromTime = 0;
    }
    destroy() {
        this.cancel();
        if (this.playingChannels && this.playingChannels !== this.channels) {
            this.playingChannels.forEach((c) => c.destroy());
        }
        this.channels.forEach((c) => c.destroy());
    }
    setMasterVolume(vol) {
        this.channels.forEach((c) => c.engine.setMasterVolume(vol));
    }
    getMIDIData() {
        return toMIDI(this.channels.filter((c) => c.type === 'instrument'));
    }
    downloadMIDI(filename) {
        const data = this.getMIDIData(), name = (filename ||
            this.chart &&
                this.chart.options.title &&
                this.chart.options.title.text ||
            'chart') + '.mid', blob = new Blob([data], { type: 'application/octet-stream' }), url = window.URL.createObjectURL(blob);
        downloadURL(url, name);
        window.URL.revokeObjectURL(url);
    }
    resetPlayState() {
        delete this.playingChannels;
        delete this.onEndArgument;
        this.playTimestamp = this.resumeFromTime = 0;
        this.isPaused = false;
    }
    clearScheduledCallbacks() {
        this.scheduledCallbacks.forEach(clearTimeout);
        this.scheduledCallbacks = [];
    }
}
/* *
 *
 *  Default Export
 *
 * */
/**
 * @internal
 */
export default SonificationTimeline;
/* *
 *
 *  API declarations
 *
 * */
/**
 * Filter callback for filtering timeline events on a SonificationTimeline.
 *
 * @callback Highcharts.SonificationTimelineFilterCallback
 *
 * @param {Highcharts.SonificationTimelineEvent} e TimelineEvent being filtered
 *
 * @param {number} ix Index of TimelineEvent in current event array
 *
 * @param {Array<Highcharts.SonificationTimelineEvent>} arr The current event array
 *
 * @return {boolean}
 * The function should return true if the TimelineEvent should be included,
 * false otherwise.
 */
(''); // Keep above doclets in JS file
