// This is a Next.js-compatible file-system router.
// It uses the filesystem to infer entry points.
// Despite being Next.js-compatible, it's not tied to Next.js.
// It does not handle the framework parts of rendering pages.
// All it does is resolve URL paths to the appropriate entry point and parse URL params/query.
const Router = @This();

const Api = @import("./api/schema.zig").Api;
const std = @import("std");
usingnamespace @import("global.zig");

const DirInfo = @import("./resolver/dir_info.zig");
const Fs = @import("./fs.zig");
const Options = @import("./options.zig");
const allocators = @import("./allocators.zig");
const URLPath = @import("./http/url_path.zig");
const PathnameScanner = @import("./query_string_map.zig").PathnameScanner;

const index_route_hash = @truncate(u32, std.hash.Wyhash.hash(0, "index"));
const arbitrary_max_route = 4096;

dir: StoredFileDescriptorType = 0,
routes: RouteMap,
loaded_routes: bool = false,
allocator: *std.mem.Allocator,
fs: *Fs.FileSystem,
config: Options.RouteConfig,

pub fn init(
    fs: *Fs.FileSystem,
    allocator: *std.mem.Allocator,
    config: Options.RouteConfig,
) !Router {
    return Router{
        .routes = RouteMap{
            .routes = Route.List{},
            .index = null,
            .allocator = allocator,
            .config = config,
        },
        .fs = fs,
        .allocator = allocator,
        .config = config,
    };
}

pub const EntryPointList = struct {
    entry_points: []const string,
    buffer: []u8,
};
pub fn getEntryPointsWithBuffer(this: *const Router, allocator: *std.mem.Allocator, comptime absolute: bool) !EntryPointList {
    var i: u16 = 0;
    const route_count: u16 = @truncate(u16, this.routes.routes.len);

    var count: usize = 0;
    var str_len: usize = 0;

    while (i < route_count) : (i += 1) {
        const children = this.routes.routes.items(.children)[i];
        count += @intCast(
            usize,
            @boolToInt(children.len == 0),
        );
        if (children.len == 0) {
            const entry = this.routes.routes.items(.entry)[i];
            str_len += entry.base().len + entry.dir.len;
        }
    }

    var buffer = try allocator.alloc(u8, str_len + count);
    var remain = buffer;
    var entry_points = try allocator.alloc(string, count);

    i = 0;
    var entry_point_i: usize = 0;
    while (i < route_count) : (i += 1) {
        const children = this.routes.routes.items(.children)[i];
        if (children.len == 0) {
            const entry = this.routes.routes.items(.entry)[i];
            if (comptime absolute) {
                var parts = [_]string{ entry.dir, entry.base() };
                entry_points[entry_point_i] = this.fs.absBuf(&parts, remain);
            } else {
                var parts = [_]string{ "/", this.config.asset_prefix_path, this.fs.relativeTo(entry.dir), entry.base() };
                entry_points[entry_point_i] = this.fs.joinBuf(&parts, remain);
            }

            remain = remain[entry_points[entry_point_i].len..];
            entry_point_i += 1;
        }
    }

    return EntryPointList{ .entry_points = entry_points, .buffer = buffer };
}

pub fn getEntryPoints(this: *const Router, allocator: *std.mem.Allocator) ![]const string {
    const list = try getEntryPointsWithBuffer(this, allocator, true);
    return list.entry_points;
}

const banned_dirs = [_]string{
    "node_modules",
};

