//! Route matching and dispatch.
//!
//! The table is a flat array of literal patterns with at most one `{id}`
//! capture, matched segment by segment. A LAN admin API has a few dozen routes
//! and one request per user action, so a linear scan is the whole algorithm —
//! a trie would buy nothing and cost a build step.
//!
//! Dispatch is where the cross-cutting policies live, in the order a request
//! meets them: match, rate limit, authenticate, handle. Matching comes first
//! because both the limiter exemption (ruling 19: `/metrics` and `/api/health`
//! must never see a 429) and the auth exemption (ruling 18) are properties of
//! the matched route, not of the raw path.

const std = @import("std");
const http = std.http;

const http_util = @import("http_util.zig");
const routes_table = @import("routes.zig");
const server = @import("server.zig");

/// Every route the server serves. Ruling 23 reads this to prove the OpenAPI
/// document and the contract test cover the whole surface.
pub const routes: []const RouteInfo = routes_table.table;

pub const HandlerFn = *const fn (
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void;

/// Whether a route needs a session cookie when authentication is enabled.
/// Ruling 18 lists the open ones: health, version, metrics, the OpenAPI
/// document, login, and the static assets.
pub const Auth = enum { open, session };

/// Whether a route spends an API rate-limit token. Ruling 19 exempts the two
/// monitoring endpoints so a Prometheus scrape can never be throttled.
pub const RateLimit = enum { counted, exempt };

/// What a route does to the configuration, and therefore whether file
/// authority may allow it (milestone-20 ruling 7). `config_write` changes the
/// declarative state the managed file owns; `runtime_action` changes runtime
/// state the file never declares; `read` changes nothing.
pub const Policy = enum { read, config_write, runtime_action };

pub const RouteInfo = struct {
    method: http.Method,
    /// Segments separated by `/`, with at most one `{id}` capture, which must
    /// be a positive integer row id.
    pattern: []const u8,
    auth: Auth,
    /// No default: a new route states its class or does not compile.
    policy: Policy,
    handler: HandlerFn,
    rate_limit: RateLimit = .counted,
};

pub const Match = union(enum) {
    found: Found,
    /// The path matches a route registered under a different method.
    method_not_allowed,
    not_found,

    pub const Found = struct {
        route: *const RouteInfo,
        id: ?i64,
    };
};

/// Matches `segments` (already decoded) against `table`.
pub fn match(
    table: []const RouteInfo,
    method: http.Method,
    segments: []const []const u8,
) Match {
    var path_exists = false;
    for (table) |*route| {
        const id = matchPattern(route.pattern, segments) orelse continue;
        if (route.method != method) {
            path_exists = true;
            continue;
        }
        return .{ .found = .{ .route = route, .id = id } };
    }
    return if (path_exists) .method_not_allowed else .not_found;
}

/// Returns the `{id}` capture, or a null capture for a pattern without one.
/// The outer optional is "did the pattern match at all".
fn matchPattern(pattern: []const u8, segments: []const []const u8) ??i64 {
    var id: ?i64 = null;
    var index: usize = 0;
    var rest = pattern;
    while (rest.len != 0) {
        const end = std.mem.findScalar(u8, rest, '/') orelse rest.len;
        const part = rest[0..end];
        rest = if (end == rest.len) rest[end..] else rest[end + 1 ..];
        if (part.len == 0) continue;
        if (index == segments.len) return null;
        const segment = segments[index];
        index += 1;
        if (std.mem.eql(u8, part, "{id}")) {
            id = std.fmt.parseInt(i64, segment, 10) catch return null;
            // A row id is a positive integer; `-1` must 404, not reach SQL.
            if (id.? <= 0) return null;
            continue;
        }
        if (!std.mem.eql(u8, part, segment)) return null;
    }
    if (index != segments.len) return null;
    return id;
}

/// Fills `buf` with the `Allow` header value for a path that matched under
/// other methods. The returned slice borrows `buf`.
fn formatAllow(table: []const RouteInfo, segments: []const []const u8, buf: []u8) []const u8 {
    var writer: std.Io.Writer = .fixed(buf);
    var first = true;
    for (table) |*route| {
        if (matchPattern(route.pattern, segments) == null) continue;
        if (!first) writer.writeAll(", ") catch break;
        writer.writeAll(@tagName(route.method)) catch break;
        first = false;
    }
    return writer.buffered();
}

/// Runs one request to completion: match, limit, authenticate, handle.
///
/// Every exit responds. A `WriteFailed` on the way out is the client
/// disconnecting (ruling 28) and ends the connection.
pub fn dispatch(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    const segments = request.path.segments();
    const found = switch (match(state.routes, request.method, segments)) {
        .found => |f| f,
        .method_not_allowed => {
            var buf: [64]u8 = undefined;
            const allow = formatAllow(state.routes, segments, &buf);
            return respondMethodNotAllowed(request, allow);
        },
        // Ruling 24: an unknown non-`/api` path is the SPA's, and the static
        // handler answers it with index.html so client-side routing works. An
        // unknown `/api` path is a real 404 and must stay JSON.
        .not_found => {
            if (state.fallback) |fallback| {
                if (!std.mem.eql(u8, request.firstSegment(), "api")) {
                    return fallback(state, io, request);
                }
            }
            return http_util.respondError(request, .not_found, "not found");
        },
    };

    request.id = found.id;

    if (found.route.rate_limit == .counted) {
        const verdict = state.check_limit(state, io, request);
        if (!verdict.allowed) return respondRateLimited(request, verdict.retry_after_s);
    }

    if (found.route.auth == .session and !state.check_auth(state, io, request)) {
        return http_util.respondError(request, .unauthorized, "authentication required");
    }

    // Milestone-20 ruling 7, and it runs *after* the auth check on purpose:
    // rejecting before authenticating would tell an anonymous caller which
    // routes exist. An unauthenticated request to a protected route answers
    // 401 in both authority modes.
    if (found.route.policy == .config_write) {
        switch (state.authority) {
            .database => {},
            .managed_file => |path| return http_util.respondManagedByFile(request, path),
        }
    }

    return found.route.handler(state, io, request);
}

fn respondMethodNotAllowed(request: *http_util.Request, allow: []const u8) http_util.HandlerError!void {
    return http_util.respondBytes(
        request,
        .method_not_allowed,
        "{\"error\":\"method not allowed\"}",
        http_util.content_type_json,
        &.{.{ .name = "allow", .value = allow }},
    );
}

fn respondRateLimited(request: *http_util.Request, retry_after_seconds: u32) http_util.HandlerError!void {
    var buf: [16]u8 = undefined;
    const retry_after = std.fmt.bufPrint(&buf, "{d}", .{retry_after_seconds}) catch "60";
    return http_util.respondBytes(
        request,
        .too_many_requests,
        "{\"error\":\"rate limited\"}",
        http_util.content_type_json,
        &.{.{ .name = "retry-after", .value = retry_after }},
    );
}

const testing = std.testing;

fn noopHandler(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    _ = state;
    _ = io;
    _ = request;
}

const test_table = [_]RouteInfo{
    .{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt },
    .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler },
    .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler },
    .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler },
    .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
    .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
    .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = noopHandler },
    .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = noopHandler },
};

