// A frame reduced to a STRUCTURAL descriptor: what shape the image is, not what
// colour it is or which way up.
//
// The problem this exists to solve: two seeds can produce videos that a pixel
// difference calls wildly different — one is teal and rotating left, the other
// is magenta and rotating right — while a viewer calls them the same video.
// Any metric built on raw pixels rewards exactly the variation we do not care
// about. So the descriptor is built to be blind to the cheap axes and sensitive
// to the expensive ones:
//
//   blind to     brightness, contrast, hue, saturation, global rotation
//   sensitive to feature SCALE (fine texture vs big soft blobs), ORIENTATION
//                structure (grid vs radial vs stripes), LAYOUT (centred glow vs
//                full-frame vs banded), and TEXTURE STATISTICS (how many
//                distinct elements, how sparse, how symmetric)
//
// Colour is still measured, but it is kept in its own block and excluded from
// the structural total — so a report can say "your palettes vary, your
// structure does not", which is the distinction the whole exercise is about.
//
// Everything here is plain Float32 maths on a 96x96 luma image. No FFT: a
// Laplacian pyramid gives the radial power spectrum directly, and a magnitude-
// weighted gradient histogram gives the angular one.

export const SIZE = 96;

/** RGBA bytes → square Float32 luma in 0..1, resampled to SIZE x SIZE. */
export function toLuma(pixels, width, height, size = SIZE) {
    const out = new Float32Array(size * size);
    for (let y = 0; y < size; y++) {
        const sy = Math.min(height - 1, Math.floor((y + 0.5) * height / size));
        for (let x = 0; x < size; x++) {
            const sx = Math.min(width - 1, Math.floor((x + 0.5) * width / size));
            const i = (sy * width + sx) * 4;
            out[y * size + x] =
                (0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]) / 255;
        }
    }
    return out;
}

/**
 * Zero mean, unit standard deviation, in place.
 *
 * This is where exposure and contrast leave the metric. Two renders of the same
 * structure at different brightness are the same image after this; that is the
 * point, and it is why the descriptor cannot be fooled by a palette reroll.
 */
export function standardize(img) {
    let mean = 0;
    for (let i = 0; i < img.length; i++) mean += img[i];
    mean /= img.length;
    let variance = 0;
    for (let i = 0; i < img.length; i++) variance += (img[i] - mean) ** 2;
    const sd = Math.sqrt(variance / img.length) || 1e-6;
    for (let i = 0; i < img.length; i++) img[i] = (img[i] - mean) / sd;
    return img;
}

/** 2x2 box downsample. */
function halve(src, w, h) {
    const ow = w >> 1, oh = h >> 1;
    const out = new Float32Array(ow * oh);
    for (let y = 0; y < oh; y++) {
        for (let x = 0; x < ow; x++) {
            const i = (y * 2) * w + x * 2;
            out[y * ow + x] = (src[i] + src[i + 1] + src[i + w] + src[i + w + 1]) * 0.25;
        }
    }
    return out;
}

/** Nearest-neighbour 2x upsample — good enough as the pyramid's low-pass. */
function double(src, w, h) {
    const ow = w * 2, oh = h * 2;
    const out = new Float32Array(ow * oh);
    for (let y = 0; y < oh; y++) {
        for (let x = 0; x < ow; x++) out[y * ow + x] = src[(y >> 1) * w + (x >> 1)];
    }
    return out;
}

/**
 * Laplacian pyramid: one band-pass image per octave.
 *
 * Band k holds the detail that lives at roughly 2^k pixels. The energy across
 * bands IS the radial power spectrum, which is the single most useful thing to
 * know about an abstract image: fine grain, mid-scale filigree and slow blobs
 * are structurally different pictures no matter what colour they are.
 */