// This loads routes recursively, in depth-first order.
// it does not currently handle duplicate exact route matches. that's undefined behavior, for now.
pub fn loadRoutes(
    this: *Router,
    root_dir_info: *const DirInfo,
    comptime ResolverType: type,
    resolver: *ResolverType,
    parent: u16,
    comptime is_root: bool,
) anyerror!void {
    var fs = &this.fs.fs;
    if (root_dir_info.getEntriesConst()) |entries| {
        var iter = entries.data.iterator();
        outer: while (iter.next()) |entry_ptr| {
            const entry = entry_ptr.value;
            if (entry.base()[0] == '.') {
                continue :outer;
            }

            switch (entry.kind(fs)) {
                .dir => {
                    inline for (banned_dirs) |banned_dir| {
                        if (strings.eqlComptime(entry.base(), comptime banned_dir)) {
                            continue :outer;
                        }
                    }

                    var abs_parts = [_]string{ entry.dir, entry.base() };
                    if (resolver.readDirInfoIgnoreError(this.fs.abs(&abs_parts))) |_dir_info| {
                        const dir_info: *const DirInfo = _dir_info;

                        var route: Route = Route.parse(
                            entry.base(),
                            Fs.PathName.init(entry.dir[this.config.dir.len..]).dirWithTrailingSlash(),
                            "",
                            entry,
                        );

                        route.parent = parent;

                        route.children.offset = @truncate(u16, this.routes.routes.len + 1);
                        try this.routes.routes.append(this.allocator, route);

                        // potential stack overflow!
                        try this.loadRoutes(
                            dir_info,
                            ResolverType,
                            resolver,
                            route.children.offset - 1,
                            false,
                        );

                        this.routes.routes.items(.children)[route.children.offset - 1].len = @truncate(u16, this.routes.routes.len) - route.children.offset;
                    }
                },

                .file => {
                    const extname = std.fs.path.extension(entry.base());
                    // exclude "." or ""
                    if (extname.len < 2) continue;

                    for (this.config.extensions) |_extname| {
                        if (strings.eql(extname[1..], _extname)) {
                            var route = Route.parse(
                                entry.base(),
                                // we extend the pointer length by one to get it's slash
                                entry.dir.ptr[this.config.dir.len..entry.dir.len],
                                extname,
                                entry,
                            );
                            route.parent = parent;

                            if (comptime is_root) {
                                if (strings.eqlComptime(route.name, "index")) {
                                    this.routes.index = @truncate(u32, this.routes.routes.len);
                                }
                            }

                            try this.routes.routes.append(
                                this.allocator,
                                route,
                            );
                        }
                    }
                },
            }
        }
    }

    if (comptime isDebug) {
        if (comptime is_root) {
            var i: usize = 0;
            Output.prettyErrorln("Routes (last segment only):", .{});
            while (i < this.routes.routes.len) : (i += 1) {
                const route = this.routes.routes.get(i);

                Output.prettyErrorln("   {s}: {s}", .{ route.name, route.path });
            }
            Output.prettyErrorln("  {d} routes\n", .{this.routes.routes.len});
            Output.flush();
        }
    }
}

pub const TinyPtr = packed struct {
    offset: u16 = 0,
    len: u16 = 0,

    pub inline fn str(this: TinyPtr, slice: string) string {
        return if (this.len > 0) slice[this.offset .. this.offset + this.len] else "";
    }
    pub inline fn toStringPointer(this: TinyPtr) Api.StringPointer {
        return Api.StringPointer{ .offset = this.offset, .length = this.len };
    }
};

pub const Param = struct {
    key: TinyPtr,
    kind: RoutePart.Tag,
    value: TinyPtr,

    pub const List = std.MultiArrayList(Param);
};

