// Copyright (C) 2023-2025 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 builtin = @import("builtin");

const IS_TEST = builtin.is_test;

const Thread = std.Thread;

const is_debug = builtin.mode == .Debug;

pub const Scope = enum {
    app,
    dom,
    bug,
    browser,
    cdp,
    console,
    http,
    frame,
    js,
    event,
    scheduler,
    not_implemented,
    telemetry,
    unknown_prop,
    mcp,
    cache,
    websocket,
    storage,
};

pub const num_scopes = @typeInfo(Scope).@"enum".fields.len;

/// A single `--log-filter-scopes` directive. `scope == null` targets every
/// scope (the `all` keyword). `enable` is true for `+X` (filter in), false
/// for `-X` / bare `X` (filter out).
pub const FilterRule = struct {
    scope: ?Scope,
    enable: bool,
};

/// Resolve an ordered list of filter directives into a per-scope enabled
/// array. Directives apply left-to-right, so `-all,+cdp` disables every
/// scope then re-enables `cdp`. Scopes untouched by any directive stay
/// enabled.
pub fn resolveFilterScopes(rules: []const FilterRule) [num_scopes]bool {
    var scope_enabled = [_]bool{true} ** num_scopes;
    for (rules) |rule| {
        if (rule.scope) |scope| {
            scope_enabled[@intFromEnum(scope)] = rule.enable;
        } else {
            for (&scope_enabled) |*e| e.* = rule.enable;
        }
    }
    return scope_enabled;
}

const Opts = struct {
    format: Format = if (is_debug) .pretty else .logfmt,
    level: Level = if (is_debug) .info else .warn,
    // Per-scope enabled flags; a `false` entry suppresses that scope's logs.
    // Only consulted in Debug builds. Default: everything enabled.
    scope_enabled: [num_scopes]bool = [_]bool{true} ** num_scopes,
};

pub var opts = Opts{};

/// Optional sink for formatted log lines. The agent's REPL terminal sets
/// this so log output can be routed through `Spinner.emitAbove` instead
/// of trampling the spinner line on stderr.
pub var sink: ?*const fn (bytes: []const u8) void = null;

// synchronizes access to last_log
var last_log_lock: Thread.Mutex = .{};

pub fn enabled(scope: Scope, level: Level) bool {
    if (@intFromEnum(level) < @intFromEnum(opts.level)) {
        return false;
    }

    if (comptime builtin.mode == .Debug) {
        if (opts.scope_enabled[@intFromEnum(scope)] == false) {
            return false;
        }
    }

    return true;
}

// Ugliness to support complex debug parameters. Could add better support for
// this directly in writeValue, but we [currently] only need this in one place
// and I kind of don't want to encourage / make this easy.
pub fn separator() []const u8 {
    return if (opts.format == .pretty) "\n        " else "; ";
}

pub const Level = enum {
    debug,
    info,
    warn,
    err,
    fatal,
    note,
};

pub const Format = enum {
    logfmt,
    pretty,
};

pub fn debug(scope: Scope, msg: []const u8, data: anytype) void {
    log(scope, .debug, msg, data);
}

pub fn info(scope: Scope, msg: []const u8, data: anytype) void {
    log(scope, .info, msg, data);
}

pub fn warn(scope: Scope, msg: []const u8, data: anytype) void {
    log(scope, .warn, msg, data);
}

pub fn err(scope: Scope, msg: []const u8, data: anytype) void {
    log(scope, .err, msg, data);
}

pub fn fatal(scope: Scope, msg: []const u8, data: anytype) void {
    log(scope, .fatal, msg, data);
}

pub fn note(scope: Scope, msg: []const u8, data: anytype) void {
    if (comptime IS_TEST == false) {
        log(scope, .note, msg, data);
    }
}

