import { Engine } from './engine/Engine.js';
import { OSDLayer } from './engine/OSD.js';
import { FeatureTrack, featureProviderFor } from './audio/FeatureTrack.js';
import { decodeFile, monoSamples } from './audio/decode.js';
import { generateLook, rerollLook, rerollSection } from './look/LookGenerator.js';
import { ArcDriver } from './look/ArcDriver.js';
import { grainEnvelope } from './look/grain.js';
import { hashSamples } from './engine/rng.js';

const FADE_SECONDS = 1.5;

/**
 * A loaded track plus its look, rendered.
 *
 * This is the object both the preview and the exporter drive, and the only way
 * they can be guaranteed to agree: neither has its own render path. The preview
 * differs from the export in output resolution and warm-up state, and in nothing
 * else.
 */
export class Show {
    constructor({ canvas = null, width = 1280, height = 720, fps = 60 } = {}) {
        this.engine = new Engine({ canvas, width, height, fps });
        this.fps = fps;
        this.track = null;
        this.look = null;
        this.arc = null;
        this.audioBuffer = null;
        this.fileName = '';
        this.osd = new OSDLayer(this.engine.renderer);
        this.osdEnabled = true;
        this._lastLayers = null;
    }

    get ready() { return !!(this.track && this.look && this.arc); }
    get duration() { return this.track ? this.track.duration : 0; }
    get frameCount() { return this.track ? this.track.frameCount : 1; }
    get timeline() { return this.engine.timeline; }

    /**
     * Decode, analyse, and generate a look. `onProgress(stage, fraction)` is
     * called throughout; analysis is CPU-bound and will block the main thread
     * for a second or two on a long track.
     */
    async load(file, onProgress = null) {
        const report = (stage, p) => onProgress && onProgress(stage, p);

        report('decoding', 0);
        const audioBuffer = await decodeFile(file);
        this.audioBuffer = audioBuffer;
        this.fileName = file.name.replace(/\.[^/.]+$/, '');

        // Yield so the progress UI can paint before the analysis pass blocks.
        await new Promise((r) => setTimeout(r, 0));

        this.track = FeatureTrack.fromAudioBuffer(audioBuffer, {
            fps: this.fps,
            onProgress: (stage, p) => report(stage, p),
        });

        report('look', 0.97);
        const samples = monoSamples(audioBuffer);
        this.setLook(generateLook(this.track, { samples }));

        this.engine.timeline.setDuration(this.track.duration);
        this.engine.setFeatureProvider(featureProviderFor(this.track));

        report('scenes', 0.99);
        this.prewarm();

        report('ready', 1);
        return this;
    }

    /** Attach an already-analysed track. Used by the check harness and by tests. */
    useTrack(track, look) {
        this.track = track;
        this.engine.timeline.setDuration(track.duration);
        this.engine.setFeatureProvider(featureProviderFor(track));
        this.setLook(look || generateLook(track, { seed: 1 }));
        return this;
    }

    setLook(look) {
        if (this.arc) this.arc.dispose();
        this.look = look;
        this.arc = new ArcDriver(look, this.track);
        this.osd.setText(this.fileName, look.personality, look.palette);
        this._lastLayers = null;
        return this;
    }

    reroll(seed) {
        this.setLook(rerollLook(this.look, this.track, seed));
        this.prewarm();
    }

    rerollSection(index, salt) {
        rerollSection(this.look, this.track, index, salt);
        this.arc.invalidateSection(index);
        this._lastLayers = null;
        this.prewarm();
    }

    setPalette(palette) {
        this.look.palette = palette;
        this.arc.setPalette(palette);
        this.osd.setPalette(palette);
    }

    /** Live param edit on a section's primary layer. */
    setSectionParam(sectionIndex, name, value) {
        const section = this.look.sections[sectionIndex];
        if (!section) return;
        section.layers[0].params[name] = value;
        this._lastLayers = null;
    }

    setSize(width, height) {
        this.engine.setSize(width, height);
    }

    /** Fade in at the head and out at the tail; nothing starts or ends abruptly. */
    _fadeAt(frame) {
        const fadeFrames = FADE_SECONDS * this.fps;
        const fromStart = frame;
        const toEnd = this.frameCount - 1 - frame;
        const a = Math.min(1, Math.max(0, fromStart / fadeFrames));
        const b = Math.min(1, Math.max(0, toEnd / fadeFrames));
        return Math.min(a, b);
    }