pub const Route = struct {
    part: RoutePart,
    name: string,
    path: string,
    hash: u32,
    children: Ptr = Ptr{},
    parent: u16 = top_level_parent,
    entry: *Fs.FileSystem.Entry,

    full_hash: u32,

    pub const top_level_parent = std.math.maxInt(u16);

    pub const List = std.MultiArrayList(Route);
    pub const Ptr = TinyPtr;

    pub fn parse(base: string, dir: string, extname: string, entry: *Fs.FileSystem.Entry) Route {
        const ensure_slash = if (dir.len > 0 and dir[dir.len - 1] != '/') "/" else "";

        var parts = [3]string{ dir, ensure_slash, base };
        // this isn't really absolute, it's relative to the pages dir
        const absolute = Fs.FileSystem.instance.join(&parts);
        const name = base[0 .. base.len - extname.len];
        const start_index: usize = if (absolute[0] == '/') 1 else 0;
        var hash_path = absolute[start_index .. absolute.len - extname.len];

        return Route{
            .name = name,
            .path = base,
            .entry = entry,
            .hash = @truncate(
                u32,
                std.hash.Wyhash.hash(
                    0,
                    name,
                ),
            ),
            .full_hash = @truncate(
                u32,
                std.hash.Wyhash.hash(
                    0,
                    hash_path,
                ),
            ),
            .part = RoutePart.parse(name),
        };
    }
};

