import * as THREE from 'three';
import { makePassMaterial } from './Renderer.js';
import {
    BLEND_FRAG, FEEDBACK_FRAG, BRIGHT_FRAG, BLUR_FRAG, COMPOSITE_FRAG, COPY_FRAG,
    BLEND_MODE_IDS,
} from './passes.js';

const DEFAULT_POST = {
    bloom: 0.35,
    bloomThreshold: 0.6,
    bloomKnee: 0.3,
    chroma: 0.15,
    // Grain defaults to absent. What it looks like when a look does ask for it
    // is described by the four fields below — see look/grain.js.
    grain: 0,
    grainScale: 1,
    grainRate: 1,
    grainMask: 0,
    grainChroma: 0,
    vignette: 0.35,
    contrast: 1.05,
    saturation: 1.1,
    lift: 0.0,
    exposure: 1.0,
};

const DEFAULT_FEEDBACK = {
    amount: 0.0,
    decay: 0.9,
    zoom: 0.995,
    rotate: 0.0,
};

/**
 * The layer stack. Layers render into their own target, then blend into an
 * accumulator; the result goes through feedback and the post chain.
 *
 * Every target is explicitly cleared on allocation and on reset, because
 * inheriting stale GPU memory is exactly the kind of thing that makes an export
 * differ from a preview.
 */
export class Compositor {
    constructor(renderer, { width, height } = {}) {
        this.renderer = renderer;
        this.width = width || renderer.width;
        this.height = height || renderer.height;

        this.layers = [];
        this.post = { ...DEFAULT_POST };
        this.feedback = { ...DEFAULT_FEEDBACK };
        this.fade = 1;
        this.soloIndex = -1;    // debug: render one layer alone
        this.postEnabled = true;
        this._primed = new WeakSet();

        this._buildTargets();
        this._buildMaterials();
    }

    _buildTargets() {
        const r = this.renderer;
        const w = this.width, h = this.height;
        const bw = Math.max(1, Math.floor(w / 2));
        const bh = Math.max(1, Math.floor(h / 2));

        this.layerTarget = r.createTarget(w, h, { depth: true });
        this.accumA = r.createTarget(w, h);
        this.accumB = r.createTarget(w, h);
        this.historyA = r.createTarget(w, h, { float: true });
        this.historyB = r.createTarget(w, h, { float: true });
        this.bloomA = r.createTarget(bw, bh);
        this.bloomB = r.createTarget(bw, bh);
        this.outputTarget = r.createTarget(w, h);
    }

    _buildMaterials() {
        this.blendMaterial = makePassMaterial(BLEND_FRAG, {
            u_base: { value: null },
            u_src: { value: null },
            u_amount: { value: 1 },
            u_mode: { value: 0 },
        });
        this.feedbackMaterial = makePassMaterial(FEEDBACK_FRAG, {
            u_current: { value: null },
            u_history: { value: null },
            u_decay: { value: 0.9 },
            u_amount: { value: 0 },
            u_zoom: { value: 0.995 },
            u_rotate: { value: 0 },
            u_aspect: { value: 1 },
        });
        this.brightMaterial = makePassMaterial(BRIGHT_FRAG, {
            u_tex: { value: null },
            u_threshold: { value: 0.6 },
            u_knee: { value: 0.3 },
        });
        this.blurMaterial = makePassMaterial(BLUR_FRAG, {
            u_tex: { value: null },
            u_direction: { value: new THREE.Vector2(0, 0) },
        });
        this.compositeMaterial = makePassMaterial(COMPOSITE_FRAG, {
            u_tex: { value: null },
            u_bloom: { value: null },
            u_bloomAmount: { value: 0 },
            u_chroma: { value: 0 },
            u_grain: { value: 0 },
            u_grainScale: { value: 1 },
            u_grainRate: { value: 1 },
            u_grainMask: { value: 0 },
            u_grainChroma: { value: 0 },
            u_vignette: { value: 0 },
            u_contrast: { value: 1 },
            u_saturation: { value: 1 },
            u_lift: { value: 0 },
            u_exposure: { value: 1 },
            u_fade: { value: 1 },
            u_frame: { value: 0 },
            u_resolution: { value: new THREE.Vector2(1, 1) },
        });
        this.copyMaterial = makePassMaterial(COPY_FRAG, { u_tex: { value: null } });
    }

