import { createLayer } from '../engine/Layer.js';
import { Rng } from '../engine/rng.js';
import { clampValue } from '../params/schema.js';
import { paletteShiftAt } from './paletteArc.js';
import { shiftPalette } from './palette.js';
import { frameShot, neutralFraming } from './framing.js';

/**
 * Drives the look across the song.
 *
 * Four timescales are stacked here, and it takes all four to keep six minutes
 * from reading as a loop:
 *
 *   per frame    — reactive mappings (handled in Layer, from the feature row)
 *   per shot     — cuts between the section's stage visuals, on phrase lines
 *   per section  — seeded LFO drift, so nothing sits still during a long sustain
 *   whole song   — scene changes at real boundaries, plus lookahead ramps that
 *                  build INTO a drop rather than reacting after it lands
 *
 * The shot level is what stops a ninety-second sustain from being one held
 * image. Everything below works in CUES — a flat list of (section, shot) spans
 * built from the look, so a shot cut and a section change take exactly the same
 * code path and differ only in how long the transition is. See look/shots.js.
 *
 * Layer instances are created once per (section, variant) and reused across
 * every shot that shows that variant. Rebuilding them per shot would recompile
 * shaders at every cut, which is the obvious way to make this unusably slow.
 */
export class ArcDriver {
    constructor(look, track, { crossfadeBars = 1, driftAmount = 0.09 } = {}) {
        this.look = look;
        this.track = track;
        this.driftAmount = driftAmount;

        const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / track.fps;
        this.crossfadeFrames = Math.max(12, Math.round(barSeconds * crossfadeBars * track.fps));
        this.barFrames = Math.max(1, Math.round(barSeconds * track.fps));

        this.layerCache = new Map();
        this.driftPlans = new Map();
        this.activeLayers = [];
        this.cues = this._buildCues();
        this.framingStyle = (look.framing && look.framing.mode !== 'locked')
            ? look.framing : null;
        this._planFraming();
        this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
    }

    /**
     * Give every cue the framing its shot will be played with.
     *
     * Framing is the one device that lives at the SHOT level — a wide answered
     * by a close is the whole point of it, and both belong to the same scene.
     * Planned here, once, walking the cues in order, so the whole video gets a
     * consistent hand and a seek always finds the same framing as playback.
     * A track that decided to stay locked-off (or a hand-built look with no
     * framing) gets the neutral, unframed read everywhere.
     */
    _planFraming() {
        if (!this.framingStyle) return;
        const rng = new Rng((this.look.seed ^ 0x517cc1b7) >>> 0);
        let previous = null;
        for (const cue of this.cues) {
            const section = this.look.sections[cue.sectionIndex];
            const energy = (section.bias && section.bias.energy) || 0;
            const framing = frameShot(this.framingStyle, previous, energy, rng);
            cue.framing = framing;
            previous = framing;
        }
    }

    dispose() {
        for (const layer of this.layerCache.values()) layer.dispose();
        this.layerCache.clear();
    }

    /**
     * Flatten the look into cues: one per shot, in playback order.
     *
     * A look generated before shots existed (or hand-built by a check) has no
     * `shots` array; it degrades to exactly one cue per section, which is the
     * old behaviour.
     */
    _buildCues() {
        const cues = [];
        this.look.sections.forEach((section, sectionIndex) => {
            const shots = (section.shots && section.shots.length) ? section.shots : [{
                startFrame: section.startFrame, endFrame: section.endFrame,
                variant: 0, hardCut: false,
            }];
            const energy = (section.bias && section.bias.energy) || 0;
            shots.forEach((shot, shotIndex) => {
                const atSectionStart = shotIndex === 0;
                const span = shot.endFrame - shot.startFrame;
                cues.push({
                    index: cues.length,
                    sectionIndex,
                    shotIndex,
                    variant: shot.variant || 0,
                    startFrame: shot.startFrame,
                    endFrame: shot.endFrame,
                    atSectionStart,
                    fadeFrames: shot.hardCut && !atSectionStart
                        ? Math.max(2, Math.round(this.track.fps * 0.06))
                        : this._dissolveFrames(energy, span),
                });
            });
        });
        return cues;
    }