export function pyramid(img, size = SIZE, levels = 5) {
    const bands = [];
    let cur = img, w = size, h = size;
    for (let k = 0; k < levels; k++) {
        const low = halve(cur, w, h);
        const up = double(low, w >> 1, h >> 1);
        const band = new Float32Array(cur.length);
        for (let i = 0; i < cur.length; i++) band[i] = cur[i] - up[i];
        bands.push({ data: band, w, h });
        cur = low; w >>= 1; h >>= 1;
    }
    return bands;
}

function rms(a) {
    let s = 0;
    for (let i = 0; i < a.length; i++) s += a[i] * a[i];
    return Math.sqrt(s / a.length);
}

/** Scale block: the radial spectrum, normalised to its own sum so it describes
 *  the SHAPE of the spectrum rather than how contrasty the frame was. */
function scaleBlock(bands) {
    const v = bands.map((b) => rms(b.data));
    const total = v.reduce((a, x) => a + x, 0) || 1e-6;
    return v.map((x) => x / total);
}

const ORIENT_BINS = 12;

/**
 * Orientation block, made rotation-invariant.
 *
 * A magnitude-weighted histogram of gradient direction (mod pi) says whether
 * the image is a grid (two peaks 90 apart), stripes (one peak), radial
 * (flat-ish), or a quasicrystal (five peaks). Rotating the image circularly
 * SHIFTS that histogram, so taking the magnitude of its DFT throws the shift
 * away and keeps the pattern. That is exactly the "not just rotation" property
 * we need: turn a scene 30 degrees and this block does not move.
 */
function orientBlock(band) {
    const { data, w, h } = band;
    const hist = new Float64Array(ORIENT_BINS);
    for (let y = 1; y < h - 1; y++) {
        for (let x = 1; x < w - 1; x++) {
            const i = y * w + x;
            const gx = data[i + 1] - data[i - 1];
            const gy = data[i + w] - data[i - w];
            const mag = Math.hypot(gx, gy);
            if (mag < 1e-6) continue;
            let a = Math.atan2(gy, gx);
            if (a < 0) a += Math.PI;               // direction, not sign
            if (a >= Math.PI) a -= Math.PI;
            hist[Math.min(ORIENT_BINS - 1, Math.floor(a / Math.PI * ORIENT_BINS))] += mag;
        }
    }
    const dc = hist.reduce((a, x) => a + x, 0) || 1e-6;
    const out = [];
    for (let k = 1; k <= 6; k++) {
        let re = 0, im = 0;
        for (let n = 0; n < ORIENT_BINS; n++) {
            const th = -2 * Math.PI * k * n / ORIENT_BINS;
            re += hist[n] * Math.cos(th);
            im += hist[n] * Math.sin(th);
        }
        out.push(Math.hypot(re, im) / dc);
    }
    return out;
}

/**
 * Layout block: where in the frame the structure actually is, on a 4x4 grid.
 *
 * Deliberately NOT rotation-invariant, and kept separate for that reason.
 * "Everything the generator makes is a bright thing in the middle of a dark
 * frame" is a composition failure, and it is invisible to every other block
 * here — the scale and orientation spectra of two differently-composed frames
 * can match perfectly.
 */
function layoutBlock(band, cells = 4) {
    const { data, w, h } = band;
    const grid = new Float64Array(cells * cells);
    const counts = new Float64Array(cells * cells);
    for (let y = 0; y < h; y++) {
        const gy = Math.min(cells - 1, Math.floor(y * cells / h));
        for (let x = 0; x < w; x++) {
            const gx = Math.min(cells - 1, Math.floor(x * cells / w));
            const v = data[y * w + x];
            grid[gy * cells + gx] += v * v;
            counts[gy * cells + gx]++;
        }
    }
    let total = 0;
    for (let i = 0; i < grid.length; i++) {
        grid[i] = Math.sqrt(grid[i] / (counts[i] || 1));
        total += grid[i];
    }
    return Array.from(grid, (x) => x / (total || 1e-6));
}