pub fn log(scope: Scope, level: Level, msg: []const u8, data: anytype) void {
    if (enabled(scope, level) == false) {
        return;
    }

    if (sink) |s| {
        var buf: [4096]u8 = undefined;
        var w: std.Io.Writer = .fixed(&buf);
        logTo(scope, level, msg, data, &w) catch |log_err| {
            std.debug.print("$time={d} $level=fatal $scope={s} $msg=\"log err\" err={s} log_msg=\"{s}\"\n", .{ timestamp(.clock), @tagName(scope), @errorName(log_err), msg });
            return;
        };
        s(w.buffered());
        return;
    }

    std.debug.lockStdErr();
    defer std.debug.unlockStdErr();

    var buf: [4096]u8 = undefined;
    var stderr = std.fs.File.stderr();
    // writerStreaming, not writer: the default positional mode starts each
    // fresh Writer at offset 0, so when stderr is redirected to a regular file
    // every log line overwrites the previous one. Streaming writes at the fd
    // offset, which is what append-style logging needs.
    var writer = stderr.writerStreaming(&buf);

    logTo(scope, level, msg, data, &writer.interface) catch |log_err| {
        std.debug.print("$time={d} $level=fatal $scope={s} $msg=\"log err\" err={s} log_msg=\"{s}\"\n", .{ timestamp(.clock), @errorName(log_err), @tagName(scope), msg });
    };
}

// Converts each field of `data` into a runtime Value so that a single copy of
// the formatting code (logToErased and below) can do the actual writing.
fn logTo(scope: Scope, level: Level, msg: []const u8, data: anytype, out: *std.Io.Writer) !void {
    const fields = @typeInfo(@TypeOf(data)).@"struct".fields;
    var kvs: [fields.len]KV = undefined;
    inline for (fields, 0..) |f, i| {
        const value = @field(data, f.name);
        kvs[i] = .{ .key = f.name, .value = Value.init(&value) };
    }
    return logToErased(scope, level, msg, &kvs, out);
}

fn logToErased(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, out: *std.Io.Writer) !void {
    if (builtin.mode == .Debug) {
        if (msg.len > 30) {
            std.debug.print("debug-only-panic: log msg cannot be more than 30 characters: {s}", .{msg});
            @panic("invalid log msg");
        }
        for (msg) |b| {
            switch (b) {
                'A'...'Z', 'a'...'z', ' ', '0'...'9', '_', '-', '.', '{', '}' => {},
                else => {
                    std.debug.print("debug-only-panic: log msg contains an invalid character: {s}", .{msg});
                    @panic("invalid log msg");
                },
            }
        }
    }
    switch (opts.format) {
        .logfmt => try logLogfmt(scope, level, msg, kvs, out),
        .pretty => try logPretty(scope, level, msg, kvs, out),
    }
    out.flush() catch return;
}

fn logLogfmt(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, writer: *std.Io.Writer) !void {
    try logLogFmtPrefix(scope, level, msg, writer);
    for (kvs) |kv| {
        switch (kv.value) {
            // logFmt implementations write their own complete " key=value" pairs
            .log_fmt => |f| try f.logFmtFn(f.ptr, kv.key, writer),
            else => {
                try writer.print(" {s}=", .{kv.key});
                try writeErased(.logfmt, kv.value, writer);
            },
        }
    }
    try writer.writeByte('\n');
}

fn logLogFmtPrefix(scope: Scope, level: Level, msg: []const u8, writer: *std.Io.Writer) !void {
    try writer.writeAll("$time=");
    try writer.print("{d}", .{timestamp(.clock)});

    try writer.writeAll(" $scope=");
    try writer.writeAll(@tagName(scope));

    try writer.writeAll(" $level=");
    try writer.writeAll(if (level == .err) "error" else @tagName(level));

    try writer.writeAll(" $msg=\"");
    try writer.writeAll(msg);
    try writer.writeByte('"');
}

fn logPretty(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, writer: *std.Io.Writer) !void {
    try logPrettyPrefix(scope, level, msg, writer);
    for (kvs) |kv| {
        try writer.print("      {s} = ", .{kv.key});
        try writeErased(.pretty, kv.value, writer);
        try writer.writeByte('\n');
    }
    try writer.writeByte('\n');
}