    /**
     * How long a dissolve takes: the default transition, and deliberately slow.
     *
     * Two bars on calm material, one on loud — a long dissolve between two
     * quiet scenes reads as the image evolving, while the same length under a
     * drop reads as mush, because both images are moving too fast to overlay.
     * Capped at 40% of the incoming shot so a transition never occupies most of
     * the shot it is transitioning into.
     */
    _dissolveFrames(energy, spanFrames) {
        const bars = energy > 0.6 ? 1 : 2;
        const wanted = Math.max(this.crossfadeFrames, this.barFrames * bars);
        return Math.max(12, Math.round(Math.min(wanted, spanFrames * 0.4)));
    }

    /** Cue covering a frame. Binary search — a seek can land anywhere. */
    _cueIndexAt(frame) {
        const cues = this.cues;
        let lo = 0;
        let hi = cues.length - 1;
        while (lo < hi) {
            const mid = (lo + hi + 1) >> 1;
            if (cues[mid].startFrame <= frame) lo = mid; else hi = mid - 1;
        }
        return lo;
    }

    /** The cue on screen at a frame. For the UI and the checks. */
    cueAt(frame) {
        return this.cues[this._cueIndexAt(frame)];
    }

    _specFor(sectionIndex, variant, slot) {
        const section = this.look.sections[sectionIndex];
        const stack = (section.variants && section.variants[variant]) || section.layers;
        return stack[slot] || null;
    }

    /** One Layer per (section, variant, layer slot), built lazily and kept. */
    _layerFor(sectionIndex, variant, slot = 0) {
        const key = `${sectionIndex}:${variant}:${slot}`;
        let layer = this.layerCache.get(key);
        if (!layer) {
            const spec = this._specFor(sectionIndex, variant, slot);
            layer = createLayer(spec.module, {
                params: spec.params,
                seed: spec.seed,
                opacity: spec.opacity,
                blend: spec.blend,
            });
            layer.setPalette(this.look.palette);
            layer.setPersonality(this.look.personality);
            this.layerCache.set(key, layer);
        }
        return layer;
    }

    /**
     * Per-param LFO plan for a section: amplitude, period and phase, all seeded.
     * Slow enough to read as evolution rather than wobble — 20 to 70 seconds.
     */
    _driftPlan(sectionIndex, variant, slot = 0) {
        const key = `${sectionIndex}:${variant}:${slot}`;
        let plan = this.driftPlans.get(key);
        if (plan) return plan;

        const spec = this._specFor(sectionIndex, variant, slot);
        const rng = new Rng(spec.seed ^ 0x5bf03635);
        plan = [];
        for (const [name, def] of Object.entries(spec.module.params || {})) {
            if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue;
            if (def.noDrift || def.rate) continue;   // see schema.js RATE_FLAG
            const [lo, hi] = def.range || [0, 1];
            plan.push({
                name,
                def,
                amplitude: (hi - lo) * this.driftAmount * rng.range(0.4, 1.3),
                period: rng.range(20, 70),
                phase: rng.next(),
            });
        }
        this.driftPlans.set(key, plan);
        return plan;
    }