function corr(a, b) {
    let num = 0, da = 0, db = 0;
    for (let i = 0; i < a.length; i++) { num += a[i] * b[i]; da += a[i] * a[i]; db += b[i] * b[i]; }
    return num / (Math.sqrt(da * db) || 1e-6);
}

/**
 * Texture block: how many things are in the frame, how sparse they are, and how
 * symmetric the composition is.
 *
 * Symmetry earns its place: mirror and 180-degree self-correlation is what
 * separates a kaleidoscope from a drift, and a library that quietly funnels
 * every seed into radially symmetric imagery will show up here as three numbers
 * that never move, when nothing else in the descriptor notices.
 */
function textureBlock(band) {
    const { data, w, h } = band;
    // Zero crossings per row and per column: a proxy for element count that
    // costs nothing and does not care about contrast.
    let rowCross = 0, colCross = 0;
    for (let y = 0; y < h; y++) {
        for (let x = 1; x < w; x++) {
            if ((data[y * w + x] > 0) !== (data[y * w + x - 1] > 0)) rowCross++;
        }
    }
    for (let x = 0; x < w; x++) {
        for (let y = 1; y < h; y++) {
            if ((data[y * w + x] > 0) !== (data[(y - 1) * w + x] > 0)) colCross++;
        }
    }
    const sd = rms(data) || 1e-6;
    let sparse = 0, tail = 0;
    for (let i = 0; i < data.length; i++) {
        const m = Math.abs(data[i]);
        if (m > sd) sparse++;
        if (m > sd * 2.5) tail++;
    }

    const mirrorH = new Float32Array(data.length);
    const mirrorV = new Float32Array(data.length);
    const rot = new Float32Array(data.length);
    for (let y = 0; y < h; y++) {
        for (let x = 0; x < w; x++) {
            mirrorH[y * w + x] = data[y * w + (w - 1 - x)];
            mirrorV[y * w + x] = data[(h - 1 - y) * w + x];
            rot[y * w + x] = data[(h - 1 - y) * w + (w - 1 - x)];
        }
    }

    // Horizontal and vertical measurements are folded into a sum and an
    // absolute difference rather than reported as a pair. A quarter turn SWAPS
    // the two, and a descriptor that changed under a quarter turn would be
    // measuring orientation twice — once here, unintentionally, on top of the
    // orient block that handles it properly. Folded this way the anisotropy
    // survives (a striped frame still reads as anisotropic) but its direction
    // does not.
    const cx = rowCross / (w * h), cy = colCross / (w * h);
    const mh = corr(data, mirrorH), mv = corr(data, mirrorV);
    return [
        cx + cy, Math.abs(cx - cy),
        sparse / data.length, tail / data.length,
        (mh + mv) / 2, Math.abs(mh - mv), corr(data, rot),
    ];
}

/**
 * Colour block. Reported, never counted in the structural score.
 *
 * Its job in the report is adversarial: it is the number that proves a high
 * pixel-difference between two seeds was only ever a palette swap.
 */
function colourBlock(pixels) {
    const hist = new Float64Array(6);
    let sat = 0, val = 0, n = 0;
    for (let i = 0; i < pixels.length; i += 4) {
        const r = pixels[i] / 255, g = pixels[i + 1] / 255, b = pixels[i + 2] / 255;
        const max = Math.max(r, g, b), min = Math.min(r, g, b);
        const d = max - min;
        const s = max <= 0 ? 0 : d / max;
        sat += s; val += max; n++;
        if (d < 1e-4) continue;
        let hue;
        if (max === r) hue = ((g - b) / d + 6) % 6;
        else if (max === g) hue = (b - r) / d + 2;
        else hue = (r - g) / d + 4;
        hist[Math.min(5, Math.floor(hue))] += s * max;
    }
    const total = hist.reduce((a, x) => a + x, 0) || 1e-6;
    return [...Array.from(hist, (x) => x / total), sat / n, val / n];
}

