import { assert } from "../../core/assert.js";
import Signal from "../../core/events/signal/Signal.js";
import Clock from '../Clock.js';

/**
 * Simulation tick generator
 * Dispatches "tick" every animation frame or "maxDelay" time, whichever happens to be soonest
 * This is essentially the timing bus that all the simulation systems subscribe to for their step advancement
 *
 *
 * @example
 *      ticker.onTick.add(delta_s => {
 *          //do something every tick
 *       });
 *       ticker.start();
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
class Ticker {
    /**
     * @readonly
     * @type {Clock}
     */
    clock = new Clock();

    /**
     * When `true`, ticking is suspended while the host document/tab is in the
     * background (hidden, or stored in the back/forward cache) and resumes from
     * the current moment once it returns to the foreground.
     *
     * This avoids dispatching a flood of catch-up ticks (or one huge delta) for
     * the time spent suspended, which would otherwise destabilise the simulation.
     *
     * Has no effect in environments without document/visibility support (e.g.
     * Node.js), where the relevant lifecycle events simply never fire.
     *
     * @type {boolean}
     */
    suspend_in_background = true;

    /**
     * @private
     * @type {boolean}
     */
    #isRunning = false;

    /**
     * Whether ticking is currently suspended because the host page is in the background
     * @private
     * @type {boolean}
     */
    #isSuspended = false;

    #animationFrameHandle = -1;
    #timeoutHandle = -1;

    /**
     * Time delta in seconds dispatched by the last tick
     * @type {number}
     */
    #last_tick_delta_s = 0;

    /**
     * Dispatches time delta in seconds since last tick
     * @readonly
     * @type {Signal<number>}
     */
    onTick = new Signal();

    constructor() {
        this.clock.pause();
    }

    #isUpdateLoopActive() {
        return this.#timeoutHandle !== -1 && this.#animationFrameHandle !== -1;
    }

    /**
     * Stop dispatching ticks because the host page moved to the background.
     * The clock is paused so no simulation time accrues while suspended. The
     * update loop itself keeps cycling so it can pick back up cleanly, but
     * {@link Ticker#onTick} stays silent until the page returns to the foreground.
     * @private
     */
    #suspend() {
        if (!this.suspend_in_background) {
            return;
        }

        if (!this.#isRunning) {
            // not actively ticking, nothing to suspend
            return;
        }

        if (this.#isSuspended) {
            return;
        }

        this.#isSuspended = true;

        this.clock.pause();
    }

    /**
     * Resume after the host page returned to the foreground.
     *
     * The clock is always restarted here (it was paused while suspended), with the
     * delta accumulated for the suspended period discarded so that time continues
     * from the current moment rather than catching up all at once. Whether ticks
     * actually flow afterwards is still governed by the running flag, so a manual
     * {@link Ticker#pause} taken during suspension is preserved.
     * @private
     */
    #resume() {
        if (!this.#isSuspended) {
            return;
        }

        this.#isSuspended = false;

        this.clock.getDelta(); //discard time spent suspended
        this.clock.start();
    }

    #handleVisibilityChange = () => {
        const doc = globalThis.document;

        if (doc === undefined) {
            return;
        }

        if (doc.visibilityState === 'hidden') {
            this.#suspend();
        } else {
            this.#resume();
        }
    };

    #handlePageHide = () => {
        this.#suspend();
    };

    #handlePageShow = () => {
        this.#resume();
    };

    /**
     * Subscribe to page lifecycle events where the host environment provides them.
     * Degrades gracefully where there is no DOM event target (e.g. Node.js).
     * @private
     */
    #addLifecycleListeners() {
        const doc = globalThis.document;
        if (doc !== undefined && typeof doc.addEventListener === 'function') {
            doc.addEventListener('visibilitychange', this.#handleVisibilityChange);
        }

        if (typeof globalThis.addEventListener === 'function') {
            globalThis.addEventListener('pagehide', this.#handlePageHide);
            globalThis.addEventListener('pageshow', this.#handlePageShow);
        }
    }

    /**
     * @private
     */
    #removeLifecycleListeners() {
        const doc = globalThis.document;
        if (doc !== undefined && typeof doc.removeEventListener === 'function') {
            doc.removeEventListener('visibilitychange', this.#handleVisibilityChange);
        }

        if (typeof globalThis.removeEventListener === 'function') {
            globalThis.removeEventListener('pagehide', this.#handlePageHide);
            globalThis.removeEventListener('pageshow', this.#handlePageShow);
        }
    }

    /**
     *
     * @param {number} [maxTimeout]
     */
    start({ maxTimeout = 100 } = {}) {
        assert.isNumber(maxTimeout, 'maxTimeout');
        assert.greaterThan(maxTimeout, 0);

        if (this.#isUpdateLoopActive()) {
            throw new Error("Already running");
        }

        this.#isRunning = true;
        this.#isSuspended = false;

        const update = () => {
            if (!this.#isRunning || this.#isSuspended) {
                return;
            }

            const delta = this.clock.getDelta();

            this.#last_tick_delta_s = delta;

            this.onTick.send1(delta);
        }

        const timeoutCallback = () => {
            cancelAnimationFrame(this.#animationFrameHandle);
            animate();
        }

        const animationFrameCallback = () => {
            clearTimeout(this.#timeoutHandle);

            //push tick beyond animation frame stack allowing draw to happen
            this.#timeoutHandle = setTimeout(animate, 0);
        }

        const animate = () => {
            this.#animationFrameHandle = requestAnimationFrame(animationFrameCallback);

            update();

            this.#timeoutHandle = setTimeout(timeoutCallback, maxTimeout);
        }

        this.clock.getDelta(); //purge delta
        this.clock.start();

        this.#addLifecycleListeners();

        requestAnimationFrame(animationFrameCallback);

        //if we are starting while the page is already in the background, suspend right away
        const doc = globalThis.document;
        if (doc !== undefined && doc.visibilityState === 'hidden') {
            this.#suspend();
        }
    }

    pause() {

        if (!this.#isUpdateLoopActive()) {
            throw new Error("Not currently running");
        }

        this.#isRunning = false;
    }

    resume() {

        if (!this.#isUpdateLoopActive()) {
            throw new Error("Not currently running");
        }


        this.#isRunning = true;
    }

    stop() {
        this.#removeLifecycleListeners();

        clearTimeout(this.#timeoutHandle);
        cancelAnimationFrame(this.#animationFrameHandle);
        this.#isRunning = false;
        this.#isSuspended = false;

        this.#timeoutHandle = -1;
        this.#animationFrameHandle = -1;
    }
}

export default Ticker;
