// The GAZE: where in the scene the frame is looking, and how it gets there.
//
// This is the director's camera department. `Story.js` says what the video is
// doing — tension, closeness, order, which act a section is in — in terms that
// are deliberately imagery-free. Something has to turn that into a picture, and
// until now every consumer did its own ad-hoc translation: shots.js reads
// tension for the cut rate, framing.js reads closeness for the shot size,
// LookGenerator reads population for the overlay chance. Nobody owned the
// camera, and it showed.
//
// WHAT WAS WRONG
//
// The recentre existed — `framing.shift`, applied in the shader epilogue as
// `p / scale + shift` — and it was inert. Measured across 121 cues:
//
//     median |shift| from centre    0.029     (2.9% of a half-frame)
//     median |jump| at a cut        0.014     (1.4%)
//     largest jump seen             0.120
//
// Three causes, and the amplitude was only one of them:
//
//   1. `amount = spec.drift * style.range * rng.range(0.3, 1)` capped the offset
//      at 0.10, because a comment worried that pushing off centre would find the
//      scenes' empty corners.
//   2. The direction was `rng.range(0, 2π)` — a fresh uniform angle every shot.
//      No axis, no continuity, no intent. That is why the median JUMP is smaller
//      than the median OFFSET: consecutive shots mostly cancelled each other.
//   3. Nothing about the song reached it at all. Shot SIZE got the story's
//      `closeness`; the recentre got a per-track constant and a die roll.
//
// WHAT THIS DOES INSTEAD
//
// A gaze is a path, planned once over the whole cue list, so the video's
// attention travels rather than jittering. Per cue it carries a move:
//
//     from → to     the two points, in scene units
//     delay         how long the shot holds before it starts moving
//     travel        how long the move takes
//     curve         how it accelerates
//
// The story decides all four. A climax jumps far and arrives hard; a resolution
// drifts a short way back toward centre and never quite stops; a low-`order`
// section throws the gaze off its axis. Between two points the gaze is a pure
// function of (plan, cue, frames-into-cue), the same rule the rest of the render
// path follows — a seek lands on the frame playback would have shown.
//
// NOTE ON THE PER-SHOT RULE. framing.js says framing is constant within a shot,
// and that a move inside a shot "would fight the drift LFO and the slow axis,
// both of which already own continuous motion". That was right about SIZE and
// wrong about the recentre: a zoom that creeps during a shot is an effect, but a
// camera that settles onto its subject is how shots have always worked. Size
// still steps at the cut and only at the cut. The gaze moves.

const clamp01 = (x) => Math.max(0, Math.min(1, x));
const lerp = (a, b, t) => a + (b - a) * t;

/**
 * How the gaze accelerates between two points.
 *
 * These are the reason a move reads as a decision rather than as a tween. A
 * `snap` and a `glide` cover the same distance in the same time and say
 * completely different things about the section they are in.
 */
export const CURVES = {
    // Hard out of the gate, decelerating into the target. The edit-room move:
    // it feels like the camera was already going when the cut happened.
    snap:   (t) => 1 - (1 - t) ** 3,
    // Slow away, slow in. The default, and what a calm section wants.
    glide:  (t) => t * t * (3 - 2 * t),
    // Constant speed, and it does NOT arrive — see `travel` below, which is
    // allowed to exceed the shot. A drifting camera that gets cut away from
    // mid-move is the most alive of these.
    drift:  (t) => t,
    // Overshoots and comes back. Used sparingly: it is the only curve here that
    // is legible as a flourish, so it belongs at moments and not everywhere.
    //
    // The standard back-ease-out, written out rather than improvised. The
    // improvised version evaluated to 2.0 at t=0 instead of 0 — every `settle`
    // shot therefore STARTED at twice its target, well outside the reach the
    // plan had clamped it to, and the headroom gate caught offsets of 0.61
    // against a 0.385 ceiling. An easing curve has two values that are not
    // negotiable, f(0)=0 and f(1)=1, and this one had neither.
    settle: (t) => {
        const c1 = 1.70158;
        const u = t - 1;
        return 1 + (c1 + 1) * u * u * u + c1 * u * u;
    },
};

export const CURVE_NAMES = Object.keys(CURVES);