    /**
     * The scene's SLOW AXIS: one or two params that travel one way across the
     * whole track.
     *
     * Drift above is an LFO with a 20-70 second period, and an LFO returns.
     * Measured over the library, that is exactly what several scenes' problem
     * was: they change as much in half a second as in two minutes, because
     * everything moving in them is cyclic, so the eye adapts in about two
     * seconds and then there is nothing left to find. Violently animated and
     * read as static. Ten cycles of a 30-second wobble is not five minutes of
     * anything.
     *
     * So this is deliberately monotonic. Where drift is the wobble, this is the
     * journey: the frame at four minutes has a different STRUCTURE — density,
     * scale, count — from the frame at thirty seconds, and no amount of
     * per-frame reactivity substitutes for that.
     *
     * Keyed on the module rather than on the section, so a scene that comes back
     * in the last section arrives further along its own axis rather than
     * resetting. Rate params are excluded for the reason schema.js gives: they
     * multiply absolute time, so moving one jumps the phase.
     */
    _slowAxisFor(module) {
        if (!this._slowAxes) this._slowAxes = new Map();
        const cached = this._slowAxes.get(module.name);
        if (cached) return cached;

        // Stable per (track, scene): the same scene evolves the same way
        // wherever it appears in this video, and differently in the next one.
        let h = (this.look.seed || 1) >>> 0;
        for (let i = 0; i < module.name.length; i++) {
            h = (Math.imul(h ^ module.name.charCodeAt(i), 0x01000193) >>> 0);
        }
        const rng = new Rng(h);

        const eligible = Object.entries(module.params || {}).filter(([, def]) =>
            def.type !== 'palette' && def.type !== 'bool' && !def.fixed
            && !def.rate && !def.noDrift && def.range);

        // A param the scene DECLARES as its axis wins outright, and travels
        // much further than a guessed one.
        //
        // The first version of this picked at random from everything eligible
        // and measured as doing nothing whatsoever: the time-averaged image at
        // thirty seconds and at two and a half minutes differed by the same
        // amount with the axis applied as without it. The reason is that which
        // param you move decides everything. Sweeping Moiré Grid's `width`
        // moves its averaged structure by 0.110 and its `offset` by 0.002, and
        // a random draw finds the second kind almost every time.
        const declared = eligible.filter(([, def]) => def.slowAxis);

        const axis = [];
        if (declared.length) {
            for (const [name, def] of declared) {
                const [lo, hi] = def.range;
                axis.push({
                    name,
                    def,
                    declared: true,
                    // Most of the range. This param was chosen because moving it
                    // is what the scene looks like changing, so a timid walk
                    // wastes the one lever that works.
                    travel: (hi - lo) * rng.range(0.45, 0.7) * (rng.bool() ? 1 : -1),
                });
            }
        } else {
            // Nothing declared: fall back to a guess. Worth keeping — it costs
            // nothing and occasionally lands on something structural — but it is
            // not what makes this mechanism work, and no gate should rely on it.
            const count = Math.min(eligible.length, rng.bool(0.45) ? 2 : 1);
            const pool = rng.shuffle(eligible.slice());
            for (let i = 0; i < count; i++) {
                const [name, def] = pool[i];
                const [lo, hi] = def.range;
                axis.push({
                    name,
                    def,
                    declared: false,
                    travel: (hi - lo) * rng.range(0.25, 0.5) * (rng.bool() ? 1 : -1),
                });
            }
        }
        this._slowAxes.set(module.name, axis);
        return axis;
    }

    /**
     * Base params for a section at a given time: the look's sampled values, plus
     * the slow axis, plus drift, plus the lookahead ramp toward what comes next.
     */
    _paramsAt(cue, slot, time, features) {
        const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
        const out = { ...spec.params };

        // --- slow axis ------------------------------------------------------
        // Eased rather than linear, so the travel is slowest at the head and
        // tail. A video should not open mid-move.
        const duration = Math.max(1e-6, this.track.duration);
        const p = Math.max(0, Math.min(1, time / duration));
        const journey = p * p * (3 - 2 * p);
        for (const item of this._slowAxisFor(spec.module)) {
            const base = out[item.name];
            if (typeof base !== 'number') continue;
            out[item.name] = clampValue(item.def, base + item.travel * (journey - 0.5));
        }

        for (const item of this._driftPlan(cue.sectionIndex, cue.variant, slot)) {
            const base = out[item.name];
            if (typeof base !== 'number') continue;
            const wave = Math.sin(2 * Math.PI * (time / item.period + item.phase));
            out[item.name] = clampValue(item.def, base + wave * item.amplitude);
        }

        // --- lookahead ------------------------------------------------------
        // buildSlope rises through the bars before a higher-energy section. This
        // is the payoff of analysing offline: the visuals arrive at the drop
        // already at tension instead of catching up afterwards.
        const slope = features ? features.buildSlope || 0 : 0;
        if (slope > 0.001) {
            const nextCue = this.cues[cue.index + 1];
            const next = nextCue
                ? this._specFor(nextCue.sectionIndex, nextCue.variant, slot)
                : null;
            if (next && next.module === spec.module) {
                // Same scene either side: ramp the actual target values.
                const target = next.params;
                for (const [name, def] of Object.entries(spec.module.params || {})) {
                    if (def.type === 'palette' || typeof out[name] !== 'number') continue;
                    if (typeof target[name] !== 'number') continue;
                    out[name] = clampValue(def, out[name] + (target[name] - out[name]) * slope);
                }
            } else {
                // Different scene: push the intensity-ish params toward the top
                // of their range so the build still reads as a build.
                for (const [name, def] of Object.entries(spec.module.params || {})) {
                    if (typeof out[name] !== 'number') continue;
                    if (def.bias !== 'energy' && def.bias !== 'density') continue;
                    const hi = (def.range || [0, 1])[1];
                    out[name] = clampValue(def, out[name] + (hi - out[name]) * slope * 0.5);
                }
            }
        }

        return out;
    }