    setSize(width, height) {
        if (width === this.width && height === this.height) return;
        this.width = width;
        this.height = height;
        this.disposeTargets();
        this._buildTargets();
    }

    /**
     * The compositor does NOT own its layers and never disposes them — the arc
     * driver caches Layer instances across sections and swaps them in and out
     * every crossfade, and disposing on removal would destroy shaders that are
     * about to be reused (and recompile them on the way back).
     */
    setLayers(layers) {
        for (const layer of layers) {
            if (!this._primed.has(layer)) {
                this._primeLayer(layer);
                this._primed.add(layer);
            }
        }
        this.layers = layers;
        return this;
    }

    /**
     * Compile layers that are not on screen yet, so the frame they first appear
     * on does not pay for the link. Used by the arc driver's prewarm pass.
     */
    primeLayers(layers) {
        for (const layer of layers) {
            if (this._primed.has(layer)) continue;
            this._primeLayer(layer);
            this._primed.add(layer);
        }
        return this;
    }

    /**
     * Force a layer's shader program to finish linking before it is used for real.
     *
     * three.js links programs through KHR_parallel_shader_compile, so the first
     * draws after a material is created can run against a program that is not
     * ready and produce wrong output. Measured on the heaviest scene in the
     * library, the first TEN frames rendered differently from every later render
     * of the same frames. Preview hides this — the frames go by and the next pass
     * is correct — but an export renders each frame exactly once, so those frames
     * would ship broken.
     *
     * Rendering a throwaway frame and reading it back is NOT sufficient: measured,
     * it left 3-5 frames still wrong. WebGLRenderer.compile() is the API that
     * actually waits for the link, and it clears the problem completely.
     */
    _primeLayer(layer) {
        try {
            if (layer.material) this.renderer.compileMaterial(layer.material);
            else if (layer.scene && layer.camera) this.renderer.compileScene(layer.scene, layer.camera);
        } catch (err) {
            console.warn('[compositor] priming failed for', layer.module && layer.module.name, err);
        }
    }

    /**
     * Bring the whole chain to a state where the next rendered frame is correct.
     *
     * Shader programs link asynchronously (KHR_parallel_shader_compile), and until
     * they are ready a draw produces wrong output. compile() covers the programs;
     * the discarded frame covers everything else that is lazily created on first
     * use. Cheap, and it converts "the first few frames may be wrong" into "the
     * first few frames were thrown away".
     *
     * The exporter calls this before encoding anything, because an export renders
     * each frame exactly once and has no second chance to get frame 0 right.
     */
    prime(ctx = null) {
        const materials = [
            this.blendMaterial, this.feedbackMaterial, this.brightMaterial,
            this.blurMaterial, this.compositeMaterial, this.copyMaterial,
        ];
        for (const material of materials) {
            try { this.renderer.compileMaterial(material); } catch { /* non-fatal */ }
        }
        for (const layer of this.layers) this._primeLayer(layer);

        if (ctx) {
            try { this.render(ctx); } catch { /* non-fatal */ }
        }
        this.reset();
    }

    setPost(post) {
        this.post = { ...this.post, ...post };
        return this;
    }

    setFeedback(feedback) {
        this.feedback = { ...this.feedback, ...feedback };
        return this;
    }

    /**
     * Wipe all history. Called on seek and before an export run so a render never
     * depends on what was on screen beforehand.
     */
    reset() {
        const r = this.renderer;
        [this.accumA, this.accumB, this.historyA, this.historyB,
         this.bloomA, this.bloomB, this.layerTarget, this.outputTarget]
            .forEach((t) => r.clear(t));
    }

