import { assert } from "../../../core/assert.js";
import { computeHashArray } from "../../../core/collection/array/computeHashArray.js";
import { isArrayEqual } from "../../../core/collection/array/isArrayEqual.js";
import { invokeObjectClone } from "../../../core/model/object/invokeObjectClone.js";
import { invokeObjectHash } from "../../../core/model/object/invokeObjectHash.js";
import { invokeObjectToJSON } from "../../../core/model/object/invokeObjectToJSON.js";
import { evaluate_two_key_curve } from "./evaluate_two_key_curve.js";
import { Keyframe } from "./Keyframe.js";

/**
 * Describes change of a numeric value over time.
 * Values are stored in {@link Keyframe}s, interpolation is defined by tangents on {@link Keyframe}s.
 * The curve is a cubic Hermite spline.
 *
 * @example
 *  const jump_curve = AnimationCurve.from([
 *      Keyframe.from(0, 0), // start at height 0
 *      Keyframe.from(0.3, 2), // at time 0.3, jump height will be 2 meters
 *      Keyframe.from(1, 0) // at time 1.0, land back on the ground
 *  ]);
 *
 *  jump_curve.evaluate(0.1); // what is the height at time 0.1?
 *
 * @example
 *  const curve = AnimationCurve.easeInOut();
 *
 *  sprite.transparency = curve.evaluate(time); // smoothly animate transparency of the sprite
 *
 * @implements Iterable<Keyframe>
 *
 * @see https://en.wikipedia.org/wiki/Cubic_Hermite_spline.
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class AnimationCurve {
    /**
     * Keyframes defining the curve, in chronological order.
     * The curve manages the contents of this array, do not modify it directly.
     * @readonly
     * @type {Keyframe[]}
     */
    keys = [];

    /**
     * Add a new keyframe into the animation.
     * Keyframes can be added out of order, they will be inserted into the correct chronological position.
     * @param {Keyframe} key
     * @returns {number} key index where it was inserted at
     */
    add(key) {
        assert.defined(key, 'key');
        assert.notNull(key, 'key');
        assert.equal(key.isKeyframe, true, 'key.isKeyframe !== true');

        const keys = this.keys;

        const key_count = keys.length;
        const last_key_index = key_count - 1;

        // Optimization: if the curve is empty or the new key is chronologically
        // after the last key, we can simply push it to the end.
        if (
            last_key_index < 0
            || keys[last_key_index].time <= key.time
        ) {

            // the inserted key goes at the end
            keys.push(key);

            return key_count;

        } else if (key_count > 0 && keys[0].time > key.time) {

            keys.unshift(key);

            return 0;
        }

        // figure out the right place to insert the key
        const i = this.getKeyIndexLow(key.time) + 1;

        // insert key at the right place
        keys.splice(i, 0, key);

        return i;

    }

    /**
     * Insert multiple keyframes. Input doesn't need to be sorted.
     * @param {Keyframe[]} keys
     */
    addMany(keys) {
        assert.isArray(keys, 'keys');

        const key_count = keys.length;

        for (let i = 0; i < key_count; i++) {
            this.add(keys[i]);
        }
    }

    /**
     *
     * @param {Keyframe} key
     * @returns {boolean} true if the key was removed, false if the key was not found
     */
    remove(key) {
        const i = this.keys.indexOf(key);

        if (i === -1) {
            return false;
        }

        this.keys.splice(i, 1);

        return true;
    }

    /**
     * Remove all keys, making the curve empty.
     */
    clear() {
        this.keys.splice(0, this.keys.length);
    }


    /**
     * Does this curve have any keyframes?
     * @return {boolean} true iff keyframe count == 0, false otherwise
     */
    isEmpty() {
        return this.keys.length === 0;
    }

    /**
     * Number of keyframes in the curve.
     * @returns {number}
     */
    get length() {
        return this.keys.length;
    }

    /**
     * Timestamp of the first keyframe.
     * Returns 0 if there are no keyframes.
     * @returns {number}
     */
    get start_time() {
        const keys = this.keys;

        if (keys.length === 0) {
            return 0;
        }

        const first = keys[0];

        return first.time;
    }

    /**
     * Time of the last chronological key in the curve.
     * Returns 0 if there are no keyframes.
     * @return {number}
     */
    get end_time() {
        const keys = this.keys;
        const key_count = keys.length;

        if (key_count === 0) {
            return 0
        }

        // keys are sorted, so we can rely on the last key's time
        return keys[key_count - 1].time;
    }

    /**
     * Time difference between the first and the last keyframe.
     * Returns 0 if there are no keyframes.
     * @returns {number}
     */
    get duration() {

        const keys = this.keys;
        const key_count = keys.length;

        if (key_count < 2) {
            // too few frames to compare
            return 0;
        }

        const first = keys[0];
        const last = keys[key_count - 1];

        return last.time - first.time;
    }

    /**
     * Returns index of a key that is just before or at the time specified.
     * Useful for insertion and evaluation logic.
     * Note: if time is past the end of last key - index of the last key will be returned instead
     * @param {number} time
     * @returns {number} index of the key
     */
    getKeyIndexLow(time) {
        assert.isNumber(time, 'time');
        assert.notNaN(time, 'time');

        const keys = this.keys;
        const key_count = keys.length;

        if (key_count === 0) {
            // no keys
            return 0;
        }

        if (time <= keys[0].time) {
            // before start
            return 0;
        }

        if (time >= keys[key_count - 1].time) {
            // after the end
            return key_count - 1;
        }

        let found_index = 0;
        let i0 = 0;
        let i1 = key_count - 1;

        // binary search
        while (i0 <= i1) {
            const pivot = (i0 + i1) >>> 1;

            const key = keys[pivot];
            const key_time = key.time;

            if (key_time <= time) {
                found_index = pivot;
                i0 = pivot + 1;
            } else if (key_time > time) {
                i1 = pivot - 1;
            }
        }

        assert.lessThanOrEqual(keys[found_index].time, time, 'keys[found_index].time >= time');

        // fast-forward to last matching frame if there are multiple matches
        while (found_index + 2 < key_count && keys[found_index + 1].time === time) {
            found_index++;
        }

        return found_index;
    }

    /**
     * Evaluate interpolated value across the curve at a given time.
     * @param {number} time time in seconds
     * @return {number} value at the specified time
     */
    evaluate(time) {
        assert.isNumber(time, 'time');

        const keys = this.keys;
        const key_count = keys.length;

        if (key_count === 0) {
            // no keys, produce arbitrary value
            return 0;
        }

        if (key_count === 1) {
            // only have one key
            return keys[0].value;
        }

        const i = this.getKeyIndexLow(time);

        if (i >= key_count - 1) {
            // past last keyframe
            return keys[key_count - 1].value;
        }

        const keyframe0 = keys[i];

        if (keyframe0.time >= time) {
            // on or past the keyframe
            return keyframe0.value;
        }

        const keyframe1 = keys[i + 1];

        return evaluate_two_key_curve(time, keyframe0, keyframe1);

    }

    /**
     * Set tangents of a key to match surrounding keys
     * Produces a smoother looking curve
     * @param {number} index index of keyframe
     */
    alignTangents(index) {
        const keys = this.keys;
        const last_index = keys.length - 1;

        assert.isNonNegativeInteger(index, "index");
        assert.lessThanOrEqual(index, last_index, "index overflow");

        const key_main = keys[index];

        const has_previous = index > 0;

        const has_next = index < last_index;

        // TODO check out https://github.com/MonoGame/MonoGame/blob/f6ce4cbbe3ca5a93f0d1926f58ed501217a04069/MonoGame.Framework/Curve.cs#L232

        if (has_previous) {
            const key_previous = keys[index - 1];

            const time_span = key_main.time - key_previous.time;
            const value_span = key_main.value - key_previous.value;

            key_main.inTangent = value_span / time_span;
        }


        if (has_next) {
            const key_next = keys[index + 1];

            const time_span = key_next.time - key_main.time;
            const value_span = key_next.value - key_main.value;

            key_main.outTangent = value_span / time_span;
        }
    }

    /**
     * @deprecated
     * @param {number} index Index of keyframe to be affected
     * @param {number} weight How much smoothing to apply, 1 will be fully smoothed out and 0 will have no effect at all. Value between 0 and 1
     */
    smoothTangents(index, weight) {
        throw new Error('Deprecated, use alignTagents() instead');
    }

    /**
     * Smooth out the curve
     */
    alignAllTangents() {
        const n = this.length;

        // TODO add support for looping curves

        for (let i = 0; i < n; i++) {
            this.alignTangents(i);
        }
    }


    /**
     * The copy is deep
     * @param {AnimationCurve} other
     */
    copy(other) {
        this.keys = other.keys.map(invokeObjectClone);
    }

    /**
     *
     * @return {AnimationCurve}
     */
    clone() {
        const curve = new AnimationCurve();

        curve.copy(this);

        return curve;
    }

    /**
     *
     * @param {AnimationCurve} other
     * @return {boolean}
     */
    equals(other) {
        return isArrayEqual(this.keys, other.keys);
    }

    /**
     *
     * @return {number}
     */
    hash() {
        return computeHashArray(this.keys, invokeObjectHash);
    }

    toJSON() {
        return {
            keys: this.keys.map(invokeObjectToJSON)
        };
    }

    fromJSON({ keys = [] }) {
        this.clear();

        const key_count = keys.length;

        for (let i = 0; i < key_count; i++) {

            const keyframe = new Keyframe();

            keyframe.fromJSON(keys[i]);

            // The 'add' sorts the keys incrementally, we can do this faster if we assume the input is already sorted.
            // But to be safe we don't make that assumption

            this.add(keyframe);

        }

    }

    * [Symbol.iterator]() {

        for (const key of this.keys) {

            yield key;

        }

    }

    /**
     * Utility constructor
     * @param {Keyframe[]} keys
     * @returns {AnimationCurve}
     */
    static from(keys) {
        const curve = new AnimationCurve();

        curve.addMany(keys);

        return curve;
    }

    /**
     * S-shaped curve that starts slowly, ramps up and flattens out again.
     * Useful for pleasing transitions where exit and entry should not be abrupt.
     *
     * @param {number} [timeStart]
     * @param {number} [valueStart]
     * @param {number} [timeEnd]
     * @param {number} [valueEnd]
     * @return {AnimationCurve}
     */
    static easeInOut(
        timeStart = 0, valueStart = 0,
        timeEnd = 1, valueEnd = 1
    ) {
        return AnimationCurve.from([
            Keyframe.from(timeStart, valueStart, 0, 0),
            Keyframe.from(timeEnd, valueEnd, 0, 0)
        ]);
    }

    /**
     * A flat-line curve with a specific start and end time
     * @param {number} [timeStart]
     * @param {number} [timeEnd]
     * @param {number} [value]
     * @return {AnimationCurve}
     */
    static constant(timeStart = 0, timeEnd = 1, value = 0) {
        return AnimationCurve.from([
            Keyframe.from(timeStart, value, 0, 0),
            Keyframe.from(timeEnd, value, 0, 0)
        ]);
    }

    /**
     * Curve with two keyframes connected by a straight line
     * @param {number} [timeStart]
     * @param {number} [valueStart]
     * @param {number} [timeEnd]
     * @param {number} [valueEnd]
     * @return {AnimationCurve}
     */
    static linear(
        timeStart = 0, valueStart = 0,
        timeEnd = 1, valueEnd = 1
    ) {
        const time_delta = timeEnd - timeStart;

        // tangent needs to be normalized
        const tangent = time_delta === 0 ? 0 : (valueEnd - valueStart) / time_delta;

        return AnimationCurve.from([
            Keyframe.from(timeStart, valueStart, 0, tangent),
            Keyframe.from(timeEnd, valueEnd, tangent, 0)
        ]);
    }
}


/**
 * Useful for fast type checks
 * @readonly
 * @type {boolean}
 */
AnimationCurve.prototype.isAnimationCurve = true;


/**
 * Smooths out the curve.
 * Alias to {@link alignAllTangents}.
 */
AnimationCurve.prototype.smooth = AnimationCurve.prototype.alignAllTangents;