// 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)];
}

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],
};

export const SCHEME_NAMES = Object.keys(SCHEMES);

/** 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 } = this.summary;
        const rng = this.rng;

        // Centroid 0..1 mapped onto roughly violet -> blue -> cyan -> green -> amber.
        // Offset by a seeded jitter so two tracks with similar spectra still differ.
        const baseHue = (4.9 - meanCentroid * 3.6) + rng.range(-0.45, 0.45);

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

        // Noisy material desaturates; tonal material is allowed to sing.
        const chromaBase = 0.10 + (1 - Math.min(1, meanFlatness * 3)) * 0.11;
        // A dynamic track gets a wider light-to-dark range.
        const spread = 0.30 + Math.min(1, dynamicRange) * 0.34;
        const anchor = 0.36 + rng.range(-0.05, 0.10);

        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.06, Math.min(0.95, anchor + Math.pow(t, 1.7) * spread));
            const C = chromaBase * (0.55 + Math.sin(t * Math.PI) * 0.75) + 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;
        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)}`;
}