    /**
     * The buildSlope value on the frame before a boundary. Read from the table
     * rather than remembered, so a seek and playback agree.
     *
     * Indexes the typed array DIRECTLY rather than calling track.at(). at()
     * returns a single reused row object, so calling it here — mid-render, while
     * the caller is still holding the row for the current frame — silently
     * rewrites the features the layer is about to read. That produced a render
     * that was correct on every repeat but wrong the first time through, which is
     * exactly the kind of fault the determinism checks exist to surface.
     */
    _boundarySlope(cue) {
        if (!this._slopeCache) this._slopeCache = new Map();
        if (this._slopeCache.has(cue.index)) return this._slopeCache.get(cue.index);

        const frame = Math.max(0, cue.startFrame - 1);
        const value = this.track.tracks.buildSlope[frame] || 0;
        this._slopeCache.set(cue.index, value);
        return value;
    }

    /**
     * Compute the active layer stack for a frame.
     *
     * The transition runs FORWARD from a cue: the outgoing image holds at full
     * opacity while the incoming one fades in over it. That keeps the cue frame
     * itself a clean state, which is what makes a boundary seek exact without
     * warm-up. A hard cut is the same path with a two-frame fade — it is still
     * a ramp rather than a jump, because a single-frame swap of two bright
     * scenes is a flash, and the flash meter is not decorative.
     */
    /**
     * The track's palette, moved to where this frame sits in the arc.
     *
     * Recomputed once per frame rather than once per layer, and memoised on the
     * rounded shift: the movement is slow by design, so consecutive frames
     * almost always want the same colours and the OKLCH round trip is wasted
     * work. Rounding also makes the cache key stable under a seek, which keeps
     * the frame-exactness guarantee — a seeked frame gets bit-identical colours
     * to a played one rather than merely similar ones.
     */
    _paletteAt(frame, features) {
        const arc = this.look.paletteArc;
        if (!arc || arc.mode === 'static') return this.look.palette;

        const shift = paletteShiftAt(arc, {
            progress: frame / Math.max(1, this.track.frameCount - 1),
            sectionKind: this.track.sectionAt(frame).kind,
            features,
        });

        const key = `${shift.hue.toFixed(3)}|${shift.chroma.toFixed(3)}|${shift.lightness.toFixed(3)}`;
        if (this._paletteKey === key) return this._palette;

        this._paletteKey = key;
        this._palette = shiftPalette(this.look.palette, shift);
        return this._palette;
    }

