// Palette generation.
//
// Colours are built in OKLCH rather than HSL. HSL's lightness is not perceptual —
// pure yellow and pure blue at the same "lightness" differ enormously in how
// bright they look — so an HSL palette with even lightness steps produces a set
// where some colours vanish and others dominate. OKLCH steps look even because
// they are even, which matters a lot when the generator is choosing palettes
// unsupervised and nobody is there to correct a bad one.
//
// Cover art is not available (PLAN.md §Decisions), so everything here derives
// from the audio. `PaletteSource` is the seam: adding a CoverArtPalette later is
// a new implementation of this interface and one line of config, with no change
// to any scene.

/** OKLCH -> sRGB, components 0..1. h in radians. */
export function oklchToRgb(L, C, h) {
    const a = C * Math.cos(h);
    const b = C * Math.sin(h);

    const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
    const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
    const s_ = L - 0.0894841775 * a - 1.2914855480 * b;

    const l = l_ * l_ * l_;
    const m = m_ * m_ * m_;
    const s = s_ * s_ * s_;

    const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
    const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
    const lb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;

    const gamma = (x) => {
        const v = Math.max(0, Math.min(1, x));
        return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
    };
    return [gamma(lr), gamma(lg), gamma(lb)];
}

/**
 * sRGB -> OKLCH, the exact inverse of the above.
 *
 * Needed because a palette is stored as RGB but has to be MOVED perceptually:
 * rotating hue in RGB space changes brightness as a side effect, which is
 * exactly the artefact OKLCH was chosen to avoid in the first place. See
 * look/paletteArc.js.
 */
export function rgbToOklch([r, g, b]) {
    const linear = (x) => {
        const v = Math.max(0, Math.min(1, x));
        return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
    };
    const R = linear(r), G = linear(g), B = linear(b);

    const l = 0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B;
    const m = 0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B;
    const s = 0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B;

    const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s_ = Math.cbrt(s);

    const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
    const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
    const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;

    return [L, Math.hypot(a, bb), Math.atan2(bb, a)];
}

/**
 * Move a whole palette in OKLCH: rotate hue, scale chroma, offset lightness.
 *
 * The rotation is applied to every colour equally, so the relationships that
 * made the palette a palette — its scheme, its spread — survive the move. A
 * per-colour rotation would be a different palette rather than the same one
 * somewhere else.
 */
export function shiftPalette(colors, { hue = 0, chroma = 1, lightness = 0 } = {}) {
    if (!hue && chroma === 1 && !lightness) return colors;
    return colors.map((c) => {
        const [L, C, h] = rgbToOklch(c);
        return oklchToRgb(
            Math.max(0, Math.min(1, L + lightness)),
            Math.max(0, C * chroma),
            h + hue,
        );
    });
}