/**
 * A director's point of view about its camera.
 *
 * Same rationale as the family table in directors.js: a fixed rule applied to
 * every track is how a library ends up with one camera, and the camera is
 * exactly the register a viewer reads as "who shot this".
 *
 *   reach   multiplier on how far the gaze is allowed from centre
 *   pace    multiplier on travel time — low is restless, high is patient
 *   curves  weights over CURVE_NAMES, in order
 *   axial   how much the gaze prefers to move along one axis rather than
 *           anywhere. High reads as composed; low reads as searching.
 */
export const CAMERAS = {
    // Patient and composed. Long moves, mostly horizontal, rarely hurried.
    contemplative: { reach: 0.85, pace: 1.45, curves: [1, 4, 3, 1], axial: 0.75 },
    // Cuts with the camera already moving. The closest to an edited music video.
    kinetic:       { reach: 1.20, pace: 0.60, curves: [5, 2, 1, 1], axial: 0.35 },
    // Locked-off until it is not. Holds, then commits to one large move.
    deliberate:    { reach: 1.10, pace: 0.85, curves: [3, 3, 1, 2], axial: 0.85 },
    // Never settles. Long drifts that get cut away from mid-travel.
    roaming:       { reach: 0.95, pace: 1.70, curves: [1, 2, 5, 1], axial: 0.25 },
    // Small, exact, and it always arrives. The one that stays near centre.
    precise:       { reach: 0.70, pace: 1.00, curves: [2, 5, 1, 2], axial: 0.90 },
};

export const CAMERA_NAMES = Object.keys(CAMERAS);

/**
 * The furthest the gaze may sit from centre, in scene units.
 *
 * The old comment's worry was real but it was stated as a constant when it is a
 * function of the shot size. The visible half-frame at scale s is 1/s scene
 * units, so a close-up is looking at a small piece of the composition and can
 * move a long way across it before reaching anywhere empty; a wide is already
 * seeing everything there is and moving off centre only finds the edges.
 *
 * The scale term is the ADDITION rather than the whole thing, which the first
 * version got wrong: `(0.12 + 0.56 * headroom)` gave a close-up 0.35 and every
 * other shot 0.12, and since headroom is zero at any scale ≤ 1 that meant the
 * normal and wide shots — most of the video — were still capped at barely more
 * than the old inert 0.10. The base has to be worth seeing on its own.
 *
 * Now: `normal` and `wide` allow 0.20–0.34 depending on the camera, `close`
 * 0.32–0.45. Against a measured median offset of 0.029 and a hard old ceiling
 * of 0.10, a typical move is roughly six times what it was.
 *
 * The absolute cap is what keeps this inside the answer to "how far should it
 * roam" — moderate, with close-ups furthest.
 */
export const MAX_REACH = 0.45;

export function reachFor(scale, camera) {
    const headroom = Math.max(0, 1 - 1 / Math.max(scale, 0.05));
    return Math.min(MAX_REACH, (0.28 + 0.42 * headroom) * camera.reach);
}

/** Pick a camera for a track. The director leans, the seed decides. */
export function deriveCamera(director, summary, rng) {
    const preferred = (director && director.camera) || null;
    const weights = CAMERA_NAMES.map((name) => (name === preferred ? 4 : 1));
    const name = rng.pickWeighted(CAMERA_NAMES, weights);
    return {
        name,
        ...CAMERAS[name],
        // The track's own axis. A video whose gaze moves along one line reads as
        // composed even when the line is arbitrary — what reads as sloppy is a
        // different direction every time, which is precisely what the uniform
        // random angle was doing.
        axis: rng.range(0, Math.PI * 2),
    };
}

/**
 * How far this cut should jump, 0..1 of the available reach.
 *
 * This is the whole "driven by the song" requirement in one function, so the
 * mapping is stated rather than buried:
 *
 *   tension     the main term. A wound-up section reframes hard.
 *   act         the climax gets the biggest move in the video, and the
 *               resolution gets the smallest — a video that keeps flinging its
 *               camera after the peak has nothing left to say with it.
 *   hardCut     a straight cut earns a bigger reframe than a dissolve. The two
 *               devices are already gated on energy together (shots.js), so this
 *               compounds deliberately.
 *   order       low order widens the spread, so a section coming apart is also
 *               less predictable about where it looks.
 */
