// Grain as a deliberate treatment rather than a permanent surface.
//
// Grain used to be two unconditional additions — every scene added sigGrain and
// the grade added its own on top — with only the amount varying. The result was
// that every track in the library was grainy, which made grain read as the
// renderer's fingerprint instead of as a choice about one video.
//
// So grain is now DESCRIBED, not dialled:
//
//   mode   — when it is present at all. A third of tracks get none, and of the
//            rest most only carry it some of the time.
//   scale  — the size of a noise cell in pixels. 1 is film-fine; 5 is a coarse
//            dither that reads as a different medium entirely.
//   rate   — how many frames a noise field survives. 1 boils; 5 is sticky
//            static that sits on the image like dirt on a lens.
//   mask   — where it lands. Only in the shadows, only in the highlights, only
//            toward the edges, or in horizontal bands.
//   chroma — mono speckle or colour speckle.
//
// Two tracks that both "have grain" should still not look like each other.

/** Mask ids, mirrored in COMPOSITE_FRAG. */
export const GRAIN_MASKS = { uniform: 0, shadows: 1, highlights: 2, edges: 3, bands: 4 };

export const GRAIN_MODES = ['off', 'constant', 'swell', 'sections', 'transient'];

/** Section kinds a 'sections' grain can be pinned to. */
const GATEABLE_KINDS = ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'];

export const NO_GRAIN = {
    mode: 'off',
    amount: 0,
    scale: 1,
    rate: 1,
    mask: 'uniform',
    chroma: 0,
    kinds: [],
    period: 24,
    duty: 0.4,
};

/**
 * The track's grain treatment.
 *
 * `noisy` (spectral flatness) tilts the odds but never forces the issue: a
 * clean tonal track can still be the one that gets heavy dirt, because that is
 * a legitimate art-direction choice and predictable mapping is what made the
 * library uniform in the first place.
 */
export function deriveGrain(summary, rng) {
    const noisy = Math.min(1, (summary.meanFlatness ?? 0.2) * 3);

    // 'off' is the single most likely outcome, and deliberately so — a library
    // where two videos in five have no grain at all is what makes the ones that
    // do read as a decision.
    const mode = rng.pickWeighted(
        ['off', 'constant', 'swell', 'sections', 'transient'],
        [6 - noisy * 3, 1.5 + noisy * 2.5, 2.5, 2.5, 2],
    );
    if (mode === 'off') return { ...NO_GRAIN };

    // Constant grain has to live with the image for the whole video, so it is
    // held well below what an intermittent treatment can get away with.
    const ceiling = mode === 'constant' ? 0.05 : 0.11;
    const amount = rng.range(0.015, ceiling);

    const scale = rng.pickWeighted([1, 1.5, 2, 3, 5], [4, 3, 3, 2, 1]);
    // Coarse cells that also boil every frame read as a broken video signal, so
    // the bigger the cell the more likely it is to hold still for a few frames.
    const rate = rng.pickWeighted([1, 2, 3, 5], [5, 2 + scale, 1 + scale, scale]);

    const mask = rng.pickWeighted(
        ['uniform', 'shadows', 'highlights', 'edges', 'bands'],
        [3, 3, 1.5, 2, 1],
    );

    const kinds = mode === 'sections' ? pickKinds(rng) : [];

    return {
        mode,
        amount,
        scale,
        rate,
        mask,
        // Colour speckle is the loudest of these choices and stays rare.
        chroma: rng.bool(0.25) ? rng.range(0.3, 1) : 0,
        kinds,
        // Swell period in seconds. Long enough that it reads as the image
        // breathing rather than as a flicker.
        period: rng.range(12, 40),
        duty: rng.range(0.25, 0.6),
    };
}

/** One to three section kinds this grain belongs to. */
function pickKinds(rng) {
    const count = rng.pickWeighted([1, 2, 3], [3, 3, 1]);
    const pool = [...GATEABLE_KINDS];
    const out = [];
    for (let i = 0; i < count && pool.length; i++) {
        const pick = rng.pick(pool);
        out.push(pick);
        pool.splice(pool.indexOf(pick), 1);
    }
    return out;
}

/**
 * The 0..1 envelope on the grain amount for one frame.
 *
 * Deterministic in frame and features only — no state, no random source — so a
 * preview frame and the exported frame agree, which is the same rule the noise
 * itself follows.
 *
 * @param {object} spec        from deriveGrain
 * @param {object} ctx
 * @param {number} ctx.time            seconds into the track
 * @param {string} ctx.sectionKind     kind of the section this frame is in
 * @param {object} ctx.features        FeatureTrack row for this frame
 */
export function grainEnvelope(spec, { time = 0, sectionKind = '', features = null } = {}) {
    if (!spec || spec.mode === 'off' || spec.amount <= 0) return 0;

    switch (spec.mode) {
        case 'constant':
            return 1;

        case 'swell': {
            // Raised cosine over `period`, on for `duty` of it. The image drifts
            // into grain and back out with nothing in the audio triggering it,
            // which is what makes it feel like film rather than like a reaction.
            const phase = (time % spec.period) / spec.period;
            if (phase > spec.duty) return 0;
            return 0.5 - 0.5 * Math.cos((phase / spec.duty) * Math.PI * 2);
        }

        case 'sections': {
            if (!spec.kinds.includes(sectionKind)) return 0;
            // Ease across the section edges so grain arrives with the section
            // rather than snapping on at the cut.
            const p = features ? features.sectionProgress : 0.5;
            return smoothstep(0, 0.08, p) * smoothstep(0, 0.08, 1 - p);
        }

        case 'transient': {
            if (!features) return 0;
            // Rides flux, so grain answers hits and edits. Floored slightly
            // above zero on loud material so it does not strobe on and off.
            const hit = Math.min(1, (features.flux ?? 0) * 2.5);
            const bed = Math.min(0.35, (features.sectionEnergy ?? 0) * 0.35);
            return Math.max(bed, hit);
        }

        default:
            return 1;
    }
}

function smoothstep(a, b, x) {
    const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
    return t * t * (3 - 2 * t);
}

/**
 * Push a grain spec's static fields into a post object.
 *
 * The amount is the exception: for anything other than 'constant' it is owned
 * by the per-frame envelope (see Show._postAt), so it is set to zero here and
 * the envelope writes it every frame.
 */
export function applyGrainToPost(spec, post) {
    post.grain = spec.mode === 'constant' ? spec.amount : 0;
    post.grainScale = spec.scale;
    post.grainRate = spec.rate;
    post.grainMask = GRAIN_MASKS[spec.mask] ?? 0;
    post.grainChroma = spec.chroma;
    return post;
}

/** One line for the look panel and check output. */
export function describeGrain(spec) {
    if (!spec || spec.mode === 'off') return 'grain: none';
    const bits = [
        `grain: ${spec.mode}`,
        `${spec.amount.toFixed(3)}`,
        `${spec.scale}px`,
        spec.rate > 1 ? `every ${spec.rate}f` : 'per frame',
        spec.mask,
    ];
    if (spec.chroma > 0) bits.push('colour');
    if (spec.kinds.length) bits.push(`on ${spec.kinds.join('+')}`);
    return bits.join(' · ');
}
