// Measuring every visualizer, and writing the answers back into the repo.
//
// The library has always had two kinds of fact about a scene. Declared ones —
// family, traits, `consumes` — which say what the scene is FOR, and measured
// ones, which say what it actually does when rendered. Declared facts belong in
// the scene file. Measured facts do not: hand-written, they drift the moment a
// shader changes, and nine scenes declaring `surface: 'canvas'` while painting
// under a third of the frame is what that drift looks like.
//
// So the measured half lives in scenes/metadata.json, generated from here,
// tracked in git, and stamped with a fingerprint of everything that could
// change it. When the fingerprint stops matching, the numbers are stale and the
// phase 12 gate says so — the file is a cache of a render, and a cache nobody
// can tell is stale is worse than no cache.
//
// The measurement is the GALLERY's: six songs, sampled parameters, real
// identities and palettes — a scene as it is actually cast, not as it renders
// at default parameters. The difference is not academic. Salt Flat paints 65%
// of the frame at defaults and 32% across six real songs, and the generator
// chooses grounds with this number.

import { Engine } from '../engine/Engine.js';
import { scenes } from '../scenes/registry.js';
import { galleryContexts, renderScene, THUMB } from './gallery.js';
import { readsHistory } from '../params/schema.js';
import { GROUND_BIAS, groundTemperamentFrom, groundPersonalityFrom } from '../scenes/surface.js';
import metadata from '../scenes/metadata.json';

/**
 * What invalidates the measurements.
 *
 * Deliberately NOT every file under src/, which is what the gallery cache
 * fingerprints: that changes when the UI changes, and it would mark the
 * metadata stale for edits that cannot move a single number. What can move one
 * is the scenes themselves, the contract they are compiled against, the
 * identities and palettes they are handed, and the metric definitions — so
 * those, and nothing else.
 *
 * Globbed rather than listed wherever a whole directory qualifies, because the
 * file that invalidates a measurement is exactly the one nobody remembers to
 * add to a list.
 */
const SOURCES = {
    ...import.meta.glob('/src/scenes/**/*.js', { query: '?raw', import: 'default', eager: true }),
    ...import.meta.glob('/src/checks/variety/descriptors.js', { query: '?raw', import: 'default', eager: true }),
    ...import.meta.glob('/src/checks/variety/signature.js', { query: '?raw', import: 'default', eager: true }),
    ...import.meta.glob('/src/checks/gallery.js', { query: '?raw', import: 'default', eager: true }),
    ...import.meta.glob('/src/checks/metadata.js', { query: '?raw', import: 'default', eager: true }),
    ...import.meta.glob('/src/engine/shader-contract.js', { query: '?raw', import: 'default', eager: true }),
    ...import.meta.glob('/src/look/Identity.js', { query: '?raw', import: 'default', eager: true }),
    ...import.meta.glob('/src/look/Personality.js', { query: '?raw', import: 'default', eager: true }),
    ...import.meta.glob('/src/look/palette.js', { query: '?raw', import: 'default', eager: true }),
    ...import.meta.glob('/src/audio/songbank.js', { query: '?raw', import: 'default', eager: true }),
};

/** The version of the measurement itself. Bump to force a refresh of everything. */
export const SCHEMA = 4;

export function metricsFingerprint() {
    let h = 0x811c9dc5 >>> 0;
    const mix = (str) => {
        for (let i = 0; i < str.length; i++) {
            h ^= str.charCodeAt(i);
            h = Math.imul(h, 0x01000193) >>> 0;
        }
    };
    mix(`schema:${SCHEMA}`);
    for (const path of Object.keys(SOURCES).sort()) {
        mix(path);
        mix(SOURCES[path]);
    }
    return h.toString(16).padStart(8, '0');
}

/** Whether the checked-in metadata was measured from the code that is here now. */
export function metadataIsFresh() {
    return metadata.fingerprint === metricsFingerprint();
}

/** Anything a viewer would read as painted rather than as backdrop. */
function litFraction(pixels) {
    let lit = 0;
    for (let i = 0; i < pixels.length; i += 4) {
        if (pixels[i] + pixels[i + 1] + pixels[i + 2] > 90) lit++;
    }
    return lit / (pixels.length / 4);
}

const round = (x, places = 4) => Number(x.toFixed(places));

/** Mean of the six per-context descriptors, block by block. */
function meanProfile(descriptors) {
    const out = {};
    for (const block of Object.keys(descriptors[0])) {
        const length = descriptors[0][block].length;
        const acc = new Array(length).fill(0);
        for (const d of descriptors) {
            for (let i = 0; i < length; i++) acc[i] += d[block][i] / descriptors.length;
        }
        out[block] = acc.map((v) => round(v));
    }
    return out;
}

