// Phase 9 gate — the production design.
//
// Phase 8 gave a track more cuts. Watching the result made the next problem
// obvious: the cuts were between images that had nothing in common but their
// palette, which is a slideshow, not a video. Phase 9 gives every track a
// personality — a signature form, a camera, a location and an art direction —
// and requires scenes to express it or sit the track out. See look/Personality.js.
//
// The gate has to answer three questions. Is the personality reproducible? Is
// the casting rule actually enforced? And — the one that matters — does any of
// it reach the screen, or are sixteen scenes quietly ignoring the uniforms?

import { check, expect } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { scenes } from '../scenes/registry.js';
import { defaultValues } from '../params/schema.js';
import { TRAITS, generatePersonality, sceneHonours, MIN_ELIGIBLE_SCENES } from '../look/Personality.js';
import { generateLook } from '../look/LookGenerator.js';
import { Rng } from '../engine/rng.js';
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { frameMaxDelta } from '../engine/hash.js';

const PALETTE = [
    [0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
    [0.98, 0.85, 0.35], [0.55, 0.25, 0.85], [0.2, 0.95, 0.6],
];

let cached = null;
function track9() {
    if (!cached) {
        cached = FeatureTrack.fromAudioBuffer(
            synthesizeSectioned({ bpm: 124, duration: 150, changeAt: 70 }), { fps: 60 });
    }
    return cached;
}

function looks(count = 8) {
    const track = track9();
    return Array.from({ length: count }, (_, i) => generateLook(track, { seed: 3000 + i * 6841 }));
}

const castOf = (look) => look.sections.flatMap((s) => (s.variants || [s.layers]).map((v) => v[0].module));

/** Two personalities differing in exactly one trait, for the "does it show" checks. */
function pairDifferingIn(trait) {
    const a = generatePersonality(summaryStub(), new Rng(11));
    const b = generatePersonality(summaryStub(), new Rng(11));
    switch (trait) {
        case 'shape':
            b.shape = { sides: 6, roundness: 0.05, elongation: 1.3, tilt: 0.7 };
            a.shape = { sides: 0, roundness: 0.5, elongation: 1.0, tilt: 0.0 };
            break;
        case 'camera':
            b.camera = { ...a.camera, driftAngle: 1.1, driftRate: 0.06, sway: 0.06, swayRate: 0.2, spin: 0.05, breathe: 0.05 };
            a.camera = { ...a.camera, driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0.1, spin: 0, breathe: 0 };
            break;
        case 'space':
            a.space = { horizon: 0.34, depth: 0.1, washAngle: 0, wash: 0.1 };
            b.space = { horizon: 0.66, depth: 0.9, washAngle: 2.4, wash: 0.5 };
            break;
        case 'style':
            a.style = { lineWeight: 0.15, softness: 0.2, texture: 0.0, symmetry: 1 };
            b.style = { lineWeight: 0.95, softness: 0.9, texture: 0.5, symmetry: 4 };
            break;
    }
    return [a, b];
}

function summaryStub() {
    return { meanCentroid: 0.5, meanFlatness: 0.2, bpm: 124 };
}

function makeEngine(width = 160, height = 90) {
    const engine = new Engine({ width, height });
    const track = track9();
    engine.timeline.setDuration(track.duration);
    engine.setFeatureProvider(featureProviderFor(track));
    return engine;
}

/** Render one scene twice under two personalities and report the largest difference. */
function deltaUnder(module, a, b, engine) {
    const spec = {
        module, params: defaultValues(module), seed: 4242,
        opacity: 1, blend: 'normal', palette: PALETTE,
    };
    const capture = (personality) => {
        engine.setLayerSpecs([{ ...spec, personality }]);
        engine.prime(420);
        engine.compositor.reset();
        return Uint8Array.from(engine.readPixels(engine.renderFrame(420)));
    };
    return frameMaxDelta(capture(a), capture(b));
}

check(9, 'the personality is reproducible from the seed', () => {
    const track = track9();
    const a = generateLook(track, { seed: 77 }).personality;
    const b = generateLook(track, { seed: 77 }).personality;
    const c = generateLook(track, { seed: 78 }).personality;

    const same = JSON.stringify(a) === JSON.stringify(b);
    const different = JSON.stringify(a) !== JSON.stringify(c);
    return expect(same && different,
        `same seed identical: ${same} · different seed differs: ${different} · ` +
        `signature ${a.signature.join('+')}`);
});

check(9, 'every trait has enough scenes to build a track from', () => {
    // The casting rule only works if the library can staff it. A trait declared
    // by three scenes cannot carry a track — the rosters would collapse and every
    // section would show the same two images, which is Phase 8 undone.
    const counts = TRAITS.map((t) => [t, scenes.filter((m) => m.role !== 'accent'
        && sceneHonours(m, [t])).length]);
    const thin = counts.filter(([, n]) => n < MIN_ELIGIBLE_SCENES);
    return expect(thin.length === 0,
        counts.map(([t, n]) => `${t}:${n}`).join(' ') +
        (thin.length ? ` — too thin: ${thin.map(([t]) => t).join(', ')}` : ''));
});

check(9, 'casting leans hard on the track\'s signature', () => {
    // This used to demand that EVERY cast scene honour the signature, because
    // the signature was a hard filter. It is a weight now — measured, the filter
    // was the largest single cause of sameness in the generator, disqualifying
    // eleven scenes into half of all videos and leaving a third of the library
    // uncastable. See look/Personality.js signatureWeight.
    //
    // So the claim being checked changes shape. A track may reach outside its
    // signature; what it must not do is stop leaning on it. Honouring scenes
    // should dominate the cast by a wide margin, and the anchor of each section
    // — the scene that opens it and returns most often — should almost always
    // honour it.
    let honoured = 0, total = 0, anchorsHonoured = 0, anchors = 0;
    for (const look of looks()) {
        const signature = look.personality.signature;
        for (const module of castOf(look)) {
            total++;
            if (sceneHonours(module, signature)) honoured++;
        }
        for (const section of look.sections) {
            anchors++;
            if (sceneHonours(section.layers[0].module, signature)) anchorsHonoured++;
        }
    }
    const share = total ? honoured / total : 0;
    const anchorShare = anchors ? anchorsHonoured / anchors : 0;

    // A signature of two traits leaves roughly a quarter of the library eligible,
    // so chance alone would land near 0.25. Anything close to that means the
    // weighting has stopped meaning anything.
    return expect(share >= 0.55 && anchorShare >= 0.6,
        `${(share * 100).toFixed(0)}% of cast scenes honour the signature ` +
        `(chance ~25%), ${(anchorShare * 100).toFixed(0)}% of section anchors do`);
});

check(9, 'the signature still leaves a track enough scenes to cut between', () => {
    // The failure mode of a hard filter: a personality so specific that the
    // whole video is two scenes. The fallback in pickSignature exists for this,
    // and this is what proves it fires.
    const problems = [];
    for (const look of looks()) {
        const distinct = new Set(castOf(look).map((m) => m.name));
        if (distinct.size < 3) {
            problems.push(`${look.personality.signature.join('+')}: only ${distinct.size} scenes`);
        }
    }
    return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'all casts 3+ scenes');
});