// Reference: https://nextjs.org/docs/routing/introduction
// Examples:
// - pages/index.js => /
// - pages/foo.js => /foo
// - pages/foo/index.js => /foo
// - pages/foo/[bar] => {/foo/bacon, /foo/bar, /foo/baz, /foo/10293012930}
// - pages/foo/[...bar] => {/foo/bacon/toast, /foo/bar/what, /foo/baz, /foo/10293012930}
// Syntax:
// - [param-name]
// - Catch All: [...param-name]
// - Optional Catch All: [[...param-name]]
// Invalid syntax:
// - pages/foo/hello-[bar]
// - pages/foo/[bar]-foo
pub const RouteMap = struct {
    routes: Route.List,
    index: ?u32,
    allocator: *std.mem.Allocator,
    config: Options.RouteConfig,

    // This is passed here and propagated through Match
    // We put this here to avoid loading the FrameworkConfig for the client, on the server.
    client_framework_enabled: bool = false,

    pub threadlocal var segments_buf: [128]string = undefined;
    pub threadlocal var segments_hash: [128]u32 = undefined;

    pub fn routePathLen(this: *const RouteMap, _ptr: u16) u16 {
        return this.appendRoutePath(_ptr, &[_]u8{}, false);
    }

    // This is probably really slow
    // But it might be fine because it's mostly looking up within the same array
    // and that array is probably in the cache line
    var ptr_buf: [arbitrary_max_route]u16 = undefined;
    // TODO: skip copying parent dirs when it's another file in the same parent dir
    pub fn appendRoutePath(this: *const RouteMap, tail: u16, buf: []u8, comptime write: bool) u16 {
        var head: u16 = this.routes.items(.parent)[tail];

        var ptr_buf_count: i32 = 0;
        var written: u16 = 0;
        while (!(head == Route.top_level_parent)) : (ptr_buf_count += 1) {
            ptr_buf[@intCast(usize, ptr_buf_count)] = head;
            head = this.routes.items(.parent)[head];
        }

        var i: usize = @intCast(usize, ptr_buf_count);
        var remain = buf;
        while (i > 0) : (i -= 1) {
            const path = this.routes.items(.path)[
                @intCast(
                    usize,
                    ptr_buf[i],
                )
            ];
            if (comptime write) {
                std.mem.copy(u8, remain, path);

                remain = remain[path.len..];
                remain[0] = std.fs.path.sep;
                remain = remain[1..];
            }
            written += @truncate(u16, path.len + 1);
        }

        {
            const path = this.routes.items(.path)[tail];
            if (comptime write) {
                std.mem.copy(u8, remain, path);
            }
            written += @truncate(u16, path.len);
        }

        return written;
    }

    const MatchContext = struct {
        params: *Param.List,
        segments: []string,
        hashes: []u32,
        map: *RouteMap,
        allocator: *std.mem.Allocator,
        redirect_path: ?string = "",
        url_path: URLPath,

        matched_route_buf: []u8 = undefined,

        file_path: string = "",

        pub fn matchDynamicRoute(
            this: *MatchContext,
            head_i: u16,
            segment_i: u16,
        ) ?Match {
            const start_len = this.params.len;
            var head = this.map.routes.get(head_i);
            const remaining: []string = this.segments[segment_i + 1 ..];

            if ((remaining.len > 0 and head.children.len == 0)) {
                return null;
            }

            switch (head.part.tag) {
                .exact => {
                    // is it the end of an exact match?
                    if (!(this.hashes.len > segment_i and this.hashes[segment_i] == head.hash)) {
                        return null;
                    }
                },
                else => {},
            }

            var match_result: Match = undefined;
            if (head.children.len > 0 and remaining.len > 0) {
                var child_i = head.children.offset;
                const last = child_i + head.children.len;
                var matched = false;
                while (child_i < last) : (child_i += 1) {
                    if (this.matchDynamicRoute(child_i, segment_i + 1)) |res| {
                        match_result = res;
                        matched = true;
                        break;
                    }
                }

                if (!matched) {
                    this.params.shrinkRetainingCapacity(start_len);
                    return null;
                }
                // this is a folder
            } else if (remaining.len == 0 and head.children.len > 0) {
                this.params.shrinkRetainingCapacity(start_len);
                return null;
            } else {
                const entry = head.entry;
                var parts = [_]string{ entry.dir, entry.base() };
                const file_path = Fs.FileSystem.instance.absBuf(&parts, this.matched_route_buf);

                match_result = Match{
                    .path = head.path,
                    .name = Match.nameWithBasename(file_path, this.map.config.dir),
                    .params = this.params,
                    .hash = head.full_hash,
                    .query_string = this.url_path.query_string,
                    .pathname = this.url_path.pathname,
                    .basename = entry.base(),
                    .file_path = file_path,
                };

                this.matched_route_buf[match_result.file_path.len] = 0;
            }

            // Now that we know for sure the route will match, we append the param
            switch (head.part.tag) {
                .param => {
                    // account for the slashes
                    var segment_offset: u16 = segment_i;
                    for (this.segments[0..segment_i]) |segment| {
                        segment_offset += @truncate(u16, segment.len);
                    }
                    var total_offset: u16 = 0;

                    var current_i: u16 = head.parent;
                    const slices = this.map.routes;
                    const names = slices.items(.name);
                    const parents = slices.items(.parent);
                    while (current_i != Route.top_level_parent) : (current_i = parents[current_i]) {
                        total_offset += @truncate(u16, names[current_i].len);
                    }

                    this.params.append(
                        this.allocator,
                        Param{
                            .key = .{ .offset = head.part.name.offset + total_offset + segment_i, .len = head.part.name.len },
                            .value = .{ .offset = segment_offset, .len = @truncate(u16, this.segments[segment_i].len) },
                            .kind = head.part.tag,
                        },
                    ) catch unreachable;
                },
                else => {},
            }

            return match_result;
        }
    };

    // This makes many passes over the list of routes
    // However, most of those passes are basically array.indexOf(number) and then smallerArray.indexOf(number)
    pub fn matchPage(this: *RouteMap, routes_dir: string, file_path_buf: []u8, url_path: URLPath, params: *Param.List) ?Match {
        // Trim trailing slash
        var path = url_path.path;
        var redirect = false;

        // Normalize trailing slash
        // "/foo/bar/index/" => "/foo/bar/index"
        if (path.len > 0 and path[path.len - 1] == '/') {
            path = path[0 .. path.len - 1];
            redirect = true;
        }

        // Normal case: "/foo/bar/index" => "/foo/bar"
        // Pathological: "/foo/bar/index/index/index/index/index/index" => "/foo/bar"
        // Extremely pathological: "/index/index/index/index/index/index/index" => "index"
        while (strings.endsWith(path, "/index")) {
            path = path[0 .. path.len - "/index".len];
            redirect = true;
        }

        if (strings.eqlComptime(path, "index")) {
            path = "";
            redirect = true;
        }

        if (strings.eqlComptime(path, ".")) {
            path = "";
            redirect = false;
        }

        const routes_slice = this.routes.slice();

        if (path.len == 0) {
            if (this.index) |index| {
                const entry = routes_slice.items(.entry)[index];
                const parts = [_]string{ entry.dir, entry.base() };

                return Match{
                    .params = params,
                    .name = routes_slice.items(.name)[index],
                    .path = routes_slice.items(.path)[index],
                    .pathname = url_path.pathname,
                    .basename = entry.base(),
                    .hash = index_route_hash,
                    .file_path = Fs.FileSystem.instance.absBuf(&parts, file_path_buf),
                    .query_string = url_path.query_string,
                    .client_framework_enabled = this.client_framework_enabled,
                };
            }

            return null;
        }

        const full_hash = @truncate(u32, std.hash.Wyhash.hash(0, path));

        // Check for an exact match
        // These means there are no params.
        if (std.mem.indexOfScalar(u32, routes_slice.items(.full_hash), full_hash)) |exact_match| {
            const route = this.routes.get(exact_match);
            // It might be a folder with an index route
            // /bacon/index.js => /bacon
            if (route.children.len > 0) {
                const children = routes_slice.items(.hash)[route.children.offset .. route.children.offset + route.children.len];
                for (children) |child_hash, i| {
                    if (child_hash == index_route_hash) {
                        const entry = routes_slice.items(.entry)[i + route.children.offset];
                        const parts = [_]string{ entry.dir, entry.base() };
                        const file_path = Fs.FileSystem.instance.absBuf(&parts, file_path_buf);
                        return Match{
                            .params = params,
                            .name = Match.nameWithBasename(file_path, this.config.dir),
                            .path = routes_slice.items(.path)[i],
                            .pathname = url_path.pathname,
                            .basename = entry.base(),
                            .hash = child_hash,
                            .file_path = file_path,
                            .query_string = url_path.query_string,
                            .client_framework_enabled = this.client_framework_enabled,
                        };
                    }
                }
                // It's an exact route, there are no params
                // /foo/bar => /foo/bar.js
            } else {
                const entry = route.entry;
                const parts = [_]string{ entry.dir, entry.base() };
                const file_path = Fs.FileSystem.instance.absBuf(&parts, file_path_buf);
                return Match{
                    .params = params,
                    .name = Match.nameWithBasename(file_path, this.config.dir),
                    .path = route.path,
                    .redirect_path = if (redirect) path else null,
                    .hash = full_hash,
                    .basename = entry.base(),
                    .pathname = url_path.pathname,
                    .query_string = url_path.query_string,
                    .file_path = file_path,
                    .client_framework_enabled = this.client_framework_enabled,
                };
            }
        }

        var last_slash_i: usize = 0;
        var segments: []string = segments_buf[0..];
        var hashes: []u32 = segments_hash[0..];
        var segment_i: usize = 0;
        var splitter = std.mem.tokenize(u8, path, "/");
        while (splitter.next()) |part| {
            if (part.len == 0 or (part.len == 1 and part[0] == '.')) continue;
            segments[segment_i] = part;
            hashes[segment_i] = @truncate(u32, std.hash.Wyhash.hash(0, part));
            segment_i += 1;
        }
        segments = segments[0..segment_i];
        hashes = hashes[0..segment_i];

        // Now, we've established that there is no exact match.
        // Something will be dynamic
        // There are three tricky things about this.
        // 1. It's possible that the correct route is a catch-all route or an optional catch-all route.
        // 2. Given routes like this:
        //      * [name]/[id]
        //      * foo/[id]
        //    If the URL is /foo/123
        //    Then the correct route is foo/[id]
        var ctx = MatchContext{
            .params = params,
            .segments = segments,
            .hashes = hashes,
            .map = this,
            .redirect_path = if (redirect) path else null,
            .allocator = this.allocator,
            .url_path = url_path,
            .matched_route_buf = file_path_buf,
        };

        // iterate over the top-level routes
        if (ctx.matchDynamicRoute(0, 0)) |_dynamic_route| {
            // route name == the filesystem path relative to the pages dir excluding the file extension
            var dynamic_route = _dynamic_route;
            dynamic_route.client_framework_enabled = this.client_framework_enabled;
            return dynamic_route;
        }

        return null;
    }
};

