// A whole video reduced to one signature, and the distance between two of them.
//
// A single frame is not a video. Two seeds could open on identical-looking
// frames and diverge completely by the drop, or — the failure we actually
// suspect — differ frame by frame while following the same arc from the same
// kind of image to the same kind of image. So a signature samples the track at
// several points, keeps the mean (what this video looks like) AND the spread
// (how much it changes over its own length), and carries a motion block.
//
// The spread is not decoration. It gives the metric its floor: if two different
// seeds are no further apart than one seed is from itself five minutes later,
// then seed has stopped being a meaningful input, and that comparison is the
// honest way to say so.

import { frameDescriptor, motionDescriptor } from './descriptors.js';

/** Blocks that count toward the structural score. Colour is measured, not counted. */
export const STRUCTURAL = ['scale', 'orient', 'layout', 'texture', 'motion'];
export const ALL_BLOCKS = [...STRUCTURAL, 'colour'];

/**
 * Per-block weights.
 *
 * Flat, on purpose. Every weighting we could justify would be a guess about
 * which kind of sameness matters most, and the report breaks the score down by
 * block anyway — so the diagnosis survives even if the single number is
 * weighted wrong.
 */
const WEIGHTS = { scale: 1, orient: 1, layout: 1, texture: 1, motion: 1 };

/**
 * Chi-square distance, 0..1, for the blocks that are normalised histograms.
 *
 * Cosine was the first choice and it was wrong here: on all-positive histograms
 * every pair scores as similar, and two genuinely unrelated scenes came out at
 * 0.05 — a range too compressed to tell "somewhat alike" from "identical".
 * Chi-square weights a difference by how small the bins involved are, which is
 * what makes a shift of energy from fine detail to coarse blobs read as the
 * large change it looks like.
 */
function chiSquare(a, b) {
    let sa = 0, sb = 0;
    for (let i = 0; i < a.length; i++) { sa += a[i]; sb += b[i]; }
    sa = sa || 1e-9; sb = sb || 1e-9;
    let d = 0;
    for (let i = 0; i < a.length; i++) {
        const x = a[i] / sa, y = b[i] / sb;
        const den = x + y;
        if (den > 1e-12) d += (x - y) ** 2 / den;
    }
    return Math.max(0, Math.min(1, d / 2));
}

// Per-dimension spans for the texture block, whose entries are not a histogram:
// crossing rates, sparsity fractions, and three correlations in -1..1.
const TEXTURE_SPAN = [1, 0.5, 0.5, 0.2, 2, 2, 2];

function scaledL1(a, b, spans) {
    let d = 0;
    for (let i = 0; i < a.length; i++) d += Math.min(1, Math.abs(a[i] - b[i]) / spans[i]);
    return d / a.length;
}

function blockDistance(name, a, b) {
    if (name === 'texture') return scaledL1(a, b, TEXTURE_SPAN);
    if (name === 'colour') {
        // Hue distribution, then saturation and brightness, weighted so a hue
        // rotation reads as the large colour change it is.
        return 0.7 * chiSquare(a.slice(0, 6), b.slice(0, 6)) +
               0.3 * scaledL1(a.slice(6), b.slice(6), [1, 1]);
    }
    return chiSquare(a, b);
}

/** Distance between two single-frame (or motion) descriptors, per block. */
export function descriptorDistance(a, b) {
    const byBlock = {};
    for (const block of ALL_BLOCKS) {
        if (!a[block] || !b[block]) continue;
        byBlock[block] = blockDistance(block, a[block], b[block]);
    }
    return byBlock;
}

/**
 * Probe points across the track, each labelled with WHAT it is.
 *
 * Taken at section midpoints: a section boundary is where the look is designed
 * to change, so sampling across boundaries is what makes the signature describe
 * the video rather than one shot of it.
 *
 * The label is what lets two DIFFERENT songs be compared. They have different
 * section counts, so probe 3 of one is not probe 3 of the other and matching by
 * index would compare a drop against an outro and call the difference variety.
 * Keyed by kind and by which occurrence of that kind it is, the comparison is
 * this song's second drop against that song's second drop — the only pairing a
 * viewer would accept as fair.
 */
export function probeFrames(show, count = 6) {
    const sections = show.look.sections;
    const step = Math.max(1, sections.length / count);
    const seen = new Map();
    const probes = [];

    for (let i = 0; i < sections.length && probes.length < count; i += step) {
        const section = sections[Math.floor(i)];
        if (!section) continue;
        const ordinal = seen.get(section.kind) || 0;
        seen.set(section.kind, ordinal + 1);
        probes.push({
            frame: section.startFrame + Math.floor((section.endFrame - section.startFrame) * 0.5),
            key: `${section.kind}#${ordinal}`,
            kind: section.kind,
        });
    }

    // A short track can run out of sections before it runs out of probe budget.
    // Rather than resample the same midpoints, spread extra probes inside the
    // sections it does have — a two-section song still has a beginning, a middle
    // and an end worth measuring.
    for (let pass = 1; probes.length < count && pass < 4; pass++) {
        for (const section of sections) {
            if (probes.length >= count) break;
            const span = section.endFrame - section.startFrame;
            const at = section.startFrame + Math.floor(span * (pass / (pass + 2)));
            probes.push({ frame: at, key: `${section.kind}@${pass}`, kind: section.kind });
        }
    }
    return probes.slice(0, count);
}