export function relativeLuminance([r, g, b]) {
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

/**
 * Spread of a palette's luminance and hue. The Phase 3 gate uses this to reject
 * muddy sets — palettes where everything sits at the same brightness read as a
 * single colour once they are composited and bloomed.
 */
export function paletteContrast(colors) {
    if (!colors || colors.length < 2) return { luminanceSpread: 0, chromaSpread: 0 };
    const lums = colors.map(relativeLuminance);
    const luminanceSpread = Math.max(...lums) - Math.min(...lums);

    let chromaSpread = 0;
    for (let i = 0; i < colors.length; i++) {
        for (let j = i + 1; j < colors.length; j++) {
            const d = Math.hypot(
                colors[i][0] - colors[j][0],
                colors[i][1] - colors[j][1],
                colors[i][2] - colors[j][2],
            );
            chromaSpread = Math.max(chromaSpread, d);
        }
    }
    return { luminanceSpread, chromaSpread };
}

const SCHEMES = {
    analogous:  (h, rng) => [h, h + 0.35, h - 0.35, h + 0.7, h - 0.6, h + 1.0],
    complement: (h) => [h, h + Math.PI, h + 0.4, h + Math.PI - 0.4, h + 0.8, h + Math.PI + 0.3],
    triad:      (h) => [h, h + 2.094, h + 4.189, h + 0.5, h + 2.6, h + 4.7],
    split:      (h) => [h, h + 2.6, h + 3.7, h + 0.35, h + 2.9, h + 3.4],
    duo:        (h) => [h, h + 1.9, h + 0.2, h + 2.1, h - 0.25, h + 1.7],
    // Four evenly spaced hues plus two repeats: the widest spread available, and
    // the reason a track can now come out looking like four colours rather than
    // a gradient between two.
    tetrad:     (h) => [h, h + 1.571, h + 3.142, h + 4.712, h + 0.8, h + 2.4],
    // One hue family carrying the frame, with a single far-off pop. Reads as a
    // deliberate art-directed choice rather than as a spectrum.
    accented:   (h) => [h, h + 0.25, h - 0.2, h + 0.45, h + 2.9, h + 3.05],
    // One hue, everything else carried by lightness and chroma. Needs the
    // widened L range below to stay legible, and gives the library the
    // near-monochrome look it could not previously reach at all.
    mono:       (h) => [h, h + 0.12, h - 0.1, h + 0.18, h - 0.16, h + 0.08],
};

export const SCHEME_NAMES = Object.keys(SCHEMES);

/**
 * Stretch a value around a centre so a narrow real-world range fills 0..1.
 *
 * A logistic rather than a linear rescale, because the tails must stay bounded:
 * an unusually bass-heavy track should land at the warm end, not past it.
 */
function expand(x, centre = 0.5, slope = 3.0) {
    return 1 / (1 + Math.exp(-slope * (x - centre) * 4));
}

/** The interface a palette source implements. */
export class PaletteSource {
    /** @returns {number[][]} array of [r,g,b] in 0..1 */
    generate() { throw new Error('PaletteSource.generate not implemented'); }
}

/**
 * Derives a palette from what the track actually sounds like.
 *
 *  - spectral centroid -> hue family. A bass-heavy track lands in deep blues and
 *    violets; a bright one moves toward cyan, green and amber. This is the single
 *    strongest differentiator between two tracks, because it tracks the thing a
 *    listener would call the track's colour anyway.
 *  - flatness (noisy vs tonal) -> chroma. Noisy material gets desaturated so it
 *    doesn't turn to mud once bloom is applied.
 *  - dynamic range -> lightness spread. A dynamic track earns a wider range
 *    between its darkest and brightest colour.
 */
export class AudioPalette extends PaletteSource {
    constructor(summary, rng) {
        super();
        this.summary = summary;
        this.rng = rng;
    }

    generate(count = 6) {
        const {
            meanCentroid = 0.5, meanFlatness = 0.2, dynamicRange = 0.5,
            bandBalance = {}, bpm = 120,
        } = this.summary;
        const rng = this.rng;

        // --- Temperature: the track's timbre signature, not its loudness ---
        // SPECTRAL TILT — the log ratio of treble energy to body energy — rather
        // than either the centroid or a plain body/(body+treble) fraction.
        //
        // Both of those were tried and both collapse. The centroid is one number
        // most mastered music sits in the middle of. The plain fraction is worse:
        // low frequencies carry most of the energy in essentially all music, so
        // it reads 0.98-1.00 for everything and the four check-battery tracks
        // came out within 0.02 of each other. The ratio is MULTIPLICATIVE, so its
        // logarithm is what actually spreads: the same four tracks measure -9.3,
        // -5.0, -4.1 and -3.8, which is a real axis to hang a palette on.
        const bands = {
            sub: bandBalance.sub ?? 0.2, low: bandBalance.low ?? 0.2, mid: bandBalance.mid ?? 0.2,
            high: bandBalance.high ?? 0.2, air: bandBalance.air ?? 0.2,
        };
        const body = bands.sub * 1.0 + bands.low * 0.9 + bands.mid * 0.35 + 1e-7;
        const treble = bands.high * 0.9 + bands.air * 1.0 + bands.mid * 0.15 + 1e-7;
        const tilt = Math.log(treble / body);

        // -9 (nothing above the low mids) .. -2 (bright, airy) covers the range
        // real material occupies; the centroid keeps a minority vote so two
        // tracks with the same tilt but different brightness still differ.
        const tiltWarmth = Math.max(0, Math.min(1, (-2 - tilt) / 7));
        const warmth = tiltWarmth * 0.7 + (1 - meanCentroid) * 0.3;

        // Hue sweeps cold -> warm, but which WAY round the wheel is seeded.
        // Going down from red through yellow and green to blue is the obvious
        // route and the only one that existed; it also means violet, magenta and
        // pink were unreachable for every track ever generated, because they sit
        // on the arc the sweep skipped. Half of tracks now take the other way
        // round, so the same warm/cool reading can land on crimson-through-
        // magenta instead of crimson-through-amber.
        //
        // Both routes span the same arc. A short return leg would mean tracks
        // that took it barely differ in hue however different they sound.
        const clockwise = rng.bool(0.5);
        const span = (clockwise ? 1 : -1) * Math.PI * 4 / 3;

        // Tempo and dynamics nudge the hue too. Timbre is the main axis, but two
        // tracks can be timbrally alike and still feel different — a slow
        // spacious one and a fast compressed one should not be handed the same
        // colour just because they occupy the same part of the spectrum.
        const feel = ((bpm - 120) / 200 + (dynamicRange - 0.5) * 0.5) * 0.6;

        const baseHue = (1 - warmth) * span + feel + rng.range(-0.45, 0.45);

        const schemeName = rng.pick(SCHEME_NAMES);
        const hues = SCHEMES[schemeName](baseHue, rng);

        // --- Energy: how vivid and punchy the palette is ---
        // Upbeat, dynamic material earns saturated colour; chill, flat material
        // stays muted. This is the "does it pop" axis, orthogonal to timbre.
        const fast = Math.min(1, Math.max(0, (bpm - 80) / 150));
        const energy = Math.min(1, fast * 0.4 + (1 - Math.min(1, meanFlatness)) * 0.4 + dynamicRange * 0.3);
        // Vividness is the track's, but how far it commits is seeded — the old
        // fixed mapping meant two tracks with similar statistics got not just
        // similar hues but the same saturation, which is most of why they read
        // as the same palette.
        const vividness = rng.range(0.55, 1.45);
        const chromaBase = (0.09 + energy * 0.19) * vividness;

        // Chroma profile: does the palette saturate in the middle (the old fixed
        // behaviour), at the bright end, or barely at all? A near-neutral set
        // with one vivid accent is a look the generator could not previously
        // produce.
        const profile = rng.pickWeighted(['arch', 'rising', 'flat', 'accent'], [3, 2, 2, 2]);
        const chromaAt = (t) => {
            switch (profile) {
                case 'rising': return 0.35 + t * 1.1;
                case 'flat':   return 0.9;
                case 'accent': return t > 0.72 ? 1.5 : 0.28;
                default:       return 0.55 + Math.sin(t * Math.PI) * 0.75;
            }
        };

        // A dynamic mercury gets a wider light-to-dark range; warmth keeps warm
        // tones from sinking into brown, since dark + orange is mud.
        const spread = (0.30 + Math.min(1, dynamicRange) * 0.30) * rng.range(0.85, 1.5);
        const anchor = 0.40 - warmth * 0.06 + rng.range(-0.14, 0.16);
        // How the lightness steps are distributed: 1.7 keeps most entries dark
        // with a couple of bright accents (the old fixed curve), below 1 spreads
        // them evenly, above 2 makes the set almost entirely dark with one
        // highlight. Another axis two similar tracks can differ on.
        const curve = rng.range(0.75, 2.4);

        const colors = [];
        for (let i = 0; i < count; i++) {
            const t = count > 1 ? i / (count - 1) : 0;
            // Deliberately non-linear: most entries mid-dark, one or two bright.
            // Scenes use pal(0) as a base and higher indices as accents.
            const L = Math.max(0.05, Math.min(0.97, anchor + Math.pow(t, curve) * spread));
            const C = chromaBase * chromaAt(t) + rng.range(-0.012, 0.012);
            const h = hues[i % hues.length] + rng.range(-0.08, 0.08);
            colors.push(oklchToRgb(L, Math.max(0, C), h));
        }

        this.lastScheme = schemeName;
        this.lastProfile = profile;
        return colors;
    }
}

/**
 * Retry until the palette clears the contrast floor. Unsupervised generation
 * will occasionally land on a muddy set; regenerating is cheap and beats
 * shipping a video where every colour is the same grey-violet.
 */
export function generateUsablePalette(source, count = 6, { minLuminanceSpread = 0.22, attempts = 12 } = {}) {
    let best = null;
    let bestScore = -1;
    for (let i = 0; i < attempts; i++) {
        const colors = source.generate(count);
        const { luminanceSpread, chromaSpread } = paletteContrast(colors);
        const score = luminanceSpread + chromaSpread * 0.4;
        if (score > bestScore) { bestScore = score; best = colors; }
        if (luminanceSpread >= minLuminanceSpread) return colors;
    }
    return best;
}

export function toHex([r, g, b]) {
    const c = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0');
    return `#${c(r)}${c(g)}${c(b)}`;
}
