// 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 lp = @import("lightpanda");

const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;

const WPT_DIR = "tests/wpt";

// use in custom panic handler
var current_test: ?[]const u8 = null;

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    var http_server = try TestHTTPServer.init();
    defer http_server.deinit();

    {
        var wg: std.Thread.WaitGroup = .{};
        wg.startMany(1);
        var thrd = try std.Thread.spawn(.{}, TestHTTPServer.run, .{ &http_server, &wg });
        thrd.detach();
        wg.wait();
    }

    // An arena for the runner itself, lives for the duration of the the process
    var ra = ArenaAllocator.init(allocator);
    defer ra.deinit();
    const runner_arena = ra.allocator();

    const cmd = try parseArgs(runner_arena);

    var it = try TestIterator.init(allocator, WPT_DIR, cmd.filters);
    defer it.deinit();

    var writer = try Writer.init(allocator, cmd.format);
    defer writer.deinit();

    lp.log.opts.level = .warn;
    const config = try lp.Config.init(allocator, "lightpanda-wpt", .{ .serve = .{
        .common = .{
            .tls_verify_host = false,
            .user_agent_suffix = "internal-tester",
        },
    } });
    defer config.deinit(allocator);

    var app = try lp.App.init(allocator, &config);
    defer app.deinit();

    const http_client = try app.http.createClient(allocator);
    defer http_client.deinit();

    var browser = try lp.Browser.init(app, .{ .http_client = http_client });
    defer browser.deinit();

    // An arena for running each tests. Is reset after every test.
    var test_arena = ArenaAllocator.init(allocator);
    defer test_arena.deinit();

    var i: usize = 0;
    while (try it.next()) |test_file| {
        defer _ = test_arena.reset(.retain_capacity);

        defer current_test = null;
        current_test = test_file;

        var err_out: ?[]const u8 = null;
        const result = run(
            test_arena.allocator(),
            &browser,
            test_file,
            &err_out,
        ) catch |err| blk: {
            if (err_out == null) {
                err_out = @errorName(err);
            }
            break :blk null;
        };
        try writer.process(test_file, result, err_out);
        // if (@mod(i, 10) == 0) {
        //     std.debug.print("\n\n=== V8 Memory {d}===\n", .{i});
        //     browser.env.dumpMemoryStats();
        // }
        i += 1;
    }
    try writer.finalize();
}

fn run(
    arena: Allocator,
    browser: *lp.Browser,
    test_file: []const u8,
    err_out: *?[]const u8,
) ![]const u8 {
    const notification = try lp.Notification.init(browser.allocator);
    defer notification.deinit();

    const session = try browser.newSession(notification);
    defer browser.closeSession();

    const page = try session.createPage();
    defer session.removePage();

    const url = try std.fmt.allocPrintSentinel(arena, "http://localhost:9582/{s}", .{test_file}, 0);
    try page.navigate(url, .{});

    _ = session.wait(2000);

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

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

    // Check the final test status.
    ls.local.eval("report.status", "teststatus") catch |err| {
        const caught = try_catch.caughtOrError(arena, err);
        err_out.* = caught.exception;
        return err;
    };

    // return the detailed result.
    const value = ls.local.exec("report.log", "report") catch |err| {
        const caught = try_catch.caughtOrError(arena, err);
        err_out.* = caught.exception;
        return err;
    };

    return value.toStringSliceWithAlloc(arena);
}