    update(frame, features) {
        const time = frame / this.track.fps;
        const palette = this._paletteAt(frame, features);
        const cueIndex = this._cueIndexAt(frame);
        const cue = this.cues[cueIndex];
        if (!cue) return this.activeLayers;
        const section = this.look.sections[cue.sectionIndex];

        const framesIntoCue = frame - cue.startFrame;
        const previous = cueIndex > 0 ? this.cues[cueIndex - 1] : null;
        const fading = !!previous && framesIntoCue < cue.fadeFrames;
        const t = fading ? framesIntoCue / cue.fadeFrames : 1;
        const eased = t * t * (3 - 2 * t);

        // The shot being played INTO carries its own framing; the shot fading
        // out keeps the framing it was filmed with, so a cut changes the size
        // exactly when the cut changes the image rather than half a beat after.
        const framing = cue.framing || neutralFraming();
        const outgoingFraming = previous ? (previous.framing || neutralFraming()) : framing;

        const layers = [];

        if (fading) {
            // buildSlope is discontinuous at a section boundary by construction:
            // it ramps to ~1 through the bars before the change and is 0
            // immediately after. The outgoing layer is still on screen when that
            // happens, so feeding it the new section's features collapses its
            // lookahead ramp in a single frame — a visible pop precisely at the
            // transition. Hold the slope it had going into the boundary; it
            // finished its build, and it stays there while it fades out. Within a
            // section the slope is continuous, so the live value is correct there.
            const outgoingFeatures = cue.atSectionStart
                ? { ...features, buildSlope: this._boundarySlope(cue) }
                : features;

            for (let slot = 0; slot < this._stackSize(previous); slot++) {
                const spec = this._specFor(previous.sectionIndex, previous.variant, slot);
                const layer = this._layerFor(previous.sectionIndex, previous.variant, slot);
                layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures));
                layer.opacity = slot === 0 ? 1 : spec.opacity;
                layer.blend = slot === 0 ? 'normal' : spec.blend;
                layer.setPalette(palette);
                layer.setPersonality(this.look.personality);
                layer.setFraming(outgoingFraming);
                layers.push(layer);
            }
        }

        for (let slot = 0; slot < this._stackSize(cue); slot++) {
            const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
            const layer = this._layerFor(cue.sectionIndex, cue.variant, slot);
            layer.setParams(this._paramsAt(cue, slot, time, features));
            layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
            layer.blend = slot === 0 ? 'normal' : spec.blend;
            layer.setPalette(palette);
            layer.setPersonality(this.look.personality);
            layer.setFraming(framing);
            layers.push(layer);
        }

        this.state = {
            sectionIndex: cue.sectionIndex,
            shotIndex: cue.shotIndex,
            shotCount: section.shots ? section.shots.length : 1,
            variant: cue.variant,
            kind: section.kind,
            crossfade: fading ? eased : 0,
            sceneName: this._specFor(cue.sectionIndex, cue.variant, 0).module.name,
            buildSlope: features ? features.buildSlope || 0 : 0,
        };

        this.activeLayers = layers;
        return layers;
    }

    _stackSize(cue) {
        const section = this.look.sections[cue.sectionIndex];
        const stack = (section.variants && section.variants[cue.variant]) || section.layers;
        return stack.length;
    }

    /** Layers changed identity — the compositor needs the new list. */
    layersChanged(previous) {
        if (!previous || previous.length !== this.activeLayers.length) return true;
        return this.activeLayers.some((l, i) => l !== previous[i]);
    }

    /** Invalidate caches for one section after an edit or reroll. */
    invalidateSection(sectionIndex) {
        for (const key of [...this.layerCache.keys()]) {
            if (key.startsWith(`${sectionIndex}:`)) {
                this.layerCache.get(key).dispose();
                this.layerCache.delete(key);
                this.driftPlans.delete(key);
            }
        }
        // A reroll re-plans the section's shots, so the cue list is stale too.
        this.cues = this._buildCues();
        this._planFraming();
        this._slopeCache = null;
    }

    invalidateAll() {
        this.dispose();
        this.driftPlans.clear();
        this.cues = this._buildCues();
        this._planFraming();
        this._slopeCache = null;
    }

    /**
     * Create and compile every layer the look will ever show.
     *
     * Layers are otherwise built on first use, which means a shader compile on
     * the frame of a cut — a visible hitch, and there are now many more cuts
     * than there were sections. Paying for all of them once at load is cheaper
     * than paying for one at every transition.
     */
    prewarm(onLayer = null) {
        for (const cue of this.cues) {
            for (let slot = 0; slot < this._stackSize(cue); slot++) {
                const layer = this._layerFor(cue.sectionIndex, cue.variant, slot);
                if (onLayer) onLayer(layer);
            }
        }
        return this;
    }

    /** Push a palette change through without rebuilding layers. */
    setPalette(palette) {
        this.look.palette = palette;
        // The moved palette is memoised on the SHIFT, so a new base palette at
        // an unchanged point in the arc would otherwise keep serving the old
        // colours until the arc happened to move.
        this._paletteKey = null;
        for (const layer of this.layerCache.values()) layer.setPalette(palette);
    }
}