/**
 * Full descriptor for one frame.
 *
 * @param {Uint8Array} pixels RGBA readback
 * @returns {{scale:number[], orient:number[], layout:number[], texture:number[], colour:number[]}}
 */
export function frameDescriptor(pixels, width, height) {
    const luma = standardize(toLuma(pixels, width, height));
    const bands = pyramid(luma);
    // Band 1 (~4px detail) is the working band for orientation, layout and
    // texture: band 0 is dominated by grain and compression-scale noise, and
    // the coarse bands are too small to have a layout worth reading.
    const band = bands[1];
    return {
        scale: scaleBlock(bands),
        orient: orientBlock(band),
        layout: layoutBlock(band),
        texture: textureBlock(band),
        colour: colourBlock(pixels),
    };
}

/**
 * Descriptor of what MOVED between two frames.
 *
 * The absolute difference image, run through the same machinery. This is the
 * block that catches "every scene in the library is a slow full-frame drift":
 * two videos can differ in every static frame and still move identically, and
 * motion is a large part of what a viewer reads as the identity of a visual.
 */
export function motionDescriptor(a, b, width, height) {
    const diff = new Uint8Array(a.length);
    for (let i = 0; i < a.length; i += 4) {
        diff[i] = Math.abs(a[i] - b[i]);
        diff[i + 1] = Math.abs(a[i + 1] - b[i + 1]);
        diff[i + 2] = Math.abs(a[i + 2] - b[i + 2]);
        diff[i + 3] = 255;
    }
    const d = frameDescriptor(diff, width, height);
    let energy = 0;
    for (let i = 0; i < diff.length; i += 4) energy += diff[i] + diff[i + 1] + diff[i + 2];
    energy = energy / ((diff.length / 4) * 3 * 255);
    return { ...d, energy };
}

// --- transforms used to VALIDATE the metric ------------------------------
// A structural metric that has never been shown to ignore colour and rotation
// is a claim, not a measurement. These let the checks prove it.

/** Rotate an RGBA frame by 90 degrees. Returns {pixels, width, height}. */
export function rotate90(pixels, width, height) {
    const out = new Uint8Array(pixels.length);
    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            const si = (y * width + x) * 4;
            const di = (x * height + (height - 1 - y)) * 4;
            out[di] = pixels[si]; out[di + 1] = pixels[si + 1];
            out[di + 2] = pixels[si + 2]; out[di + 3] = pixels[si + 3];
        }
    }
    return { pixels: out, width: height, height: width };
}

/** Rotate the hue of every pixel by `turns` and scale brightness. */
export function recolour(pixels, turns = 0.33, gain = 1.25) {
    const out = new Uint8Array(pixels.length);
    const c = Math.cos(turns * 2 * Math.PI), s = Math.sin(turns * 2 * Math.PI);
    // YIQ hue rotation — cheap, and it leaves luma structure exactly alone.
    const m = [
        0.299 + 0.701 * c + 0.168 * s, 0.587 - 0.587 * c + 0.330 * s, 0.114 - 0.114 * c - 0.497 * s,
        0.299 - 0.299 * c - 0.328 * s, 0.587 + 0.413 * c + 0.035 * s, 0.114 - 0.114 * c + 0.292 * s,
        0.299 - 0.300 * c + 1.250 * s, 0.587 - 0.588 * c - 1.050 * s, 0.114 + 0.886 * c - 0.203 * s,
    ];
    for (let i = 0; i < pixels.length; i += 4) {
        const r = pixels[i], g = pixels[i + 1], b = pixels[i + 2];
        out[i] = Math.max(0, Math.min(255, (m[0] * r + m[1] * g + m[2] * b) * gain));
        out[i + 1] = Math.max(0, Math.min(255, (m[3] * r + m[4] * g + m[5] * b) * gain));
        out[i + 2] = Math.max(0, Math.min(255, (m[6] * r + m[7] * g + m[8] * b) * gain));
        out[i + 3] = pixels[i + 3];
    }
    return out;
}
