// A 3D particle field — the proof that the compositor is genuinely hybrid and
// not just a fragment-shader stack.
//
// DETERMINISM: particle positions are ANALYTIC functions of (time, index, seed),
// never integrated frame to frame. An integrated system would accumulate state,
// which would make a seek land somewhere different from sequential playback and
// break export parity. Anything added here must follow the same rule: if you find
// yourself writing `position += velocity * dt`, it belongs in a closed form instead.

export const particleField = {
    name: 'Particle Field',
    family: 'flow',
    kind: 'layer3d',
    // Composited over a background, never used as one: most of the frame is
    // legitimately black, so it is judged on variance rather than luminance and
    // the look generator only picks it as an accent layer.
    role: 'accent',
    // Personality: see look/Personality.js. A point cloud cannot draw the
    // signature form and has no horizon, so it claims only the camera — which
    // it can honour exactly, being the one scene with a real one.
    traits: ['camera'],

    params: {
        count:     { type: 'int',   range: [200, 4000], default: 1200, bias: 'density', noDrift: true },
        size:      { type: 'float', range: [0.01, 0.12], default: 0.04 },
        spread:    { type: 'float', range: [2, 14],     default: 7 },
        swirl:     { type: 'float', range: [0, 2],      default: 0.6, bias: 'motion', rate: true },
        rise:      { type: 'float', range: [-1, 1],     default: 0.25, rate: true },
        depth:     { type: 'float', range: [2, 20],     default: 9 },
        brightness:{ type: 'float', range: [0, 2],      default: 0.8, bias: 'energy' },
        palette:   { type: 'palette', count: 4 },
    },

    reactive: {
        brightness: { feature: 'beat',    amount: 0.5, response: 'spike' },
        size:       { feature: 'bandHigh', amount: 0.2 },
    },

    build({ scene, seed, params, THREE }) {
        const max = 4000;
        const geometry = new THREE.BufferGeometry();
        const positions = new Float32Array(max * 3);
        const colors = new Float32Array(max * 3);
        const phases = new Float32Array(max * 4);   // per-particle constants

        // Mulberry32 inline: build() runs once, and importing the engine's Rng
        // here would couple a scene module to the engine for four lines.
        let state = seed >>> 0;
        const rnd = () => {
            let t = (state += 0x6d2b79f5) >>> 0;
            t = Math.imul(t ^ (t >>> 15), t | 1);
            t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
            return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
        };

        for (let i = 0; i < max; i++) {
            phases[i * 4 + 0] = rnd() * Math.PI * 2;        // orbital phase
            phases[i * 4 + 1] = 0.3 + rnd() * 1.4;          // radius factor
            phases[i * 4 + 2] = rnd();                      // depth position
            phases[i * 4 + 3] = 0.4 + rnd() * 1.2;          // speed factor
        }

        geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
        geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
        geometry.setDrawRange(0, params.count || 1200);

        const material = new THREE.PointsMaterial({
            size: 0.04,
            vertexColors: true,
            transparent: true,
            blending: THREE.AdditiveBlending,
            depthWrite: false,
            sizeAttenuation: true,
        });

        const points = new THREE.Points(geometry, material);
        points.frustumCulled = false;
        scene.add(points);

        return { points, geometry, material, positions, colors, phases, max };
    },

    update({ instance, camera, timeline, features, params, palette, personality, framing }) {
        const { geometry, material, positions, colors, phases, max } = instance;
        const count = Math.min(max, Math.round(params.count));
        const t = timeline.time;

        const spread = params.spread;
        const depth = params.depth;
        const swirl = params.swirl;
        const rise = params.rise;
        const brightness = Math.max(0, params.brightness);

        const colorCount = palette && palette.length ? palette.length : 0;

        for (let i = 0; i < count; i++) {
            const phase = phases[i * 4 + 0];
            const radiusFactor = phases[i * 4 + 1];
            const depthSeed = phases[i * 4 + 2];
            const speed = phases[i * 4 + 3];

            const angle = phase + t * swirl * speed * 0.35;
            const radius = radiusFactor * spread * 0.5;

            // Depth wraps analytically: fract() of a linear ramp, so a seek to
            // any frame reproduces the exact same layout.
            const z = ((depthSeed + t * rise * 0.05 * speed) % 1 + 1) % 1;

            positions[i * 3 + 0] = Math.cos(angle) * radius;
            positions[i * 3 + 1] = Math.sin(angle) * radius * 0.6
                                 + Math.sin(t * 0.4 * speed + phase) * 0.6;
            positions[i * 3 + 2] = -z * depth;

            // Fade with depth so the field reads as volume rather than confetti.
            const fade = (1 - z) * brightness;
            if (colorCount) {
                const c = palette[i % colorCount];
                colors[i * 3 + 0] = c[0] * fade;
                colors[i * 3 + 1] = c[1] * fade;
                colors[i * 3 + 2] = c[2] * fade;
            } else {
                colors[i * 3 + 0] = colors[i * 3 + 1] = colors[i * 3 + 2] = fade;
            }
        }

        geometry.setDrawRange(0, count);
        geometry.attributes.position.needsUpdate = true;
        geometry.attributes.color.needsUpdate = true;
        material.size = params.size;
        material.opacity = 1;

        // The track's camera, applied to the only literal camera in the library:
        // the same slow returning pan, sway and roll every shader scene fakes in
        // its coordinate space. Bounded and periodic, so a seek still lands on
        // the same frame as sequential playback.
        // The shot's framing, applied to the only literal camera in the
        // library. Every fragment scene gets this as a coordinate scale in the
        // shader epilogue; here it is what it actually is — the camera standing
        // closer or further back. Dividing the distance by the scale matches
        // the fragment behaviour, where the coordinate is divided by it.
        const frame = framing || { scale: 1, shift: [0, 0] };
        const dolly = 4 / Math.max(frame.scale, 0.05);

        const cam = personality ? personality.camera : null;
        if (cam) {
            const pan = 20 * Math.sin(t * 0.05);
            camera.position.set(
                Math.cos(cam.driftAngle) * cam.driftRate * pan
                    + Math.sin(t * cam.swayRate) * cam.sway + frame.shift[0],
                Math.sin(cam.driftAngle) * cam.driftRate * pan
                    + Math.cos(t * cam.swayRate * 0.83) * cam.sway + frame.shift[1],
                dolly,
            );
            camera.rotation.z = cam.spin * t;
        } else {
            camera.position.set(frame.shift[0], frame.shift[1], dolly);
            camera.rotation.z = 0;
        }
        camera.lookAt(camera.position.x, camera.position.y, -depth * 0.4);
    },
};

export default particleField;
