import { Muxer, ArrayBufferTarget } from 'mp4-muxer';

/**
 * Offline export.
 *
 * Drives the same Show object the preview does, with the timeline in fixed-step
 * mode — the frame index is counted, never derived from a clock. That is the
 * whole basis of preview/export parity: the exporter has no render path of its
 * own, so there is nothing for it to diverge from.
 *
 * Video goes through WebCodecs VideoEncoder (hardware accelerated where
 * available) into an mp4. Audio is re-encoded to AAC from the already-decoded
 * PCM, so the file carries the same samples the analysis ran on.
 */

export const PRESETS = {
    '720p':  { width: 1280, height: 720,  bitrate: 8_000_000 },
    '1080p': { width: 1920, height: 1080, bitrate: 16_000_000 },
    '1440p': { width: 2560, height: 1440, bitrate: 28_000_000 },
    '4K':    { width: 3840, height: 2160, bitrate: 45_000_000 },
};

export function isSupported() {
    return typeof VideoEncoder !== 'undefined' && typeof VideoFrame !== 'undefined';
}

/** Probe for a codec configuration the browser will actually accept. */
async function pickVideoConfig(width, height, bitrate, fps) {
    const candidates = [
        'avc1.640034', 'avc1.640033', 'avc1.4d0034', 'avc1.42003e',
    ];
    for (const codec of candidates) {
        const config = {
            codec, width, height, bitrate, framerate: fps,
            avc: { format: 'avc' },
        };
        try {
            const support = await VideoEncoder.isConfigSupported(config);
            if (support.supported) return config;
        } catch { /* try the next one */ }
    }
    return null;
}

export class Exporter {
    constructor(show) {
        this.show = show;
        this.cancelled = false;
    }

    cancel() { this.cancelled = true; }

    /**
     * @param {object} options
     * @param {string} options.preset key of PRESETS
     * @param {[number, number]} [options.frameRange] inclusive-exclusive frame range
     * @param {(progress: {frame, total, fraction, stage}) => void} [options.onProgress]
     * @returns {Promise<Blob>}
     */
    async export({ preset = '1080p', frameRange = null, onProgress = null } = {}) {
        if (!isSupported()) {
            throw new Error('WebCodecs VideoEncoder is unavailable in this browser');
        }
        const show = this.show;
        if (!show.ready) throw new Error('no track loaded');

        const { width, height, bitrate } = PRESETS[preset] || PRESETS['1080p'];
        const fps = show.fps;

        const [startFrame, endFrame] = frameRange || [0, show.frameCount];
        const total = Math.max(1, endFrame - startFrame);

        const videoConfig = await pickVideoConfig(width, height, bitrate, fps);
        if (!videoConfig) throw new Error('no supported H.264 configuration found');

        const hasAudio = !!show.audioBuffer && typeof AudioEncoder !== 'undefined';

        const muxer = new Muxer({
            target: new ArrayBufferTarget(),
            video: { codec: 'avc', width, height, frameRate: fps },
            ...(hasAudio ? {
                audio: {
                    codec: 'aac',
                    sampleRate: show.audioBuffer.sampleRate,
                    numberOfChannels: Math.min(2, show.audioBuffer.numberOfChannels),
                },
            } : {}),
            fastStart: 'in-memory',
        });

        const errors = [];
        const videoEncoder = new VideoEncoder({
            output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
            error: (e) => errors.push(e),
        });
        videoEncoder.configure(videoConfig);

        // Render at export resolution. The preview's own size is restored after.
        const previousWidth = show.engine.width;
        const previousHeight = show.engine.height;
        show.setSize(width, height);

        try {
            // Warm-up so the first exported frame has the same feedback state it
            // would have had in sequential playback from the range start.
            if (startFrame > 0) {
                onProgress && onProgress({ frame: 0, total, fraction: 0, stage: 'warming up' });
                show.warmUp(startFrame, show.warmupFrames());
            } else {
                show.engine.compositor.reset();
            }

            for (let i = 0; i < total; i++) {
                if (this.cancelled) throw new Error('export cancelled');

                const frameIndex = startFrame + i;
                const target = show.renderFrame(frameIndex);
                show.present(target);   // encode from the canvas, which now holds this frame

                const timestamp = Math.round(((frameIndex - startFrame) * 1e6) / fps);
                const videoFrame = new VideoFrame(show.engine.renderer.canvas, {
                    timestamp,
                    duration: Math.round(1e6 / fps),
                });
                // Keyframe every two seconds: seekable output without bloating size.
                videoEncoder.encode(videoFrame, { keyFrame: i % (fps * 2) === 0 });
                videoFrame.close();

                // Yield periodically so the progress UI paints and the encoder
                // queue drains rather than growing without bound.
                if (i % 10 === 0) {
                    while (videoEncoder.encodeQueueSize > 30) {
                        await new Promise((r) => setTimeout(r, 4));
                    }
                    onProgress && onProgress({
                        frame: i, total, fraction: i / total, stage: 'rendering',
                    });
                    await new Promise((r) => setTimeout(r, 0));
                }
                if (errors.length) throw errors[0];
            }

            onProgress && onProgress({ frame: total, total, fraction: 1, stage: 'finishing video' });
            await videoEncoder.flush();

            if (hasAudio) {
                onProgress && onProgress({ frame: total, total, fraction: 1, stage: 'encoding audio' });
                await this._encodeAudio(muxer, startFrame, endFrame, fps);
            }

            muxer.finalize();
            return new Blob([muxer.target.buffer], { type: 'video/mp4' });
        } finally {
            try { videoEncoder.close(); } catch { /* already closed */ }
            show.setSize(previousWidth, previousHeight);
        }
    }

