// 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/>.

const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;

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

const Function = @This();

local: *const js.Local,
this: ?*const v8.Object = null,
handle: *const v8.Function,

pub const Result = struct {
    stack: ?[]const u8,
    exception: []const u8,
};

pub fn withThis(self: *const Function, value: anytype) !Function {
    const local = self.local;
    const this_obj = if (@TypeOf(value) == js.Object)
        value.handle
    else
        (try local.zigValueToJs(value, .{})).handle;

    return .{
        .local = local,
        .this = this_obj,
        .handle = self.handle,
    };
}

pub fn newInstance(self: *const Function, caught: *js.TryCatch.Caught) !js.Object {
    const local = self.local;

    var try_catch: js.TryCatch = undefined;
    try_catch.init(local);
    defer try_catch.deinit();

    // This creates a new instance using this Function as a constructor.
    // const c_args = @as(?[*]const ?*c.Value, @ptrCast(&.{}));
    const handle = v8.v8__Function__NewInstance(self.handle, local.handle, 0, null) orelse {
        caught.* = try_catch.caughtOrError(local.call_arena, error.Unknown);
        return error.JsConstructorFailed;
    };

    return .{
        .local = local,
        .handle = handle,
    };
}

pub fn call(self: *const Function, comptime T: type, args: anytype) !T {
    var caught: js.TryCatch.Caught = undefined;
    return self._tryCallWithThis(T, self.getThis(), args, &caught, .{}) catch |err| {
        log.warn(.js, "call caught", .{ .err = err, .caught = caught });
        return err;
    };
}

pub fn callRethrow(self: *const Function, comptime T: type, args: anytype) !T {
    var caught: js.TryCatch.Caught = undefined;
    return self._tryCallWithThis(T, self.getThis(), args, &caught, .{ .rethrow = true }) catch |err| {
        log.warn(.js, "call caught", .{ .err = err, .caught = caught });
        return err;
    };
}

pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype) !T {
    var caught: js.TryCatch.Caught = undefined;
    return self._tryCallWithThis(T, this, args, &caught, .{}) catch |err| {
        log.warn(.js, "callWithThis caught", .{ .err = err, .caught = caught });
        return err;
    };
}

pub fn tryCall(self: *const Function, comptime T: type, args: anytype, caught: *js.TryCatch.Caught) !T {
    return self._tryCallWithThis(T, self.getThis(), args, caught, .{});
}

pub fn tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype, caught: *js.TryCatch.Caught) !T {
    return self._tryCallWithThis(T, this, args, caught, .{});
}

const CallOpts = struct {
    rethrow: bool = false,
};
fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype, caught: *js.TryCatch.Caught, comptime opts: CallOpts) !T {
    caught.* = .{};
    const local = self.local;

    // When we're calling a function from within JavaScript itself, this isn't
    // necessary. We're within a Caller instantiation, which will already have
    // incremented the call_depth and it won't decrement it until the Caller is
    // done.
    // But some JS functions are initiated from Zig code, and not v8. For
    // example, Observers, some event and window callbacks. In those cases, we
    // need to increase the call_depth so that the call_arena remains valid for
    // the duration of the function call. If we don't do this, the call_arena
    // will be reset after each statement of the function which executes Zig code.
    const ctx = local.ctx;
    const call_depth = ctx.call_depth;
    ctx.call_depth = call_depth + 1;
    defer ctx.call_depth = call_depth;

    const js_this = blk: {
        if (@TypeOf(this) == js.Object) {
            break :blk this;
        }
        break :blk try local.zigValueToJs(this, .{});
    };

    const aargs = if (comptime @typeInfo(@TypeOf(args)) == .null) struct {}{} else args;

    const js_args: []const *const v8.Value = switch (@typeInfo(@TypeOf(aargs))) {
        .@"struct" => |s| blk: {
            const fields = s.fields;
            var js_args: [fields.len]*const v8.Value = undefined;
            inline for (fields, 0..) |f, i| {
                js_args[i] = (try local.zigValueToJs(@field(aargs, f.name), .{})).handle;
            }
            const cargs: [fields.len]*const v8.Value = js_args;
            break :blk &cargs;
        },
        .pointer => blk: {
            var values = try local.call_arena.alloc(*const v8.Value, args.len);
            for (args, 0..) |a, i| {
                values[i] = (try local.zigValueToJs(a, .{})).handle;
            }
            break :blk values;
        },
        else => @compileError("JS Function called with invalid paremter type"),
    };

    const c_args = @as(?[*]const ?*v8.Value, @ptrCast(js_args.ptr));

    var try_catch: js.TryCatch = undefined;
    try_catch.init(local);
    defer try_catch.deinit();

    const handle = v8.v8__Function__Call(self.handle, local.handle, js_this.handle, @as(c_int, @intCast(js_args.len)), c_args) orelse {
        if ((comptime opts.rethrow) and try_catch.hasCaught()) {
            try_catch.rethrow();
            return error.TryCatchRethrow;
        }
        caught.* = try_catch.caughtOrError(local.call_arena, error.JsException);
        return error.JsException;
    };

    if (@typeInfo(T) == .void) {
        return {};
    }
    return local.jsValueToZig(T, .{ .local = local, .handle = handle });
}

fn getThis(self: *const Function) js.Object {
    const handle = if (self.this) |t| t else v8.v8__Context__Global(self.local.handle).?;
    return .{
        .local = self.local,
        .handle = handle,
    };
}

pub fn src(self: *const Function) ![]const u8 {
    return self.local.valueToString(.{ .local = self.local, .handle = @ptrCast(self.handle) }, .{});
}

pub fn getPropertyValue(self: *const Function, name: []const u8) !?js.Value {
    const local = self.local;
    const key = local.isolate.initStringHandle(name);
    const handle = v8.v8__Object__Get(self.handle, self.local.handle, key) orelse {
        return error.JsException;
    };

    return .{
        .local = local,
        .handle = handle,
    };
}

pub fn persist(self: *const Function) !Global {
    return self._persist(true);
}

pub fn temp(self: *const Function) !Temp {
    return self._persist(false);
}

fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Global else Temp) {
    var ctx = self.local.ctx;

    var global: v8.Global = undefined;
    v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
    if (comptime is_global) {
        try ctx.trackGlobal(global);
        return .{ .handle = global, .temps = {} };
    }
    try ctx.trackTemp(global);
    return .{ .handle = global, .temps = &ctx.session.temps };
}

pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
    const with_this = try self.withThis(value);
    return with_this.temp();
}

pub fn persistWithThis(self: *const Function, value: anytype) !Global {
    const with_this = try self.withThis(value);
    return with_this.persist();
}

pub const Temp = G(.temp);
pub const Global = G(.global);

const GlobalType = enum(u8) {
    temp,
    global,
};

fn G(comptime global_type: GlobalType) type {
    return struct {
        handle: v8.Global,
        temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,

        const Self = @This();

        pub fn deinit(self: *Self) void {
            v8.v8__Global__Reset(&self.handle);
        }

        pub fn local(self: *const Self, l: *const js.Local) Function {
            return .{
                .local = l,
                .handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
            };
        }

        pub fn isEqual(self: *const Self, other: Function) bool {
            return v8.v8__Global__IsEqual(&self.handle, other.handle);
        }

        pub fn release(self: *const Self) void {
            if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
                var g = kv.value;
                v8.v8__Global__Reset(&g);
            }
        }
    };
}