const Writer = struct {
    format: Format,
    allocator: Allocator,
    pass_count: usize = 0,
    fail_count: usize = 0,
    case_pass_count: usize = 0,
    case_fail_count: usize = 0,
    writer: std.fs.File.Writer,
    cases: std.ArrayList(Case) = .{},

    const Format = enum { json, text, summary, quiet };

    fn init(allocator: Allocator, format: Format) !Writer {
        const out = std.fs.File.stdout();
        var writer = out.writer(&.{});

        if (format == .json) {
            try writer.interface.writeByte('[');
        }

        return .{
            .format = format,
            .writer = writer,
            .allocator = allocator,
        };
    }

    fn deinit(self: *Writer) void {
        self.cases.deinit(self.allocator);
    }

    fn finalize(self: *Writer) !void {
        var writer = &self.writer.interface;
        if (self.format == .json) {
            // When we write a test output, we add a trailing comma to act as
            // a separator for the next test. We need to add this dummy entry
            // to make it valid json.
            // Better option could be to change the formatter to work on JSONL:
            // https://github.com/lightpanda-io/perf-fmt/blob/main/wpt/wpt.go
            try writer.writeAll("{\"name\":\"empty\",\"pass\": true, \"cases\": []}]");
        } else {
            try writer.print("\n==Summary==\nTests: {d}/{d}\nCases: {d}/{d}\n", .{
                self.pass_count,
                self.pass_count + self.fail_count,
                self.case_pass_count,
                self.case_pass_count + self.case_fail_count,
            });
        }
    }

    fn process(self: *Writer, test_file: []const u8, result_: ?[]const u8, err_: ?[]const u8) !void {
        var writer = &self.writer.interface;
        if (err_) |err| {
            self.fail_count += 1;
            switch (self.format) {
                .text => return writer.print("Fail\t{s}\n\t{s}\n", .{ test_file, err }),
                .summary => return writer.print("Fail 0/0\t{s}\n", .{test_file}),
                .json => {
                    try std.json.Stringify.value(Test{
                        .pass = false,
                        .name = test_file,
                        .cases = &.{},
                    }, .{ .whitespace = .indent_2 }, writer);
                    return writer.writeByte(',');
                },
                .quiet => {},
            }
            // just make sure we didn't fall through by mistake
            unreachable;
        }

        // if we don't have an error, we must have a result
        const result = result_ orelse return error.InvalidResult;

        var cases = &self.cases;
        cases.clearRetainingCapacity(); // from previous run

        var pass = true;
        var case_pass_count: usize = 0;
        var case_fail_count: usize = 0;

        var lines = std.mem.splitScalar(u8, result, '\n');
        while (lines.next()) |line| {
            if (line.len == 0) {
                break;
            }
            // case names can have | in them, so we can't simply split on |
            var case_name = line;
            var case_pass = false; // so pessimistic!
            var case_message: []const u8 = "";

            if (std.mem.endsWith(u8, line, "|Pass")) {
                case_name = line[0 .. line.len - 5];
                case_pass = true;
                case_pass_count += 1;
            } else {
                // both cases names and messages can have | in them. Our only
                // chance to "parse" this is to anchor off the |$Status.
                const statuses = [_][]const u8{ "|Fail", "|Timeout", "|Not Run", "|Optional Feature Unsupported" };
                var pos_: ?usize = null;
                var message_start: usize = 0;
                for (statuses) |status| {
                    if (std.mem.indexOf(u8, line, status)) |idx| {
                        pos_ = idx;
                        message_start = idx + status.len;
                        break;
                    }
                }
                const pos = pos_ orelse {
                    std.debug.print("invalid result line: {s}\n", .{line});
                    return error.InvalidResult;
                };

                case_name = line[0..pos];
                case_message = line[message_start..];
                pass = false;
                case_fail_count += 1;
            }

            try cases.append(self.allocator, .{
                .name = case_name,
                .pass = case_pass,
                .message = case_message,
            });
        }

        // our global counters
        if (pass) {
            self.pass_count += 1;
        } else {
            self.fail_count += 1;
        }
        self.case_pass_count += case_pass_count;
        self.case_fail_count += case_fail_count;

        switch (self.format) {
            .summary => try writer.print("{s} {d}/{d}\t{s}\n", .{ statusText(pass), case_pass_count, case_pass_count + case_fail_count, test_file }),
            .text => {
                try writer.print("{s}\t{s}\n", .{ statusText(pass), test_file });
                for (cases.items) |c| {
                    try writer.print("\t{s}\t{s}\n", .{ statusText(c.pass), c.name });
                    if (c.message) |msg| {
                        try writer.print("\t\t{s}\n", .{msg});
                    }
                }
            },
            .json => {
                try std.json.Stringify.value(Test{
                    .pass = pass,
                    .name = test_file,
                    .cases = cases.items,
                }, .{ .whitespace = .indent_2 }, writer);
                // separator, see `finalize` for the hack we use to terminate this
                try writer.writeByte(',');
            },
            .quiet => {},
        }
    }

    fn statusText(pass: bool) []const u8 {
        return if (pass) "Pass" else "Fail";
    }
};

