import { DecisionPoint } from "./DecisionPoint.js";

const LN2 = Math.log(2);

/**
 * Exponential moving average of mastery contributions. Provides a per-
 * decision-point and a global aggregate score, both centred at 0:
 *
 *   `score === 0` → on average, evaluators returned 1.0 (neutral)
 *   `score > 0`   → trending bonus (player is consistently timing well)
 *   `score < 0`   → trending penalty (player is consistently mistiming)
 *
 * The controller does NOT consume this score itself. It's a side-channel
 * for downstream systems (HUDs, post-match summaries, training-mode
 * coaching, achievement triggers, leaderboards) to expose mastery
 * progress however they see fit, without forcing any visual treatment on
 * the controller.
 *
 * Samples are added by {@link MasterySet} as evaluators fire. Time-based
 * decay (so old samples wash out) is applied via {@link tick}.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class MasteryScore {
    /**
     * @param {number} [halfLife] seconds for any sample's contribution to halve
     */
    constructor(halfLife = 8.0) {
        /**
         * Time-half-life of the EMA. A score sample 1×halfLife ago counts ½
         * as much as a fresh one. Tune for "how recent should mastery feel".
         * @type {number}
         */
        this.halfLife = halfLife;

        /**
         * EMA of (multiplier − 1.0) across ALL evaluator contributions.
         * Useful as a single-number "how skilled has the player been
         * recently" indicator.
         * @type {number}
         */
        this.aggregate = 0;

        /**
         * EMA per decision point — same metric, sliced by which interaction
         * the contribution came from. Index space matches DecisionPoint.
         * Useful for telemetry like "you're nailing jump timing but poor at
         * slide initiation".
         * @type {number[]}
         */
        this.byPoint = new Array(DecisionPoint._Count).fill(0);
    }

    /**
     * Record a contribution from an evaluator. Call once per evaluator per
     * decision-point evaluation. The multiplier is converted to a signed
     * deviation from neutral (1.0) for EMA accumulation.
     *
     * @param {number} decisionPoint
     * @param {number} multiplier  raw evaluator output (1.0 = neutral)
     */
    record(decisionPoint, multiplier) {
        const dev = multiplier - 1.0;
        // Light blend on each sample; the per-tick time decay (apply()) is
        // what actually carries the EMA character.
        const blend = 0.1;
        this.aggregate = (1 - blend) * this.aggregate + blend * dev;
        const pi = decisionPoint;
        if (pi >= 0 && pi < this.byPoint.length) {
            this.byPoint[pi] = (1 - blend) * this.byPoint[pi] + blend * dev;
        }
    }

    /**
     * Apply time-based decay so stale samples wash out. Call once per
     * fixed step; the system handles this for you.
     *
     * @param {number} dt seconds
     */
    decay(dt) {
        if (this.halfLife <= 0 || dt <= 0) return;
        // Multiplicative decay toward 0 with the given half-life.
        const alpha = Math.exp(-LN2 * dt / this.halfLife);
        this.aggregate *= alpha;
        for (let i = 0; i < this.byPoint.length; i++) {
            this.byPoint[i] *= alpha;
        }
    }

    /**
     * Reset to neutral. Useful on respawn / scene change.
     */
    reset() {
        this.aggregate = 0;
        for (let i = 0; i < this.byPoint.length; i++) {
            this.byPoint[i] = 0;
        }
    }
}