function jumpFor(story, hardCut, rng) {
    const tension = story ? story.tension : 0.5;
    const order = story ? story.order : 0.5;

    let base = 0.25 + tension * 0.55;
    if (story) {
        if (story.act === 'climax') base = Math.max(base, 0.85);
        else if (story.act === 'turn') base = Math.max(base, 0.6);
        else if (story.act === 'resolution') base = Math.min(base, 0.3);
        else if (story.act === 'setup') base = Math.min(base, 0.45);
    }
    if (hardCut) base = Math.min(1, base * 1.25);

    // Disorder widens the draw rather than raising it: a broken section is less
    // predictable, not uniformly bigger.
    const spread = 0.2 + (1 - order) * 0.55;
    return clamp01(base * rng.range(1 - spread, 1 + spread * 0.6));
}

/**
 * Where the gaze goes next.
 *
 * Direction is the track's axis, plus a wander that `order` controls and the
 * camera's `axial` bounds. The one hard rule is that a move must not simply
 * undo the last one — reversing along the same line is how the old uniform
 * angle produced offsets that cancelled, and it is why nothing appeared to
 * move even at the amplitudes it did reach.
 */
function targetFor(from, distance, camera, story, rng, previousDir) {
    const order = story ? story.order : 0.5;
    const wander = (1 - camera.axial) * (0.35 + (1 - order) * 0.65);

    // Both ends of the axis are legitimate; which one is a coin flip biased
    // away from wherever we already are, so the gaze crosses the frame rather
    // than orbiting one side of it.
    const along = camera.axis + (rng.bool() ? 0 : Math.PI);
    let dir = along + rng.range(-Math.PI, Math.PI) * wander;

    if (previousDir !== null) {
        // Within 35° of a straight reversal, nudge it off. A reversal is a
        // legitimate move; an exact retrace is the thing that reads as jitter.
        const delta = Math.abs(normalizeAngle(dir - (previousDir + Math.PI)));
        if (delta < 0.6) dir += (delta < 0.3 ? 1 : -1) * 0.9;
    }

    return {
        point: [from[0] + Math.cos(dir) * distance, from[1] + Math.sin(dir) * distance],
        dir,
    };
}

function normalizeAngle(a) {
    let x = a % (Math.PI * 2);
    if (x > Math.PI) x -= Math.PI * 2;
    if (x < -Math.PI) x += Math.PI * 2;
    return x;
}

/**
 * How long the move takes, and how long the shot waits first.
 *
 * The "different speeds in different sections" requirement. A loud section
 * moves fast and is done; a quiet one takes most of the shot to arrive. Travel
 * is allowed to exceed the shot length — that is not a bug, it is what makes a
 * `drift` read as a camera that was going somewhere when the edit cut away.
 */
function timingFor(spanFrames, story, energy, camera, curve, rng) {
    const tension = story ? story.tension : 0.5;
    const urgency = clamp01(energy * 0.6 + tension * 0.4);

    // Fraction of the shot spent moving. Fast material arrives in the first
    // third; slow material is still arriving at the cut.
    let travel = lerp(1.15, 0.28, urgency) * camera.pace * rng.range(0.8, 1.25);
    // A drift is defined by not arriving, so it always outruns its shot.
    if (curve === 'drift') travel = Math.max(travel, 1.2);

    // How long it holds first. A `deliberate` camera earns its name here: the
    // hold is what makes the move that follows read as a decision.
    const delay = lerp(0.22, 0.02, urgency) * rng.range(0.4, 1.3);

    const usable = Math.max(1, spanFrames);
    return {
        delayFrames: Math.round(clamp01(delay) * usable),
        travelFrames: Math.max(1, Math.round(travel * usable)),
    };
}

/**
 * Whether the cut RELOCATES the camera or the camera walks through it.
 *
 * Both are real edits and they say opposite things. A cut that lands on a new
 * part of the scene is a reframe — the loud one, the one you notice. A cut the
 * camera walks through is a match cut, and it is what makes two scenes read as
 * one continuous place, which is the effect worth keeping.
 *
 * So this is a decision per cut, not a mode. Hard cuts relocate, dissolves
 * mostly do not, and tension raises the odds everywhere: a wound-up section
 * jumps around inside itself, a resolution stops doing that.
 */
function relocatesAt(cue, story, rng) {
    if (!cue.hardCut && cue.atSectionStart) return true;   // a new section is a new place
    const tension = story ? story.tension : 0.5;
    let chance = 0.12 + tension * 0.45;
    if (cue.hardCut) chance += 0.3;
    if (story) {
        if (story.act === 'climax') chance += 0.2;
        else if (story.act === 'resolution') chance *= 0.4;
    }
    return rng.bool(clamp01(chance));
}