// This is a u32
pub const RoutePart = packed struct {
    name: Ptr,
    tag: Tag,

    pub fn str(this: RoutePart, name: string) string {
        return switch (this.tag) {
            .exact => name,
            else => name[this.name.offset..][0..this.name.len],
        };
    }

    pub const Ptr = packed struct {
        offset: u14,
        len: u14,
    };

    pub const Tag = enum(u4) {
        optional_catch_all = 1,
        catch_all = 2,
        param = 3,
        exact = 4,
    };

    pub fn parse(base: string) RoutePart {
        std.debug.assert(base.len > 0);

        var part = RoutePart{
            .name = Ptr{ .offset = 0, .len = @truncate(u14, base.len) },
            .tag = .exact,
        };

        if (base[0] == '[') {
            if (base.len > 1) {
                switch (base[1]) {
                    ']' => {},

                    '[' => {
                        // optional catch all
                        if (strings.eqlComptime(base[1..std.math.min(base.len, 5)], "[...")) {
                            part.name.len = @truncate(u14, std.mem.indexOfScalar(u8, base[5..], ']') orelse return part);
                            part.name.offset = 5;
                            part.tag = .optional_catch_all;
                        }
                    },
                    '.' => {
                        // regular catch all
                        if (strings.eqlComptime(base[1..std.math.min(base.len, 4)], "...")) {
                            part.name.len = @truncate(u14, std.mem.indexOfScalar(u8, base[4..], ']') orelse return part);
                            part.name.offset = 4;
                            part.tag = .catch_all;
                        }
                    },
                    else => {
                        part.name.len = @truncate(u14, std.mem.indexOfScalar(u8, base[1..], ']') orelse return part);
                        part.tag = .param;
                        part.name.offset = 1;
                    },
                }
            }
        }

        return part;
    }
};