fn logPrettyPrefix(scope: Scope, level: Level, msg: []const u8, writer: *std.Io.Writer) !void {
    if (scope == .console and level == .fatal) {
        try writer.writeAll("\x1b[0;104mWARN  ");
    } else {
        try writer.writeAll(switch (level) {
            .debug => "\x1b[0;36mDEBUG\x1b[0m ",
            .info => "\x1b[0;32mINFO\x1b[0m  ",
            .warn => "\x1b[0;33mWARN\x1b[0m  ",
            .err => "\x1b[0;31mERROR ",
            .fatal => "\x1b[0;35mFATAL ",
            .note => "\x1b[0;32mNOTE\x1b[0m  ",
        });
    }

    try writer.writeAll(@tagName(scope));
    try writer.writeAll(" : ");
    try writer.writeAll(msg);

    {
        // msg.len cannot be > 30, and @tagName(scope).len cannot be > 15
        // so this is safe
        const prefix_len = @tagName(scope).len + msg.len + 2;
        const padding = 55 - prefix_len;
        for (0..padding / 2) |_| {
            try writer.writeAll(" .");
        }
        if (@mod(padding, 2) == 1) {
            try writer.writeByte(' ');
        }
        const el = elapsed();
        try writer.print(" \x1b[0m[+{d}{s}]", .{ el.time, el.unit });
        try writer.writeByte('\n');
    }
}

const KV = struct {
    key: []const u8,
    value: Value,
};

const Value = union(enum) {
    null,
    string: []const u8,
    int: i64,
    uint: u64,
    float32: f32,
    float64: f64,
    boolean: bool,
    formatter: Formatter,
    log_fmt: LogFmt,

    const Formatter = struct {
        ptr: *const anyopaque,
        writeFn: *const fn (ptr: *const anyopaque, writer: *std.Io.Writer) anyerror!void,
    };

    const LogFmt = struct {
        ptr: *const anyopaque,
        // writes one or more complete " key=value" pairs (logfmt only)
        logFmtFn: *const fn (ptr: *const anyopaque, key: []const u8, writer: *std.Io.Writer) anyerror!void,
        // value-only formatting, used by the pretty format
        writeFn: *const fn (ptr: *const anyopaque, writer: *std.Io.Writer) anyerror!void,
    };

    fn init(vp: anytype) Value {
        const T = @TypeOf(vp.*);

        if (comptime std.meta.hasMethod(T, "logFmt")) {
            const Thunk = struct {
                fn logFmt(ptr: *const anyopaque, key: []const u8, writer: *std.Io.Writer) anyerror!void {
                    const value: *const T = @ptrCast(@alignCast(ptr));
                    return value.logFmt(key, LogFormatWriter{ .writer = writer });
                }
            };
            return .{ .log_fmt = .{
                .ptr = @ptrCast(vp),
                .logFmtFn = Thunk.logFmt,
                .writeFn = writeThunk(T, if (std.meta.hasMethod(T, "format")) "f" else ""),
            } };
        }

        if (comptime std.meta.hasMethod(T, "format")) {
            return formatterValue(vp, "f");
        }

        switch (@typeInfo(T)) {
            .optional => {
                if (vp.*) |_| {
                    return init(&vp.*.?);
                }
                return .null;
            },
            .comptime_int => {
                const value = vp.*;
                if (value >= 0 and value <= std.math.maxInt(u64)) {
                    return .{ .uint = value };
                }
                if (value < 0 and value >= std.math.minInt(i64)) {
                    return .{ .int = value };
                }
                return .{ .string = std.fmt.comptimePrint("{d}", .{value}) };
            },
            .int => |int_info| {
                if (comptime int_info.bits <= 64) {
                    return if (comptime int_info.signedness == .signed) .{ .int = vp.* } else .{ .uint = vp.* };
                }
                return formatterValue(vp, "d");
            },
            .comptime_float => return .{ .float64 = vp.* },
            .float => |float_info| switch (comptime float_info.bits) {
                32 => return .{ .float32 = vp.* },
                64 => return .{ .float64 = vp.* },
                else => return formatterValue(vp, "d"),
            },
            .bool => return .{ .boolean = vp.* },
            .error_set => return .{ .string = @errorName(vp.*) },
            .@"enum" => return .{ .string = @tagName(vp.*) },
            .array => |arr| if (comptime arr.child == u8) {
                return .{ .string = vp };
            },
            .pointer => |ptr| switch (comptime ptr.size) {
                .slice => if (comptime ptr.child == u8) {
                    return .{ .string = vp.* };
                },
                .one => switch (@typeInfo(ptr.child)) {
                    .array => |arr| if (comptime arr.child == u8) {
                        return .{ .string = vp.* };
                    },
                    else => return formatterValue(vp, "f"),
                },
                else => {},
            },
            .@"union", .@"struct" => return formatterValue(vp, ""),
            else => {},
        }

        @compileError("cannot log a: " ++ @typeName(T));
    }
};