/**
 * Plan the gaze across a whole video.
 *
 * Walks the cues in order. Each move starts from where the gaze ACTUALLY was
 * when the cut arrived — not from the target of the previous move, which may
 * never have been reached: a `drift` is defined by outrunning its shot, and
 * resuming from its unreached target is a silent teleport at every cut. That
 * was measurable as a median jump of 0.000 with a p90 of 0.274, which is a
 * camera that mostly does nothing and occasionally lurches.
 *
 * @param {Array} cues     ArcDriver cues, each already carrying `framing`
 * @param {Array} sections look.sections, for bias and story state
 * @param {object} camera  from deriveCamera
 * @param {Rng} rng
 * @returns {Array} one move per cue, index-aligned
 */
export function planGaze(cues, sections, camera, rng) {
    const moves = [];
    let at = [0, 0];
    let previousDir = null;

    for (const cue of cues) {
        const section = sections[cue.sectionIndex] || {};
        const story = section.story || null;
        const energy = (section.bias && section.bias.energy) || 0;
        const scale = (cue.framing && cue.framing.scale) || 1;
        const reach = reachFor(scale, camera);
        const span = Math.max(1, cue.endFrame - cue.startFrame);

        const curve = rng.pickWeighted(CURVE_NAMES, camera.curves);

        // THE CUT. Either the camera is somewhere new when the image changes,
        // or it walks through the change and the two shots read as one place.
        //
        // Carried-over positions are re-clamped, because reach belongs to the
        // SHOT: walking a close-up's 0.45 offset into a wide would start that
        // wide further off centre than a wide is ever allowed to be. The small
        // snap this causes lands exactly on a cut, which is where the eye is
        // least able to see it.
        let from = clampToReach(at, reach);
        if (moves.length && relocatesAt(cue, story, rng)) {
            const hop = jumpFor(story, !!cue.hardCut, rng) * reach;
            const jumped = targetFor(from, hop, camera, story, rng, previousDir);
            from = clampToReach(jumped.point, reach);
            previousDir = jumped.dir;
        }

        // THE MOVE. Where it travels during the shot, from wherever the cut
        // left it. Deliberately smaller than the cut's hop — the reframe is the
        // statement and the move is the camera living inside it.
        const distance = jumpFor(story, false, rng) * reach * 0.7;
        const { point, dir } = targetFor(from, distance, camera, story, rng, previousDir);

        // Clamp the TARGET to the reach disc rather than the step, so a move
        // that would leave the frame is shortened instead of being redirected —
        // redirecting is what makes a bounded random walk orbit its boundary.
        const to = clampToReach(point, reach);
        const { delayFrames, travelFrames } = timingFor(span, story, energy, camera, curve, rng);

        const move = { from, to, curve, delayFrames, travelFrames, reach };
        moves.push(move);

        // Where the gaze actually ends up when this shot is cut away from.
        at = gazeAt(move, span);
        previousDir = dir;
    }

    return moves;
}

function clampToReach(p, reach) {
    const d = Math.hypot(p[0], p[1]);
    if (d <= reach || d === 0) return p;
    const k = reach / d;
    return [p[0] * k, p[1] * k];
}

/**
 * Where the gaze is, `frames` into a cue.
 *
 * Pure in (move, frames): this is what keeps a seek frame-identical to
 * playback, and it is why the plan holds points and durations rather than a
 * running position.
 */
export function gazeAt(move, frames) {
    if (!move) return [0, 0];
    const t = clamp01((frames - move.delayFrames) / move.travelFrames);
    const eased = CURVES[move.curve] ? CURVES[move.curve](t) : t;
    const p = [
        lerp(move.from[0], move.to[0], eased),
        lerp(move.from[1], move.to[1], eased),
    ];
    // The reach bound is enforced HERE and not only on the endpoints, because a
    // curve is allowed to leave the segment between them: `settle` overshoots
    // its target by about 10% on purpose. Clamping the plan is not the same as
    // clamping the path, and only the path is what reaches the screen.
    return move.reach ? clampToReach(p, move.reach) : p;
}

/** One line for the HUD, the look panel and check output. */
export function describeCamera(camera) {
    if (!camera) return 'camera: locked';
    return `camera: ${camera.name} (reach ${camera.reach.toFixed(2)}, `
        + `pace ${camera.pace.toFixed(2)})`;
}