threadlocal var params_list: Param.List = undefined;
pub fn match(app: *Router, server: anytype, comptime RequestContextType: type, ctx: *RequestContextType) !void {
    // If there's an extname assume it's an asset and not a page
    switch (ctx.url.extname.len) {
        0 => {},
        // json is used for updating the route client-side without a page reload
        "json".len => {
            if (!strings.eqlComptime(ctx.url.extname, "json")) {
                try ctx.handleRequest();
                return;
            }
        },
        else => {
            try ctx.handleRequest();
            return;
        },
    }

    params_list.shrinkRetainingCapacity(0);
    var filepath_buf = std.mem.span(&ctx.match_file_path_buf);
    if (app.routes.matchPage(app.config.dir, filepath_buf, ctx.url, &params_list)) |route| {
        if (route.redirect_path) |redirect| {
            try ctx.handleRedirect(redirect);
            return;
        }

        std.debug.assert(route.path.len > 0);

        if (server.watcher.watchloop_handle == null) {
            server.watcher.start() catch {};
        }

        ctx.matched_route = route;
        RequestContextType.JavaScriptHandler.enqueue(ctx, server, filepath_buf, &params_list) catch {
            server.javascript_enabled = false;
        };
    }

    if (!ctx.controlled and !ctx.has_called_done) {
        try ctx.handleRequest();
    }
}