const Command = struct {
    format: Writer.Format,
    filters: [][]const u8,
};

fn parseArgs(arena: Allocator) !Command {
    const usage =
        \\usage: {s} [options] [test filter]
        \\  Run the Web Test Platform.
        \\
        \\  -h, --help       Print this help message and exit.
        \\  --json           result is formatted in JSON.
        \\  --summary        print a summary result. Incompatible w/ --json or --quiet
        \\  --quiet          No output. Incompatible w/ --json or --summary
        \\
    ;

    var args = try std.process.argsWithAllocator(arena);

    // get the exec name.
    const exec_name = args.next().?;

    var format = Writer.Format.text;
    var filters: std.ArrayList([]const u8) = .{};

    while (args.next()) |arg| {
        if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "--help", arg)) {
            std.debug.print(usage, .{exec_name});
            std.posix.exit(0);
        }

        if (std.mem.eql(u8, "--json", arg)) {
            format = .json;
        } else if (std.mem.eql(u8, "--summary", arg)) {
            format = .summary;
        } else if (std.mem.eql(u8, "--quiet", arg)) {
            format = .quiet;
        } else {
            try filters.append(arena, arg);
        }
    }

    return .{
        .format = format,
        .filters = filters.items,
    };
}

const TestIterator = struct {
    dir: Dir,
    walker: Dir.Walker,
    filters: [][]const u8,
    read_arena: ArenaAllocator,

    const Dir = std.fs.Dir;

    fn init(allocator: Allocator, root: []const u8, filters: [][]const u8) !TestIterator {
        var dir = try std.fs.cwd().openDir(root, .{ .iterate = true, .no_follow = true });
        errdefer dir.close();

        return .{
            .dir = dir,
            .filters = filters,
            .walker = try dir.walk(allocator),
            .read_arena = ArenaAllocator.init(allocator),
        };
    }

    fn deinit(self: *TestIterator) void {
        self.walker.deinit();
        self.dir.close();
        self.read_arena.deinit();
    }

    fn next(self: *TestIterator) !?[]const u8 {
        NEXT: while (try self.walker.next()) |entry| {
            if (entry.kind != .file) {
                continue;
            }

            if (std.mem.startsWith(u8, entry.path, "resources/")) {
                // resources for running the tests themselves, not actual tests
                continue;
            }

            if (!std.mem.endsWith(u8, entry.basename, ".html") and !std.mem.endsWith(u8, entry.basename, ".htm")) {
                continue;
            }

            const path = entry.path;
            for (self.filters) |filter| {
                if (std.mem.indexOf(u8, path, filter) == null) {
                    continue :NEXT;
                }
            }

            {
                defer _ = self.read_arena.reset(.retain_capacity);
                // We need to read the file's content to see if there's a
                // "testharness.js" in it. If there isn't, it isn't a test.
                // Shame we have to do this.

                const arena = self.read_arena.allocator();
                const full_path = try std.fs.path.join(arena, &.{ WPT_DIR, path });
                const file = try std.fs.cwd().openFile(full_path, .{});
                defer file.close();
                const html = try file.readToEndAlloc(arena, 128 * 1024);

                if (std.mem.indexOf(u8, html, "testharness.js") == null) {
                    // This isn't a test. A lot of files are helpers/content for tests to
                    // make use of.
                    continue :NEXT;
                }
            }

            return path;
        }

        return null;
    }
};

const Case = struct {
    pass: bool,
    name: []const u8,
    message: ?[]const u8,
};

const Test = struct {
    pass: bool,
    crash: bool = false,
    name: []const u8,
    cases: []Case,
};