check(9, 'every declared trait visibly changes the scene that declares it', () => {
    // The check that would have caught the whole thing being decorative. The
    // lint proves a scene MENTIONS the trait; only rendering proves it matters.
    // One LSB is the tolerance the determinism checks already treat as noise, so
    // anything at or under it counts as ignored.
    const engine = makeEngine();
    const problems = [];
    const measured = [];
    try {
        for (const trait of TRAITS) {
            const [a, b] = pairDifferingIn(trait);
            for (const module of scenes) {
                if (!(module.traits || []).includes(trait)) continue;
                if (module.kind !== 'fragment') continue;   // 3D layers move the camera, not the frame
                const delta = deltaUnder(module, a, b, engine);
                measured.push(delta);
                if (delta <= 1) problems.push(`${module.name}/${trait}: delta ${delta}`);
            }
        }
    } finally {
        engine.dispose();
    }
    const worst = Math.min(...measured);
    return expect(problems.length === 0,
        problems.slice(0, 4).join(' · ') ||
        `${measured.length} scene/trait pairs, weakest response delta ${worst}`);
}, { slow: true });

check(9, 'a personality changes the whole cast, not one scene', () => {
    // Coherence, measured the only way it can be: if the track's design reaches
    // every scene it cast, then changing the design changes every one of them.
    const engine = makeEngine();
    try {
        const track = track9();
        const look = generateLook(track, { seed: 4242 });
        const [a, b] = pairDifferingIn(look.personality.signature[0] || 'camera');

        const cast = [...new Map(castOf(look).map((m) => [m.name, m])).values()]
            .filter((m) => m.kind === 'fragment');
        const deltas = cast.map((m) => [m.name, deltaUnder(m, a, b, engine)]);
        const unmoved = deltas.filter(([, d]) => d <= 1);

        return expect(unmoved.length === 0,
            unmoved.length
                ? `unmoved: ${unmoved.map(([n]) => n).join(', ')}`
                : `${deltas.length} cast scenes all respond · ` +
                  deltas.map(([n, d]) => `${n} ${d}`).join(', '));
    } finally {
        engine.dispose();
    }
}, { slow: true });

check(9, 'a layer with no personality renders what it always rendered', () => {
    // The neutral-default promise. Every range sweep, flash sweep and library
    // regression builds layers directly with no personality attached, and they
    // all compare against numbers recorded before this phase existed.
    const engine = makeEngine();
    try {
        const module = scenes.find((m) => m.kind === 'fragment' && (m.traits || []).length);
        const neutral = {
            shape: { sides: 0, roundness: 0.25, elongation: 1, tilt: 0 },
            camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0.1, spin: 0, breathe: 0 },
            space: { horizon: 0.5, depth: 0, washAngle: 0, wash: 0 },
            style: { lineWeight: 0.5, softness: 0.5, texture: 0, symmetry: 1 },
            signature: [],
        };
        const delta = deltaUnder(module, null, neutral, engine);
        return expect(delta <= 1,
            `${module.name}: no-personality vs explicit-neutral delta ${delta}`);
    } finally {
        engine.dispose();
    }
}, { slow: true });
