// Copyright (C) 2023-2026  Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

// Shared bookkeeping for setTimeout / setInterval (and Window-only
// setImmediate / requestAnimationFrame / requestIdleCallback). Both Window
// and WorkerGlobalScope embed a Timers and forward their JS-bridged
// methods through `schedule` / `clear`.

const std = @import("std");
const lp = @import("lightpanda");

const js = @import("../js/js.zig");

const log = lp.log;
const Allocator = std.mem.Allocator;

const CLAMP_MS = 4;
const CLAMP_NESTING = 5;

const Timers = @This();

_timer_id: u30 = 0,
_callbacks: CallbackHashMap = .{},

// We keep the depth of the timers (a setTimeout calling a setTimeout). When
// the depth reaches CLAMP_NESTING, the minimum timeout is 4ms. This is per-
// spec and it's necessary to prevent some sites from virtually breaking because
// they repeatedly do heavy work in endlessly looping setTimeout with a short
// timeout (often of 0ms)
_nesting_level: u8 = 0,

const Key = u32;
const CallbackHashMap = std.HashMapUnmanaged(
    Key,
    *ScheduleCallback,
    struct {
        pub fn hash(_: @This(), key: Key) Key {
            return std.hash.int(key);
        }

        pub fn eql(_: @This(), a: Key, b: Key) bool {
            return std.meta.eql(a, b);
        }
    },
    std.hash_map.default_max_load_percentage,
);

pub const Mode = enum {
    idle,
    normal,
    animation_frame,
};

pub const ScheduleOpts = struct {
    repeat: bool,
    params: []js.Value.Global,
    name: []const u8,
    low_priority: bool = false,
    mode: Mode = .normal,
};

pub fn schedule(
    self: *Timers,
    exec: *js.Execution,
    cb: js.Function.Global,
    delay_ms: u32,
    opts: ScheduleOpts,
) !u32 {
    if (self._callbacks.count() > 512) {
        // these are active
        return error.TooManyTimeout;
    }

    const arena = try exec.getArena(.tiny, "Timers.schedule");
    errdefer exec.releaseArena(arena);

    const timer_id = self._timer_id +% 1;
    self._timer_id = timer_id;

    const nesting = @min(self._nesting_level + 1, CLAMP_NESTING + 1);
    const delay = if (nesting > CLAMP_NESTING and delay_ms < CLAMP_MS) CLAMP_MS else delay_ms;

    var persisted_params: []js.Value.Global = &.{};
    if (opts.params.len > 0) {
        persisted_params = try arena.dupe(js.Value.Global, opts.params);
    }

    const gop = try self._callbacks.getOrPut(exec.arena, timer_id);
    if (gop.found_existing) {
        // 2^31 would have to wrap for this to happen.
        return error.TooManyTimeout;
    }
    errdefer _ = self._callbacks.remove(timer_id);

    const callback = try arena.create(ScheduleCallback);
    callback.* = .{
        .cb = cb,
        .exec = exec,
        .timers = self,
        .arena = arena,
        .mode = opts.mode,
        .name = opts.name,
        .nesting = nesting,
        .timer_id = timer_id,
        .params = persisted_params,
        .repeat_ms = if (opts.repeat) if (delay == 0) 1 else delay else null,
    };
    gop.value_ptr.* = callback;

    try exec.js.scheduler.add(callback, ScheduleCallback.run, delay, .{
        .name = opts.name,
        .low_priority = opts.low_priority,
        .finalizer = ScheduleCallback.cancelled,
    });

    return timer_id;
}

pub fn clear(self: *Timers, id: u32) void {
    var sc = self._callbacks.fetchRemove(id) orelse return;
    sc.value.removed = true;
}

// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timerhandler
// TimerHandler = Function or DOMString. When a string is passed, it is
// compiled into an anonymous function body, matching how legacy browsers
// (and all current UAs) interpret `setTimeout("foo()", 100)`.
pub const LegacyHandler = union(enum) {
    function: js.Function.Global,
    string: js.String,

    pub fn resolve(handler: LegacyHandler, exec: *js.Execution) !js.Function.Global {
        switch (handler) {
            .function => |fun| return fun,
            .string => |str| {
                const fun = try exec.js.local.?.compileFunction(str, &.{}, &.{});
                return fun.persist();
            },
        }
    }
};

const ScheduleCallback = struct {
    // for debugging
    name: []const u8,

    // Timers._callbacks key
    timer_id: u31,

    // delay, in ms, to repeat. When null, removed after first invocation.
    repeat_ms: ?u32,

    // The nesting of this task. When it executes, this nesting will become
    // the Timer's _nesting_level so that any new timers will become nesting + 1
    nesting: u8,

    cb: js.Function.Global,

    mode: Mode,
    exec: *js.Execution,
    timers: *Timers,
    arena: Allocator,
    removed: bool = false,
    params: []const js.Value.Global,

    fn cancelled(ptr: *anyopaque) void {
        var self: *ScheduleCallback = @ptrCast(@alignCast(ptr));
        self.deinit();
    }

    fn deinit(self: *ScheduleCallback) void {
        self.cb.release();
        for (self.params) |param| {
            param.release();
        }
        self.exec.releaseArena(self.arena);
    }

    fn run(ptr: *anyopaque) !?u32 {
        const self: *ScheduleCallback = @ptrCast(@alignCast(ptr));
        if (self.removed) {
            self.deinit();
            return null;
        }

        var ls: js.Local.Scope = undefined;
        self.exec.js.localScope(&ls);
        defer ls.deinit();

        const timers = self.timers;
        const prev_nesting = timers._nesting_level;
        timers._nesting_level = self.nesting;
        defer timers._nesting_level = prev_nesting;

        switch (self.mode) {
            .idle => {
                const IdleDeadline = @import("IdleDeadline.zig");
                ls.toLocal(self.cb).call(void, .{IdleDeadline{}}) catch |err| {
                    log.warn(.js, "idleCallback", .{ .name = self.name, .err = err });
                };
            },
            .animation_frame => {
                const now = switch (self.exec.js.global) {
                    .frame => |frame| frame.window._performance.now(),
                    .worker => |worker| worker._performance.now(),
                };
                ls.toLocal(self.cb).call(void, .{now}) catch |err| {
                    log.warn(.js, "RAF", .{ .name = self.name, .err = err });
                };
            },
            .normal => {
                ls.toLocal(self.cb).call(void, self.params) catch |err| {
                    log.warn(.js, "timer", .{ .name = self.name, .err = err });
                };
            },
        }
        ls.local.runMicrotasks();

        if (self.repeat_ms) |ms| {
            // each repeat re-enters the timer initialization steps, so the
            // nesting level keeps growing and sub-4ms intervals get clamped.
            self.nesting = @min(self.nesting + 1, CLAMP_NESTING + 1);
            if (self.nesting > CLAMP_NESTING and ms < CLAMP_MS) {
                return CLAMP_MS;
            }
            return ms;
        }
        defer self.deinit();
        _ = self.timers._callbacks.remove(self.timer_id);
        return null;
    }
};