pub const Match = struct {
    /// normalized url path from the request
    path: string,
    /// raw url path from the request
    pathname: string,
    /// absolute filesystem path to the entry point
    file_path: string,
    /// route name, like `"posts/[id]"`
    name: string,

    client_framework_enabled: bool = false,

    /// basename of the route in the file system, including file extension
    basename: string,

    hash: u32,
    params: *Param.List,
    redirect_path: ?string = null,
    query_string: string = "",

    pub fn paramsIterator(this: *const Match) PathnameScanner {
        return PathnameScanner.init(this.pathname, this.name, this.params);
    }

    pub fn nameWithBasename(file_path: string, dir: string) string {
        var name = file_path;
        if (strings.indexOf(name, dir)) |i| {
            name = name[i + dir.len ..];
        }

        return name[0 .. name.len - std.fs.path.extension(name).len];
    }

    pub fn pathnameWithoutLeadingSlash(this: *const Match) string {
        return std.mem.trimLeft(u8, this.pathname, "/");
    }
};

const FileSystem = Fs.FileSystem;

const MockRequestContextType = struct {
    controlled: bool = false,
    url: URLPath,
    match_file_path_buf: [1024]u8 = undefined,

    handle_request_called: bool = false,
    redirect_called: bool = false,
    matched_route: ?Match = null,
    has_called_done: bool = false,

    pub fn handleRequest(this: *MockRequestContextType) !void {
        this.handle_request_called = true;
    }

    pub fn handleRedirect(this: *MockRequestContextType, pathname: string) !void {
        this.redirect_called = true;
    }

    pub const JavaScriptHandler = struct {
        pub fn enqueue(ctx: *MockRequestContextType, server: *MockServer, filepath_buf: []u8, params: *Router.Param.List) !void {}
    };
};

pub const MockServer = struct {
    watchloop_handle: ?StoredFileDescriptorType = null,
    watcher: Watcher = Watcher{},

    pub const Watcher = struct {
        watchloop_handle: ?StoredFileDescriptorType = null,
        pub fn start(this: *Watcher) anyerror!void {}
    };
};

fn makeTest(cwd_path: string, data: anytype) !void {
    std.debug.assert(cwd_path.len > 1 and !strings.eql(cwd_path, "/") and !strings.endsWith(cwd_path, "bun"));
    const bun_tests_dir = try std.fs.cwd().makeOpenPath("bun-test-scratch", .{ .iterate = true });
    bun_tests_dir.deleteTree(cwd_path) catch {};

    const cwd = try bun_tests_dir.makeOpenPath(cwd_path, .{ .iterate = true });
    try cwd.setAsCwd();

    const Data = @TypeOf(data);
    const fields: []const std.builtin.TypeInfo.StructField = comptime std.meta.fields(Data);
    inline for (fields) |field| {
        const value = @field(data, field.name);

        if (std.fs.path.dirname(field.name)) |dir| {
            try cwd.makePath(dir);
        }
        var file = try cwd.createFile(field.name, .{ .truncate = true });
        try file.writeAll(std.mem.span(value));
        file.close();
    }
}

const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;
const expectEqualStrings = std.testing.expectEqualStrings;
const Logger = @import("./logger.zig");

pub const Test = struct {
    pub fn make(comptime testName: string, data: anytype) !Router {
        try makeTest(testName, data);
        const JSAst = @import("./js_ast.zig");
        JSAst.Expr.Data.Store.create(default_allocator);
        JSAst.Stmt.Data.Store.create(default_allocator);
        var fs = try FileSystem.init1(default_allocator, null);
        var top_level_dir = fs.top_level_dir;

        var pages_parts = [_]string{ top_level_dir, "pages" };
        var pages_dir = try Fs.FileSystem.instance.absAlloc(default_allocator, &pages_parts);
        // _ = try std.fs.makeDirAbsolute(
        //     pages_dir,
        // );
        var router = try Router.init(&FileSystem.instance, default_allocator, Options.RouteConfig{
            .dir = pages_dir,
            .routes_enabled = true,
            .extensions = &.{"js"},
        });
        Output.initTest();

        const Resolver = @import("./resolver/resolver.zig").Resolver;
        var logger = Logger.Log.init(default_allocator);
        errdefer {
            logger.printForLogLevel(Output.errorWriter()) catch {};
        }

        var opts = Options.BundleOptions{
            .resolve_mode = .lazy,
            .platform = .browser,
            .loaders = undefined,
            .define = undefined,
            .log = &logger,
            .entry_points = &.{},
            .out_extensions = std.StringHashMap(string).init(default_allocator),
            .transform_options = std.mem.zeroes(Api.TransformOptions),
            .external = Options.ExternalModules.init(
                default_allocator,
                &FileSystem.instance.fs,
                FileSystem.instance.top_level_dir,
                &.{},
                &logger,
                .browser,
            ),
        };

        var resolver = Resolver.init1(default_allocator, &logger, &FileSystem.instance, opts);

        var root_dir = (try resolver.readDirInfo(pages_dir)).?;
        var entries = root_dir.getEntries().?;
        try router.loadRoutes(root_dir, Resolver, &resolver, 0, true);
        var entry_points = try router.getEntryPoints(default_allocator);

        try expectEqual(std.meta.fieldNames(@TypeOf(data)).len, entry_points.len);
        return router;
    }
};