/**
 * Render a loaded Show and reduce it to a signature.
 *
 * `warmup` frames are rendered before each probe so feedback and stateful
 * layers are converged — an unwarmed probe measures the trail of a black frame,
 * which is a structure all seeds share and would flatten the metric on its own.
 *
 * @returns {{blocks: object, probes: object[], drift: number, motion: number}}
 */
export function videoSignature(show, { probes = 6, gap = 5, warmup = 20 } = {}) {
    const width = show.engine.width ?? show.engine.renderer.width;
    const height = show.engine.height ?? show.engine.renderer.height;
    const frames = probeFrames(show, probes);
    const perProbe = [];

    for (const probe of frames) {
        const frame = probe.frame;
        show.engine.compositor.reset();
        const start = Math.max(0, frame - warmup);
        for (let f = start; f < frame; f++) show.renderFrame(f);

        const a = Uint8Array.from(show.readPixels(show.renderFrame(frame)));
        const b = Uint8Array.from(show.readPixels(show.renderFrame(frame + gap)));
        const still = frameDescriptor(a, width, height);
        const moved = motionDescriptor(a, b, width, height);
        perProbe.push({
            ...still,
            motion: moved.scale.concat(moved.layout),
            energy: moved.energy,
            key: probe.key,
            kind: probe.kind,
        });
    }

    // Self-distance: how far this video travels from itself over its own length.
    let drift = 0, pairs = 0;
    for (let i = 0; i < perProbe.length; i++) {
        for (let j = i + 1; j < perProbe.length; j++) {
            drift += blockMean(descriptorDistance(perProbe[i], perProbe[j]));
            pairs++;
        }
    }

    return {
        frames,
        probes: perProbe,
        drift: pairs ? drift / pairs : 0,
        motion: perProbe.reduce((a, p) => a + p.energy, 0) / perProbe.length,
    };
}

function blockMean(byBlock) {
    let sum = 0, weight = 0;
    for (const block of STRUCTURAL) {
        if (byBlock[block] === undefined) continue;
        sum += byBlock[block] * WEIGHTS[block];
        weight += WEIGHTS[block];
    }
    return weight ? sum / weight : 0;
}

/**
 * Structural distance between two video signatures, 0..1.
 *
 * MATCHED probes, not averaged descriptors. The track is held fixed while the
 * seed varies, so probe i is the same moment of the same song in both videos,
 * and comparing them is the closest thing to sitting two renders side by side.
 *
 * The first version of this averaged each video's probes into one descriptor
 * and compared those. It made the score meaningless: averaging six moments
 * washes out exactly the structure being measured, so two seeds scored as
 * closer to each other than one seed scored to ITSELF five minutes later — the
 * floor came out above the ceiling. Matched probes put the between-seed
 * distance and the within-video drift on the same scale, which is the only
 * reason the ratio between them means anything.
 *
 * `byBlock` is the whole point of the return value — a bad total is only
 * actionable once you know which block collapsed.
 */
export function signatureDistance(a, b) {
    // Pair probes by their LABEL, not their position: drop#0 against drop#0.
    // Two seeds of one song produce the same labels in the same order, so this
    // is identical to index matching there — it only starts mattering when the
    // songs differ, which is exactly when index matching would compare a drop
    // against an outro and score the mismatch as variety.
    const byKey = new Map(b.probes.map((p) => [p.key, p]));
    const pairs = [];
    for (const probe of a.probes) {
        const other = byKey.get(probe.key);
        if (other) pairs.push([probe, other]);
    }
    // Too few shared labels to average over. A two-section ambient track shares
    // only its outro with a five-section club track, and a distance computed
    // from one probe pair is not comparable to one computed from five — which
    // silently made the comparison depend on how alike the two ARRANGEMENTS
    // were, the very thing being measured. Below three matches, fall back to
    // position so every pair is averaged over the same number of probes.
    if (pairs.length < 3) {
        pairs.length = 0;
        const n = Math.min(a.probes.length, b.probes.length);
        for (let i = 0; i < n; i++) pairs.push([a.probes[i], b.probes[i]]);
    }

    const sums = {};
    for (const [x, y] of pairs) {
        const d = descriptorDistance(x, y);
        for (const [block, v] of Object.entries(d)) {
            sums[block] = (sums[block] || 0) + v / pairs.length;
        }
    }
    return { total: blockMean(sums), byBlock: sums, matched: pairs.length };
}

export { blockMean };