pub fn writeValue(comptime format: Format, value: anytype, writer: *std.Io.Writer) !void {
    return writeErased(format, Value.init(&value), writer);
}

fn formatterValue(vp: anytype, comptime spec: []const u8) Value {
    return .{ .formatter = .{
        .ptr = @ptrCast(vp),
        .writeFn = writeThunk(@TypeOf(vp.*), spec),
    } };
}

// The per-type fallback for values that Value cannot represent as a primitive:
fn writeThunk(comptime T: type, comptime spec: []const u8) *const fn (*const anyopaque, *std.Io.Writer) anyerror!void {
    return struct {
        fn write(ptr: *const anyopaque, writer: *std.Io.Writer) anyerror!void {
            const vp: *const T = @ptrCast(@alignCast(ptr));
            return writer.print("{" ++ spec ++ "}", .{vp.*});
        }
    }.write;
}

fn writeErased(format: Format, value: Value, writer: *std.Io.Writer) !void {
    switch (value) {
        .null => return writer.writeAll("null"),
        .string => |s| return writeString(format, s, writer),
        .int => |n| return writer.print("{d}", .{n}),
        .uint => |n| return writer.print("{d}", .{n}),
        .float32 => |n| return writer.print("{d}", .{n}),
        .float64 => |n| return writer.print("{d}", .{n}),
        .boolean => |b| return writer.writeAll(if (b) "true" else "false"),
        .formatter => |f| return f.writeFn(f.ptr, writer),
        .log_fmt => |f| return f.writeFn(f.ptr, writer),
    }
}

fn writeString(format: Format, value: []const u8, writer: *std.Io.Writer) !void {
    if (format == .pretty) {
        return writer.writeAll(value);
    }

    var space_count: usize = 0;
    var escape_count: usize = 0;
    var binary_count: usize = 0;

    for (value) |b| {
        switch (b) {
            '\r', '\n', '"' => escape_count += 1,
            ' ' => space_count += 1,
            '\t', '!', '#'...'~' => {}, // printable characters
            else => binary_count += 1,
        }
    }

    if (binary_count > 0) {
        // TODO: use a different encoding if the ratio of binary data / printable is low
        return std.base64.standard_no_pad.Encoder.encodeWriter(writer, value);
    }

    if (escape_count == 0) {
        if (space_count == 0) {
            return writer.writeAll(value);
        }
        try writer.writeByte('"');
        try writer.writeAll(value);
        try writer.writeByte('"');
        return;
    }

    try writer.writeByte('"');

    var rest = value;
    while (rest.len > 0) {
        const pos = std.mem.indexOfAny(u8, rest, "\r\n\"") orelse {
            try writer.writeAll(rest);
            break;
        };
        try writer.writeAll(rest[0..pos]);
        try writer.writeByte('\\');
        switch (rest[pos]) {
            '"' => try writer.writeByte('"'),
            '\r' => try writer.writeByte('r'),
            '\n' => try writer.writeByte('n'),
            else => unreachable,
        }
        rest = rest[pos + 1 ..];
    }
    return writer.writeByte('"');
}

pub const LogFormatWriter = struct {
    writer: *std.Io.Writer,

    pub fn write(self: LogFormatWriter, key: []const u8, value: anytype) !void {
        const writer = self.writer;
        try writer.print(" {s}=", .{key});
        try writeErased(.logfmt, Value.init(&value), writer);
    }
};

var first_log: u64 = 0;
fn elapsed() struct { time: f64, unit: []const u8 } {
    const now = timestamp(.monotonic);

    last_log_lock.lock();
    defer last_log_lock.unlock();

    if (first_log == 0) {
        first_log = now;
    }

    const e = now - first_log;
    if (e < 10_000) {
        return .{ .time = @floatFromInt(e), .unit = "ms" };
    }
    return .{ .time = @as(f64, @floatFromInt(e)) / @as(f64, 1000), .unit = "s" };
}