    /** Encode the exported time range of the decoded PCM to AAC and mux it. */
    async _encodeAudio(muxer, startFrame, endFrame, fps) {
        const buffer = this.show.audioBuffer;
        const sampleRate = buffer.sampleRate;
        const channels = Math.min(2, buffer.numberOfChannels);

        const startSample = Math.floor((startFrame / fps) * sampleRate);
        const endSample = Math.min(buffer.length, Math.ceil((endFrame / fps) * sampleRate));
        const length = Math.max(0, endSample - startSample);
        if (!length) return;

        const errors = [];
        const encoder = new AudioEncoder({
            output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
            error: (e) => errors.push(e),
        });
        encoder.configure({
            codec: 'mp4a.40.2', sampleRate, numberOfChannels: channels, bitrate: 192_000,
        });

        const chunkFrames = 1024;
        const interleaved = new Float32Array(chunkFrames * channels);
        const sources = [];
        for (let c = 0; c < channels; c++) sources.push(buffer.getChannelData(c));

        for (let offset = 0; offset < length; offset += chunkFrames) {
            const count = Math.min(chunkFrames, length - offset);
            for (let i = 0; i < count; i++) {
                for (let c = 0; c < channels; c++) {
                    interleaved[i * channels + c] = sources[c][startSample + offset + i];
                }
            }
            const data = new AudioData({
                format: 'f32',
                sampleRate,
                numberOfFrames: count,
                numberOfChannels: channels,
                timestamp: Math.round((offset / sampleRate) * 1e6),
                data: interleaved.slice(0, count * channels),
            });
            encoder.encode(data);
            data.close();
            if (errors.length) throw errors[0];
            if (offset % (chunkFrames * 64) === 0) await new Promise((r) => setTimeout(r, 0));
        }
        await encoder.flush();
        encoder.close();
    }
}

/** Render a short range around a frame — the "test render" bridge before a full export. */
export async function exportSegment(show, centreFrame, { seconds = 20, preset = '1080p', onProgress } = {}) {
    const half = Math.round((seconds * show.fps) / 2);
    const start = Math.max(0, centreFrame - half);
    const end = Math.min(show.frameCount, centreFrame + half);
    return new Exporter(show).export({ preset, frameRange: [start, end], onProgress });
}

export function downloadBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 10000);
}