test "Routes basic" {
    var server = MockServer{};
    var ctx = MockRequestContextType{
        .url = try URLPath.parse("/hi"),
    };
    var router = try Test.make("routes-basic", .{
        .@"pages/hi.js" = "//hi",
        .@"pages/index.js" = "//index",
        .@"pages/blog/hi.js" = "//blog/hi",
    });
    try router.match(&server, MockRequestContextType, &ctx);
    try expectEqualStrings(ctx.matched_route.?.name, "/hi");

    ctx = MockRequestContextType{
        .url = try URLPath.parse("/"),
    };

    try router.match(&server, MockRequestContextType, &ctx);
    try expectEqualStrings(ctx.matched_route.?.name, "/index");

    ctx = MockRequestContextType{
        .url = try URLPath.parse("/blog/hi"),
    };

    try router.match(&server, MockRequestContextType, &ctx);
    try expectEqualStrings(ctx.matched_route.?.name, "/blog/hi");

    ctx = MockRequestContextType{
        .url = try URLPath.parse("/blog/hey"),
    };

    try router.match(&server, MockRequestContextType, &ctx);
    try expect(ctx.matched_route == null);

    ctx = MockRequestContextType{
        .url = try URLPath.parse("/blog/"),
    };

    try router.match(&server, MockRequestContextType, &ctx);
    try expect(ctx.matched_route == null);

    ctx = MockRequestContextType{
        .url = try URLPath.parse("/pages/hi"),
    };

    try router.match(&server, MockRequestContextType, &ctx);
    try expect(ctx.matched_route == null);
}

test "Dynamic routes" {
    var server = MockServer{};
    var ctx = MockRequestContextType{
        .url = try URLPath.parse("/blog/hi"),
    };
    var filepath_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
    var router = try Test.make("routes-dynamic", .{
        .@"pages/index.js" = "//index.js",
        .@"pages/blog/hi.js" = "//blog-hi",
        .@"pages/posts/[id].js" = "//hi",
        // .@"pages/blog/posts/bacon.js" = "//index",
    });

    try router.match(&server, MockRequestContextType, &ctx);
    try expectEqualStrings(ctx.matched_route.?.name, "/blog/hi");

    var params = ctx.matched_route.?.paramsIterator();
    try expect(params.next() == null);

    ctx.matched_route = null;

    ctx.url = try URLPath.parse("/posts/123");
    try router.match(&server, MockRequestContextType, &ctx);

    params = ctx.matched_route.?.paramsIterator();

    try expectEqualStrings(ctx.matched_route.?.name, "/posts/[id]");
    try expectEqualStrings(params.next().?.rawValue(ctx.matched_route.?.pathname), "123");

    // ctx = MockRequestContextType{
    //     .url = try URLPath.parse("/"),
    // };

    // try router.match(&server, MockRequestContextType, &ctx);
    // try expectEqualStrings(ctx.matched_route.name, "index");
}