    /**
     * The post settings for one frame.
     *
     * Everything in the grade is constant per track except grain, which has a
     * time envelope: it can swell in and out, or belong to particular section
     * kinds, or answer transients. See look/grain.js. The envelope is computed
     * from frame and features only, so preview and export agree.
     */
    _postAt(timeline, features) {
        const grain = this.look.grain;
        if (!grain || grain.mode === 'off') return this.look.post;
        if (grain.mode === 'constant') return this.look.post;

        const kind = this.track.sectionAt(timeline.frame)?.kind || '';
        const env = grainEnvelope(grain, { time: timeline.time, sectionKind: kind, features });

        // Reused object: renderFrame runs 60 times a second and setPost copies
        // out of it anyway.
        this._post = this._post || {};
        Object.assign(this._post, this.look.post);
        this._post.grain = grain.amount * env;
        return this._post;
    }

    /** Toggle the title plate. Off removes it from the stack entirely. */
    setOSDEnabled(enabled) {
        this.osdEnabled = !!enabled;
        this._lastLayers = null;
        return this;
    }

    /**
     * Render one frame. Identical in preview and export — the only difference is
     * the size of the target and whether the result is presented or encoded.
     */
    renderFrame(frame) {
        const timeline = this.engine.timeline;
        timeline.seek(frame);

        const features = this.track.at(timeline.frame);
        const sceneLayers = this.arc.update(timeline.frame, features);
        const layers = this.osdEnabled ? [...sceneLayers, this.osd] : sceneLayers;

        if (this._lastLayers === null || this.arc.layersChanged(this._lastLayers)) {
            this.engine.compositor.setLayers(layers);
            this._lastLayers = layers.slice();
        }

        this.engine.compositor
            .setPost(this._postAt(timeline, features))
            .setFeedback(this.look.feedback);
        this.engine.compositor.fade = this._fadeAt(timeline.frame);

        return this.engine.compositor.render({ timeline, features });
    }

    /**
     * Build and compile every layer in the look up front.
     *
     * Without this the first frame of every shot pays for a shader link, which
     * shows as a hitch exactly on the cut. One pass at load costs a few hundred
     * milliseconds and removes all of them.
     */
    prewarm() {
        if (!this.arc) return this;
        this.arc.prewarm();
        this.engine.compositor.primeLayers([this.osd, ...this.arc.layerCache.values()]);
        return this;
    }

    /** See Engine.prime — required before frame-exact rendering. */
    prime(frame = 0) {
        this.prewarm();
        this.renderFrame(frame);               // ensures the arc has built its layers
        this.engine.prime(frame);
        return this;
    }

    /** Advance stateful layers so an arbitrary seek lands on converged state. */
    warmUp(frame, warmupFrames = 120) {
        const start = Math.max(0, frame - warmupFrames);
        this.engine.compositor.reset();
        for (let f = start; f < frame; f++) this.renderFrame(f);
    }

    /**
     * Frames of warm-up needed for the feedback loop to converge.
     *
     * Feedback decays geometrically, so the residual after n frames is decay^n.
     * Converging to 0.1% rather than 1% costs only ~50% more frames and takes the
     * result from "close" to "indistinguishable" — measured, a 1% target still
     * left a visible 0.015 mean difference at heavy settings.
     */
    warmupFrames() {
        const amount = this.look ? this.look.feedback.amount : 0;
        if (amount <= 0.01) return 0;
        const decay = Math.min(0.99, this.look.feedback.decay);
        return Math.min(400, Math.ceil(Math.log(0.001) / Math.log(decay)));
    }

    /**
     * Seek for review.
     *
     * NOTE, corrected from the original plan: a section boundary is NOT exact for
     * free. Layer state is re-seeded there, but the compositor's feedback buffer
     * is global and carries straight across the boundary, so a look with feedback
     * enabled still needs warm-up wherever you land. Resetting feedback at
     * boundaries would make seeks exact at the cost of a visible flash at every
     * transition, which is a much worse trade. Warm-up is cheap; the flash is not.
     */
    seek(frame, { warmup = true } = {}) {
        const frames = warmup ? this.warmupFrames() : 0;
        if (frames > 0) {
            this.warmUp(frame, frames);
        } else {
            this.engine.compositor.reset();
            this.engine.timeline.seek(frame);
        }
        return this.renderFrame(frame);
    }

    present(target) { this.engine.present(target); }
    readPixels(target) { return this.engine.readPixels(target); }
    hashFrame(frame) { return this.engine.hashCurrent(this.renderFrame(frame)); }

    contentSeed() {
        return this.audioBuffer ? hashSamples(monoSamples(this.audioBuffer)) : 0;
    }

    dispose() {
        if (this.arc) this.arc.dispose();
        this.osd.dispose();
        this.engine.dispose();
    }
}