const datetime = @import("datetime.zig");
fn timestamp(comptime mode: datetime.TimestampMode) u64 {
    if (IS_TEST) {
        return 1739795092929;
    }
    return datetime.milliTimestamp(mode);
}

const testing = @import("testing.zig");
test "log: data" {
    opts.format = .logfmt;
    defer opts.format = .pretty;

    var aw = std.Io.Writer.Allocating.init(testing.allocator);
    defer aw.deinit();

    {
        try logTo(.browser, .err, "nope", .{}, &aw.writer);
        try testing.expectEqual("$time=1739795092929 $scope=browser $level=error $msg=\"nope\"\n", aw.written());
    }

    {
        aw.clearRetainingCapacity();
        const string = try testing.allocator.dupe(u8, "spice_must_flow");
        defer testing.allocator.free(string);

        try logTo(.frame, .warn, "a msg", .{
            .cint = 5,
            .cfloat = 3.43,
            .int = @as(i16, -49),
            .float = @as(f32, 0.0003232),
            .bt = true,
            .bf = false,
            .nn = @as(?i32, 33),
            .n = @as(?i32, null),
            .lit = "over9000!",
            .slice = string,
            .err = error.Nope,
            .level = Level.warn,
        }, &aw.writer);

        try testing.expectEqual("$time=1739795092929 $scope=frame $level=warn $msg=\"a msg\" " ++
            "cint=5 cfloat=3.43 int=-49 float=0.0003232 bt=true bf=false " ++
            "nn=33 n=null lit=over9000! slice=spice_must_flow " ++
            "err=Nope level=warn\n", aw.written());
    }
}

test "log: string escape" {
    opts.format = .logfmt;
    defer opts.format = .pretty;

    var aw = std.Io.Writer.Allocating.init(testing.allocator);
    defer aw.deinit();

    const prefix = "$time=1739795092929 $scope=app $level=error $msg=\"test\" ";
    {
        try logTo(.app, .err, "test", .{ .string = "hello world" }, &aw.writer);
        try testing.expectEqual(prefix ++ "string=\"hello world\"\n", aw.written());
    }

    {
        aw.clearRetainingCapacity();
        try logTo(.app, .err, "test", .{ .string = "\n \thi  \" \" " }, &aw.writer);
        try testing.expectEqual(prefix ++ "string=\"\\n \thi  \\\" \\\" \"\n", aw.written());
    }
}

test "log: resolveFilterScopes" {
    // No directives: everything enabled.
    {
        const se = resolveFilterScopes(&.{});
        try testing.expectEqual(true, se[@intFromEnum(Scope.cdp)]);
        try testing.expectEqual(true, se[@intFromEnum(Scope.http)]);
    }

    // Backward compatible: bare/`-` scope filters that scope out, rest stay in.
    {
        const se = resolveFilterScopes(&.{
            .{ .scope = .cdp, .enable = false },
            .{ .scope = .http, .enable = false },
        });
        try testing.expectEqual(false, se[@intFromEnum(Scope.cdp)]);
        try testing.expectEqual(false, se[@intFromEnum(Scope.http)]);
        try testing.expectEqual(true, se[@intFromEnum(Scope.js)]);
    }

    // `-all,+cdp`: disable everything, then re-enable cdp.
    {
        const se = resolveFilterScopes(&.{
            .{ .scope = null, .enable = false },
            .{ .scope = .cdp, .enable = true },
        });
        try testing.expectEqual(true, se[@intFromEnum(Scope.cdp)]);
        try testing.expectEqual(false, se[@intFromEnum(Scope.http)]);
        try testing.expectEqual(false, se[@intFromEnum(Scope.js)]);
    }

    // `+all,-cdp`: enable everything, then disable cdp. Order matters.
    {
        const se = resolveFilterScopes(&.{
            .{ .scope = null, .enable = true },
            .{ .scope = .cdp, .enable = false },
        });
        try testing.expectEqual(false, se[@intFromEnum(Scope.cdp)]);
        try testing.expectEqual(true, se[@intFromEnum(Scope.http)]);
    }
}