/**
 * Measure the whole library.
 *
 * Per scene: how much frame it paints, how much it changes between songs, and
 * its mean structural profile — the descriptor the variety harness compares
 * videos with, averaged over the six renders. The profile is what makes this
 * more than a list of numbers: two profiles can be compared, so the generator
 * can ask whether a shot and the thing under it are the same picture twice.
 *
 * @returns {object} the metadata file's contents
 */
export function measureLibrary({ onScene = null, contexts = null } = {}) {
    const ctx = contexts || galleryContexts(6);
    // The same six songs, sampled the way a BED is. Coverage is mostly a
    // function of a scene's parameters, so "how much does this paint" has two
    // answers and the generator needs both: one for the budget, and one for
    // whether it may be a ground at all. See GROUND_BIAS.
    const bedCtx = ctx.map((c) => ({
        ...c,
        bias: { ...c.bias, ...GROUND_BIAS },
        personality: {
            ...groundPersonalityFrom(c.personality),
            temperament: groundTemperamentFrom(c.personality.temperament),
        },
    }));
    const engine = new Engine({ ...THUMB });
    const out = {};
    try {
        const list = scenes.filter((m) => m.kind === 'fragment');
        for (const module of list) {
            const { thumbs, variety, byBlock, descriptors, error } = renderScene(engine, module, ctx);
            const bed = renderScene(engine, module, bedCtx);
            out[module.name] = {
                coverage: round(thumbs.reduce((s, px) => s + litFraction(px), 0) / thumbs.length, 3),
                // The WORST of the six, not the mean. What a ground has to
                // promise is a filled frame in the video it lands in, and the
                // spread across identities is enormous: a track whose ink
                // treatment is `hollow` draws outlines instead of fills, so a
                // scene that paints 61% averaged over six songs paints 2% in
                // the one that asked for outlines — measured, and it is how a
                // section with a ground under it still rendered near-black.
                // A mean cannot make a promise; a minimum can.
                bedCoverage: round(Math.min(...bed.thumbs.map(litFraction)), 3),
                bedCoverageMean: round(
                    bed.thumbs.reduce((s, px) => s + litFraction(px), 0) / bed.thumbs.length, 3),
                variety: round(variety, 3),
                blocks: Object.fromEntries(
                    Object.entries(byBlock).map(([b, v]) => [b, round(v, 3)])),
                // Declared, not measured, and carried here anyway: it is a fact
                // about the scene that the composition rules read, and having
                // every compositional input in one file is the point.
                readsHistory: readsHistory(module),
                profile: meanProfile(descriptors),
                ...(error ? { error } : {}),
            };
            if (onScene) onScene(Object.keys(out).length, list.length, module.name);
        }
    } finally {
        engine.dispose();
    }

    return {
        fingerprint: metricsFingerprint(),
        schema: SCHEMA,
        measured: new Date().toISOString().slice(0, 10),
        contexts: ctx.map((c) => c.name),
        scenes: out,
    };
}

/** What moved against the checked-in file. */
export function metadataDrift(fresh) {
    // Tolerates a missing or half-written file on purpose: this runs on the way
    // to REPLACING it, and refusing to report because the thing being replaced
    // is malformed is the least useful moment to be strict.
    const previous = (metadata && metadata.scenes) || {};
    const moved = [];
    for (const [name, row] of Object.entries(fresh.scenes)) {
        const was = previous[name];
        if (!was) {
            moved.push({ name, note: 'new' });
            continue;
        }
        const delta = row.coverage - was.coverage;
        if (Math.abs(delta) > 0.02) {
            moved.push({
                name, note: `coverage ${(was.coverage * 100).toFixed(0)}% → ${(row.coverage * 100).toFixed(0)}%`,
                delta,
            });
        }
    }
    const gone = Object.keys(previous).filter((n) => !fresh.scenes[n]);
    return {
        moved: moved.sort((a, b) => Math.abs(b.delta || 0) - Math.abs(a.delta || 0)),
        gone,
    };
}

/**
 * Ask the dev server to write the file back into the source tree.
 *
 * Dev-only by construction — the endpoint is a middleware in vite.config.js. A
 * built page has no source tree to write to, and failing there is correct.
 */
export async function writeMetadata(fresh) {
    const response = await fetch('/__metadata', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(fresh, null, 2) + '\n',
    });
    if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
    return response.text();
}