const TestHTTPServer = struct {
    shutdown: bool,
    dir: std.fs.Dir,
    listener: ?std.net.Server,

    pub fn init() !TestHTTPServer {
        return .{
            .dir = try std.fs.cwd().openDir(WPT_DIR, .{}),
            .shutdown = true,
            .listener = null,
        };
    }

    pub fn deinit(self: *TestHTTPServer) void {
        self.shutdown = true;
        if (self.listener) |*listener| {
            listener.deinit();
        }
        self.dir.close();
    }

    pub fn run(self: *TestHTTPServer, wg: *std.Thread.WaitGroup) !void {
        const address = try std.net.Address.parseIp("127.0.0.1", 9582);

        self.listener = try address.listen(.{ .reuse_address = true });
        var listener = &self.listener.?;

        wg.finish();

        while (true) {
            const conn = listener.accept() catch |err| {
                if (self.shutdown) {
                    return;
                }
                return err;
            };
            const thrd = try std.Thread.spawn(.{}, handleConnection, .{ self, conn });
            thrd.detach();
        }
    }

    fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !void {
        defer conn.stream.close();

        var req_buf: [2048]u8 = undefined;
        var conn_reader = conn.stream.reader(&req_buf);
        var conn_writer = conn.stream.writer(&req_buf);

        var http_server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface);

        while (true) {
            var req = http_server.receiveHead() catch |err| switch (err) {
                error.ReadFailed => continue,
                error.HttpConnectionClosing => continue,
                else => {
                    std.debug.print("Test HTTP Server error: {}\n", .{err});
                    return err;
                },
            };

            self.handler(&req) catch |err| {
                std.debug.print("test http error '{s}': {}\n", .{ req.head.target, err });
                try req.respond("server error", .{ .status = .internal_server_error });
                return;
            };
        }
    }

    fn handler(server: *TestHTTPServer, req: *std.http.Server.Request) !void {
        const path = req.head.target;

        if (std.mem.eql(u8, path, "/")) {
            // There's 1 test that does an XHR request to this, and it just seems
            // to want a 200 success.
            return req.respond("Hello!", .{});
        }

        // strip out leading '/' to make the path relative
        const file = try server.dir.openFile(path[1..], .{});
        defer file.close();

        const stat = try file.stat();
        var send_buffer: [4096]u8 = undefined;

        var res = try req.respondStreaming(&send_buffer, .{
            .content_length = stat.size,
            .respond_options = .{
                .extra_headers = &.{
                    .{ .name = "content-type", .value = getContentType(path) },
                },
            },
        });

        var read_buffer: [4096]u8 = undefined;
        var reader = file.reader(&read_buffer);
        _ = try res.writer.sendFileAll(&reader, .unlimited);
        try res.writer.flush();
        try res.end();
    }

    pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void {
        var file = std.fs.cwd().openFile(file_path, .{}) catch |err| switch (err) {
            error.FileNotFound => return req.respond("server error", .{ .status = .not_found }),
            else => return err,
        };
        defer file.close();

        const stat = try file.stat();
        var send_buffer: [4096]u8 = undefined;

        var res = try req.respondStreaming(&send_buffer, .{
            .content_length = stat.size,
            .respond_options = .{
                .extra_headers = &.{
                    .{ .name = "content-type", .value = getContentType(file_path) },
                },
            },
        });

        var read_buffer: [4096]u8 = undefined;
        var reader = file.reader(&read_buffer);
        _ = try res.writer.sendFileAll(&reader, .unlimited);
        try res.writer.flush();
        try res.end();
    }

    fn getContentType(file_path: []const u8) []const u8 {
        if (std.mem.endsWith(u8, file_path, ".js")) {
            return "application/json";
        }

        if (std.mem.endsWith(u8, file_path, ".html")) {
            return "text/html";
        }

        if (std.mem.endsWith(u8, file_path, ".htm")) {
            return "text/html";
        }

        if (std.mem.endsWith(u8, file_path, ".xml")) {
            // some wpt tests do this
            return "text/xml";
        }

        if (std.mem.endsWith(u8, file_path, ".mjs")) {
            // mjs are ECMAScript modules
            return "application/json";
        }

        std.debug.print("TestHTTPServer asked to serve an unknown file type: {s}\n", .{file_path});
        return "text/html";
    }
};

pub const panic = std.debug.FullPanic(struct {
    pub fn panicFn(msg: []const u8, first_trace_addr: ?usize) noreturn {
        if (current_test) |ct| {
            std.debug.print("===panic running: {s}===\n", .{ct});
        }
        std.debug.defaultPanic(msg, first_trace_addr);
    }
}.panicFn);