    /**
     * Render one frame. Returns the target holding the finished image, so the
     * caller decides whether it goes to the canvas or to the encoder.
     */
    render(ctx) {
        const r = this.renderer;
        const { timeline, features } = ctx;

        r.clear(this.accumA);
        let accum = this.accumA;
        let spare = this.accumB;

        const active = this.soloIndex >= 0
            ? this.layers.slice(this.soloIndex, this.soloIndex + 1)
            : this.layers;

        for (const layer of active) {
            if (layer.opacity <= 0.001) continue;

            r.clear(this.layerTarget);
            layer.render(r, this.layerTarget, {
                timeline,
                features,
                prevTexture: this.historyA.texture,
            });

            const bu = this.blendMaterial.uniforms;
            bu.u_base.value = accum.texture;
            bu.u_src.value = this.layerTarget.texture;
            bu.u_amount.value = 1.0;   // layer opacity already applied in-shader
            bu.u_mode.value = BLEND_MODE_IDS[layer.blend] ?? 0;
            r.blit(this.blendMaterial, spare);

            const t = accum; accum = spare; spare = t;
        }

        // --- feedback -------------------------------------------------------
        let composited = accum;
        if (this.feedback.amount > 0.001) {
            const fu = this.feedbackMaterial.uniforms;
            fu.u_current.value = accum.texture;
            fu.u_history.value = this.historyA.texture;
            fu.u_decay.value = Math.min(0.99, this.feedback.decay);
            fu.u_amount.value = this.feedback.amount;
            fu.u_zoom.value = this.feedback.zoom;
            fu.u_rotate.value = this.feedback.rotate;
            fu.u_aspect.value = this.width / this.height;
            r.blit(this.feedbackMaterial, this.historyB);
            composited = this.historyB;
            const t = this.historyA; this.historyA = this.historyB; this.historyB = t;
        } else {
            // Keep history tracking the image even when feedback is off, so
            // enabling it mid-track doesn't pop from black.
            this.copyMaterial.uniforms.u_tex.value = accum.texture;
            r.blit(this.copyMaterial, this.historyA);
        }

        if (!this.postEnabled) {
            this.copyMaterial.uniforms.u_tex.value = composited.texture;
            r.blit(this.copyMaterial, this.outputTarget);
            return this.outputTarget;
        }

        // --- bloom ----------------------------------------------------------
        const p = this.post;
        if (p.bloom > 0.001) {
            this.brightMaterial.uniforms.u_tex.value = composited.texture;
            this.brightMaterial.uniforms.u_threshold.value = p.bloomThreshold;
            this.brightMaterial.uniforms.u_knee.value = p.bloomKnee;
            r.blit(this.brightMaterial, this.bloomA);

            const bw = this.bloomA.width, bh = this.bloomA.height;
            for (let i = 0; i < 2; i++) {
                this.blurMaterial.uniforms.u_tex.value = this.bloomA.texture;
                this.blurMaterial.uniforms.u_direction.value.set((1 + i) / bw, 0);
                r.blit(this.blurMaterial, this.bloomB);

                this.blurMaterial.uniforms.u_tex.value = this.bloomB.texture;
                this.blurMaterial.uniforms.u_direction.value.set(0, (1 + i) / bh);
                r.blit(this.blurMaterial, this.bloomA);
            }
        } else {
            r.clear(this.bloomA);
        }

        // --- final grade ----------------------------------------------------
        const cu = this.compositeMaterial.uniforms;
        cu.u_tex.value = composited.texture;
        cu.u_bloom.value = this.bloomA.texture;
        cu.u_bloomAmount.value = p.bloom;
        cu.u_chroma.value = p.chroma;
        cu.u_grain.value = p.grain;
        cu.u_grainScale.value = p.grainScale;
        cu.u_grainRate.value = p.grainRate;
        cu.u_grainMask.value = p.grainMask;
        cu.u_grainChroma.value = p.grainChroma;
        cu.u_vignette.value = p.vignette;
        cu.u_contrast.value = p.contrast;
        cu.u_saturation.value = p.saturation;
        cu.u_lift.value = p.lift;
        cu.u_exposure.value = p.exposure;
        cu.u_fade.value = this.fade;
        cu.u_frame.value = timeline.frame;
        cu.u_resolution.value.set(this.width, this.height);
        r.blit(this.compositeMaterial, this.outputTarget);

        return this.outputTarget;
    }

    /** Present a finished target to the canvas. */
    present(target) {
        this.copyMaterial.uniforms.u_tex.value = target.texture;
        this.renderer.blit(this.copyMaterial, null);
    }

    disposeTargets() {
        [this.layerTarget, this.accumA, this.accumB, this.historyA, this.historyB,
         this.bloomA, this.bloomB, this.outputTarget].forEach((t) => t && t.dispose());
    }

    dispose() {
        this.layers = [];       // owned elsewhere; see setLayers
        this.disposeTargets();
    }
}