fn matchPath(method: http.Method, path: []const u8) Match {
    var buf: [128]u8 = undefined;
    @memcpy(buf[0..path.len], path);
    const parsed = http_util.parsePath(buf[0..path.len]) catch return .not_found;
    return match(&test_table, method, parsed.segments());
}

test "the matching table resolves every registered shape" {
    const cases = [_]struct { method: http.Method, path: []const u8, id: ?i64 }{
        .{ .method = .GET, .path = "/api/health", .id = null },
        .{ .method = .GET, .path = "/api/groups", .id = null },
        .{ .method = .POST, .path = "/api/groups", .id = null },
        .{ .method = .GET, .path = "/api/groups/7", .id = 7 },
        .{ .method = .PUT, .path = "/api/groups/7", .id = 7 },
        .{ .method = .DELETE, .path = "/api/groups/12", .id = 12 },
        .{ .method = .PUT, .path = "/api/groups/12/sources", .id = 12 },
    };
    for (cases) |case| {
        const found = matchPath(case.method, case.path).found;
        try testing.expectEqual(case.id, found.id);
        try testing.expectEqual(case.method, found.route.method);
    }
}

test "a trailing slash matches the same route" {
    try testing.expectEqual(@as(?i64, 7), matchPath(.GET, "/api/groups/7/").found.id);
    try testing.expectEqual(@as(?i64, null), matchPath(.GET, "/api/groups/").found.id);
}

test "an unregistered path is not found" {
    try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/nope")));
    try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api")));
    try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/7/sources/1")));
}

test "a non-numeric or non-positive id does not match the capture" {
    try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/abc")));
    try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/0")));
    try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/-1")));
}

test "a known path under an unknown method is 405, not 404" {
    try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.DELETE, "/api/groups")));
    try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.POST, "/api/groups/7")));
    try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.PUT, "/api/health")));
}

test "the allow header lists every method the path accepts" {
    var path_buf = "/api/groups".*;
    const collection = try http_util.parsePath(&path_buf);
    var buf: [64]u8 = undefined;
    try testing.expectEqualStrings("GET, POST", formatAllow(&test_table, collection.segments(), &buf));

    var item_buf = "/api/groups/7".*;
    const item = try http_util.parsePath(&item_buf);
    try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf));
}

test "matching carries the class the table declares, per route and not per prefix" {
    const cases = [_]struct { method: http.Method, path: []const u8, policy: Policy }{
        .{ .method = .GET, .path = "/api/groups", .policy = .read },
        .{ .method = .GET, .path = "/api/groups/7", .policy = .read },
        .{ .method = .POST, .path = "/api/groups", .policy = .config_write },
        .{ .method = .PUT, .path = "/api/groups/7", .policy = .config_write },
        .{ .method = .DELETE, .path = "/api/groups/7", .policy = .config_write },
        .{ .method = .PUT, .path = "/api/groups/7/sources", .policy = .config_write },
        // Same prefix, different class: the column is per route.
        .{ .method = .POST, .path = "/api/pause", .policy = .runtime_action },
    };
    for (cases) |case| {
        try testing.expectEqual(case.policy, matchPath(case.method, case.path).found.route.policy);
    }
}

test "the shipped route table classifies /api/blocklists by route, not by prefix" {
    var refresh: ?Policy = null;
    var create: ?Policy = null;
    for (routes) |route| {
        if (route.method != .POST) continue;
        if (std.mem.eql(u8, route.pattern, "/api/blocklists/update")) refresh = route.policy;
        if (std.mem.eql(u8, route.pattern, "/api/blocklists")) create = route.policy;
    }
    try testing.expectEqual(Policy.runtime_action, refresh.?);
    try testing.expectEqual(Policy.config_write, create.?);
}

test "the shipped route table is the one the router matches against" {
    try testing.expectEqual(routes_table.table.ptr, routes.ptr);
    try testing.expectEqual(routes_table.table.len, routes.len);
}
