//! The HTTP plumbing every web handler shares: the request view the router
//! builds, JSON and error responses, the capped body reader, and the pure
//! target/cookie parsers.
//!
//! Pure apart from the response helpers, which need the live `std.http.Server`
//! request. No sockets, no clock, no database.
//!
//! Two traps shape this file:
//!
//! - `Request.head.target` and every header string are invalidated the moment
//!   the body stream is initialised (http/Server.zig:594 calls
//!   `head.invalidateStrings`, Server.zig:230 documents it). Everything a
//!   handler may need after a body read is therefore copied out of the head
//!   before dispatch, into buffers the connection slot owns.
//! - `std.Uri.percentDecodeInPlace` is lenient: a truncated or non-hex escape
//!   is copied through as literal text. PLAN §19 wants malformed input
//!   rejected, not forwarded to a query, so this file decodes itself and
//!   returns `error.BadEscape`.

const std = @import("std");
const http = std.http;
const net = std.Io.net;
const Allocator = std.mem.Allocator;

/// Ruling 7. A request body larger than this is refused with 413 rather than
/// buffered: every body this API accepts is a small JSON document.
pub const max_body_bytes: usize = 1 << 20;

/// The request line's target, path plus query. The receive buffer is 8 KiB, so
/// a longer target cannot arrive intact anyway; a target over this is 414.
pub const max_target_len: usize = 2048;

/// A cookie header holding one session cookie is ~60 bytes. The rest of the
/// budget absorbs whatever else the browser sends for the origin.
pub const max_cookie_len: usize = 1024;

/// `accept-encoding` and `if-none-match` are the only other headers the layer
/// reads. Both are short; an over-long one is treated as absent.
pub const max_header_value_len: usize = 128;

/// How much of an `x-forwarded-for` header is kept. A trusted proxy appends its
/// own entry last, so the entry that names the real client always sits in the
/// final bytes; the copy therefore keeps the tail rather than the head (see
/// `server.copyHeaderSuffix`). 256 bytes hold an IPv6 entry many times over.
pub const max_xff_len: usize = 256;

/// A path deeper than this matches no route, so parsing can stop there.
pub const max_path_segments: usize = 8;

/// Query values are single domains, integers, booleans and timestamps. A longer
/// one is a 400, never a truncation.
pub const max_query_value_len: usize = 512;

pub const content_type_json = "application/json";
pub const content_type_text = "text/plain; charset=utf-8";

/// What a handler may fail with. Everything domain-specific — a missing row, a
/// bad body, a database error — is the handler's job to turn into a status code
/// (ruling 8); only these three escape.
pub const HandlerError = error{
    /// The client went away mid-response. Ruling 28: end the connection quietly.
    WriteFailed,
    /// The client sent an `expect` header nxdns does not implement.
    HttpExpectationFailed,
    OutOfMemory,
};

/// The path of a request, split into segments and percent-decoded.
///
/// Segments are split before they are decoded, so `%2F` inside a segment stays
/// inside it and cannot forge a path boundary. Empty segments are dropped, so
/// `/api/groups/` and `/api//groups` both read as `api`, `groups`.
pub const Path = struct {
    buf: [max_path_segments][]const u8,
    len: usize,

    pub const empty: Path = .{ .buf = undefined, .len = 0 };

    pub fn segments(self: *const Path) []const []const u8 {
        return self.buf[0..self.len];
    }
};

pub const PathError = error{ BadEscape, TooManySegments };

/// Decodes `buffer` in place. The returned `Path` borrows from it.
pub fn parsePath(buffer: []u8) PathError!Path {
    var path: Path = .empty;
    var rest = buffer;
    while (rest.len != 0) {
        const end = std.mem.findScalar(u8, rest, '/') orelse rest.len;
        const raw = rest[0..end];
        rest = if (end == rest.len) rest[end..] else rest[end + 1 ..];
        if (raw.len == 0) continue;
        if (path.len == max_path_segments) return error.TooManySegments;
        path.buf[path.len] = try decodeInPlace(raw, .literal_plus);
        path.len += 1;
    }
    return path;
}

/// Whether `+` means a space. It does in a query string (form encoding) and
/// does not in a path, where it is an ordinary character.
pub const PlusRule = enum { literal_plus, plus_is_space };

pub const DecodeError = error{BadEscape};

/// Percent-decodes `buffer` in place and returns the shortened slice. Decoding
/// only ever shrinks, so the write cursor never passes the read cursor.
pub fn decodeInPlace(buffer: []u8, plus: PlusRule) DecodeError![]u8 {
    var read: usize = 0;
    var write: usize = 0;
    while (read < buffer.len) : (write += 1) {
        const c = buffer[read];
        if (c == '%') {
            if (read + 3 > buffer.len) return error.BadEscape;
            const hi = hexDigit(buffer[read + 1]) orelse return error.BadEscape;
            const lo = hexDigit(buffer[read + 2]) orelse return error.BadEscape;
            buffer[write] = hi * 16 + lo;
            read += 3;
        } else if (c == '+' and plus == .plus_is_space) {
            buffer[write] = ' ';
            read += 1;
        } else {
            buffer[write] = c;
            read += 1;
        }
    }
    return buffer[0..write];
}

fn hexDigit(c: u8) ?u8 {
    return switch (c) {
        '0'...'9' => c - '0',
        'a'...'f' => c - 'a' + 10,
        'A'...'F' => c - 'A' + 10,
        else => null,
    };
}

pub const Pair = struct {
    /// Still percent-encoded. Every key this API defines is plain ASCII, so
    /// keys are compared raw and only values are decoded.
    key: []const u8,
    value: []const u8,
};

/// Walks `key=value` pairs separated by `&`. A pair without `=` yields an empty
/// value; an empty pair is skipped.
pub const PairIterator = struct {
    rest: []const u8,

    pub fn next(self: *PairIterator) ?Pair {
        while (self.rest.len != 0) {
            const end = std.mem.findScalar(u8, self.rest, '&') orelse self.rest.len;
            const raw = self.rest[0..end];
            self.rest = if (end == self.rest.len) self.rest[end..] else self.rest[end + 1 ..];
            if (raw.len == 0) continue;
            const eq = std.mem.findScalar(u8, raw, '=') orelse return .{ .key = raw, .value = "" };
            return .{ .key = raw[0..eq], .value = raw[eq + 1 ..] };
        }
        return null;
    }
};

pub fn queryPairs(query: []const u8) PairIterator {
    return .{ .rest = query };
}

pub const QueryError = error{ BadEscape, ValueTooLong };

/// Copies the value of `key` into `out`, decodes it there, and returns the
/// decoded slice. `null` means the key is absent. A value that does not fit
/// `out` is `error.ValueTooLong`, which the caller answers with 400 — it is
/// never silently truncated.
pub fn queryValue(query: []const u8, key: []const u8, out: []u8) QueryError!?[]u8 {
    var it = queryPairs(query);
    while (it.next()) |pair| {
        if (!std.mem.eql(u8, pair.key, key)) continue;
        if (pair.value.len > out.len) return error.ValueTooLong;
        @memcpy(out[0..pair.value.len], pair.value);
        return try decodeInPlace(out[0..pair.value.len], .plus_is_space);
    }
    return null;
}

pub const QueryIntError = QueryError || error{BadValue};

/// The whole decoded value must parse, so `?limit=10x` is a 400 rather than 10.
pub fn queryInt(comptime T: type, query: []const u8, key: []const u8) QueryIntError!?T {
    var buf: [max_query_value_len]u8 = undefined;
    const text = try queryValue(query, key, &buf) orelse return null;
    return std.fmt.parseInt(T, text, 10) catch error.BadValue;
}

/// Accepts the four spellings a browser query string realistically carries.
pub fn queryBool(query: []const u8, key: []const u8) QueryIntError!?bool {
    var buf: [max_query_value_len]u8 = undefined;
    const text = try queryValue(query, key, &buf) orelse return null;
    if (std.mem.eql(u8, text, "true") or std.mem.eql(u8, text, "1")) return true;
    if (std.mem.eql(u8, text, "false") or std.mem.eql(u8, text, "0")) return false;
    return error.BadValue;
}

/// Reads one cookie out of a `cookie` header value. Returns a slice of `header`.
pub fn cookieValue(header: []const u8, name: []const u8) ?[]const u8 {
    var rest = header;
    while (rest.len != 0) {
        const end = std.mem.findScalar(u8, rest, ';') orelse rest.len;
        var pair = rest[0..end];
        rest = if (end == rest.len) rest[end..] else rest[end + 1 ..];
        pair = std.mem.trim(u8, pair, " \t");
        const eq = std.mem.findScalar(u8, pair, '=') orelse continue;
        if (std.mem.eql(u8, pair[0..eq], name)) return pair[eq + 1 ..];
    }
    return null;
}

/// Ruling 17: `Secure` is deliberately absent. nxdns serves plain HTTP on the
/// LAN and TLS termination is the operator's proxy; setting `Secure` would make
/// the cookie unusable in the configuration nxdns actually ships.
///
/// `max_age_seconds` of null writes a session cookie; 0 deletes it.
pub fn formatSetCookie(
    buf: []u8,
    name: []const u8,
    value: []const u8,
    max_age_seconds: ?i64,
) error{NoSpace}![]const u8 {
    var writer: std.Io.Writer = .fixed(buf);
    writer.print("{s}={s}; HttpOnly; SameSite=Lax; Path=/", .{ name, value }) catch return error.NoSpace;
    if (max_age_seconds) |age| {
        writer.print("; Max-Age={d}", .{age}) catch return error.NoSpace;
    }
    return writer.buffered();
}

/// Everything a handler is allowed to know about the request. The router builds
/// it once per request, before any body read, out of buffers the connection
/// slot owns (see this file's header for why).
pub const Request = struct {
    /// The live request, for responding and for reading the body.
    http: *http.Server.Request,
    method: http.Method,
    /// Decoded path segments.
    path: Path,
    /// The raw target's path part, undecoded, for exact asset matching.
    raw_path: []const u8,
    /// The raw query string, without the `?`. Values are decoded on demand.
    query: []const u8,
    /// The `{id}` capture of the matched route, when it had one.
    id: ?i64,
    cookie: []const u8,
    accept_encoding: []const u8,
    if_none_match: []const u8,
    peer: net.IpAddress,
    /// Who the request is from, as far as every policy decision is concerned:
    /// the socket peer, unless that peer is a configured trusted proxy and the
    /// request carried an `x-forwarded-for`, in which case it is that header's
    /// last entry. Computed once per request by the router, because the header
    /// it derives from dies on the first body read.
    ///
    /// Only the address is meaningful — a forwarded entry carries no port, so
    /// the port reads 0 there. This file stays free of nxdns imports (the fuzz
    /// target builds it as a module root), so the address type is the std one;
    /// `address.NetAddress.fromIp` is what the policy sites key on.
    client_addr: net.IpAddress,
    /// Reset between requests on the same connection. Nothing allocated here
    /// survives the response.
    arena: Allocator,

    pub fn firstSegment(self: *const Request) []const u8 {
        return if (self.path.len == 0) "" else self.path.buf[0];
    }
};

pub const BodyError = error{
    OutOfMemory,
    /// Over `max_body_bytes` — ruling 8's 413.
    TooLarge,
    /// The peer stopped sending. The connection ends.
    ReadFailed,
    HttpExpectationFailed,
    WriteFailed,
};

/// Reads the whole request body, capped. Callable once per request: the
/// underlying reader is initialised on first use.
pub fn readBody(request: *Request) BodyError![]u8 {
    const staging = try request.arena.alloc(u8, 4096);
    const reader = try request.http.readerExpectContinue(staging);
    return reader.allocRemaining(request.arena, .limited(max_body_bytes)) catch |err| switch (err) {
        error.StreamTooLong => error.TooLarge,
        error.OutOfMemory => error.OutOfMemory,
        error.ReadFailed => error.ReadFailed,
    };
}

/// Parses the body as `T`. Unknown fields are rejected so a typo in a PUT is a
/// 400 rather than a silently ignored field.
pub fn parseBody(comptime T: type, request: *Request) (BodyError || error{BadJson})!std.json.Parsed(T) {
    const bytes = try readBody(request);
    return std.json.parseFromSlice(T, request.arena, bytes, .{
        .ignore_unknown_fields = false,
    }) catch error.BadJson;
}

/// Ruling 8's envelope. `message` is operator-facing text, never a raw internal
/// error string for a 500 (PLAN §19: details go to the log, not the wire).
///
/// Built on the request arena, like `respondJson` below. It used to build into
/// a 512-byte stack buffer and fall back to `text/plain` when the message
/// overflowed it, which made the documented JSON envelope a function of message
/// length — a long managed-file path (milestone-20 ruling 7) was enough to
/// demote it. The envelope is `application/json` at every length now.
pub fn respondError(
    request: *Request,
    status: http.Status,
    message: []const u8,
) HandlerError!void {
    var allocating: std.Io.Writer.Allocating = .init(request.arena);
    defer allocating.deinit();
    var stringify: std.json.Stringify = .{ .writer = &allocating.writer };
    stringify.beginObject() catch return error.OutOfMemory;
    stringify.objectField("error") catch return error.OutOfMemory;
    stringify.write(message) catch return error.OutOfMemory;
    stringify.endObject() catch return error.OutOfMemory;
    return respondBytes(request, status, allocating.written(), content_type_json, &.{});
}

/// Milestone-20 ruling 7's rejection: a configuration write under file
/// authority. One function, because the router rejects most of them and the
/// clients handler rejects the one that needs a row read — two wordings would
/// be two contracts.
pub fn respondManagedByFile(request: *Request, path: []const u8) HandlerError!void {
    const message = try std.fmt.allocPrint(
        request.arena,
        "configuration is managed by {s}; edit the file and restart",
        .{path},
    );
    return respondError(request, .forbidden, message);
}

/// Serialises `value` and responds. The document is built in the request arena
/// so `respond` can send a content-length rather than chunking.
pub fn respondJson(
    request: *Request,
    status: http.Status,
    value: anytype,
    extra_headers: []const http.Header,
) HandlerError!void {
    var allocating: std.Io.Writer.Allocating = .init(request.arena);
    defer allocating.deinit();
    std.json.Stringify.value(value, .{}, &allocating.writer) catch return error.OutOfMemory;
    return respondBytes(request, status, allocating.written(), content_type_json, extra_headers);
}

pub fn respondBytes(
    request: *Request,
    status: http.Status,
    body: []const u8,
    content_type: []const u8,
    extra_headers: []const http.Header,
) HandlerError!void {
    var headers: [8]http.Header = undefined;
    headers[0] = .{ .name = "content-type", .value = content_type };
    // Every call site passes a comptime-known list, the longest of them three
    // headers, so overflowing this array is a programmer error and never a
    // condition a request can produce.
    std.debug.assert(extra_headers.len + 1 <= headers.len);
    @memcpy(headers[1 .. 1 + extra_headers.len], extra_headers);
    return request.http.respond(body, .{
        .status = status,
        .extra_headers = headers[0 .. 1 + extra_headers.len],
    });
}

/// 204: no body, no content-type.
pub fn respondEmpty(request: *Request, status: http.Status) HandlerError!void {
    return request.http.respond("", .{ .status = status });
}

const testing = std.testing;

test "a path splits into segments and decodes each one" {
    var buf = "/api/groups/12".*;
    const path = try parsePath(&buf);
    try testing.expectEqual(@as(usize, 3), path.len);
    try testing.expectEqualStrings("api", path.buf[0]);
    try testing.expectEqualStrings("groups", path.buf[1]);
    try testing.expectEqualStrings("12", path.buf[2]);
}

test "empty segments collapse so a trailing slash changes nothing" {
    var with = "/api//groups/".*;
    const a = try parsePath(&with);
    var without = "/api/groups".*;
    const b = try parsePath(&without);
    try testing.expectEqual(b.len, a.len);
    try testing.expectEqualStrings(b.buf[1], a.buf[1]);
}

test "an encoded slash stays inside its segment" {
    var buf = "/api/rules/a%2Fb".*;
    const path = try parsePath(&buf);
    try testing.expectEqual(@as(usize, 3), path.len);
    try testing.expectEqualStrings("a/b", path.buf[2]);
}

test "a path deeper than the segment budget is refused" {
    var buf = "/1/2/3/4/5/6/7/8/9".*;
    try testing.expectError(error.TooManySegments, parsePath(&buf));
}

test "a plus in a path is a literal plus" {
    var buf = "/a+b".*;
    const path = try parsePath(&buf);
    try testing.expectEqualStrings("a+b", path.buf[0]);
}

test "a plus in a query value is a space" {
    var out: [16]u8 = undefined;
    const value = try queryValue("domain=a+b", "domain", &out) orelse return error.TestUnexpectedResult;
    try testing.expectEqualStrings("a b", value);
}

test "a truncated escape is rejected rather than passed through" {
    var out: [16]u8 = undefined;
    try testing.expectError(error.BadEscape, queryValue("domain=%2", "domain", &out));
    try testing.expectError(error.BadEscape, queryValue("domain=%", "domain", &out));
    try testing.expectError(error.BadEscape, queryValue("domain=%zz", "domain", &out));
}

test "an over-long query value is rejected rather than truncated" {
    var out: [4]u8 = undefined;
    try testing.expectError(error.ValueTooLong, queryValue("domain=abcde", "domain", &out));
}

test "query pairs tolerate empty pairs and missing values" {
    var it = queryPairs("a=1&&b&c=");
    try testing.expectEqualStrings("a", it.next().?.key);
    const b = it.next().?;
    try testing.expectEqualStrings("b", b.key);
    try testing.expectEqualStrings("", b.value);
    const c = it.next().?;
    try testing.expectEqualStrings("c", c.key);
    try testing.expectEqualStrings("", c.value);
    try testing.expectEqual(@as(?Pair, null), it.next());
}

test "an absent query key reads as null, not as an error" {
    var out: [16]u8 = undefined;
    try testing.expectEqual(@as(?[]u8, null), try queryValue("a=1", "b", &out));
    try testing.expectEqual(@as(?u32, null), try queryInt(u32, "a=1", "b"));
}

test "typed query values parse and reject" {
    try testing.expectEqual(@as(?u32, 250), try queryInt(u32, "limit=250", "limit"));
    try testing.expectError(error.BadValue, queryInt(u32, "limit=10x", "limit"));
    try testing.expectEqual(@as(?bool, true), try queryBool("blocked=1", "blocked"));
    try testing.expectEqual(@as(?bool, false), try queryBool("blocked=false", "blocked"));
    try testing.expectError(error.BadValue, queryBool("blocked=maybe", "blocked"));
}

test "a formatted cookie parses back to the same value" {
    var buf: [128]u8 = undefined;
    const header = try formatSetCookie(&buf, "nxdns_session", "abcDEF-_", null);
    try testing.expectEqualStrings("nxdns_session=abcDEF-_; HttpOnly; SameSite=Lax; Path=/", header);

    const cookie_header = "other=1; nxdns_session=abcDEF-_; last=2";
    try testing.expectEqualStrings("abcDEF-_", cookieValue(cookie_header, "nxdns_session").?);
}

test "a deleting cookie carries a zero max age" {
    var buf: [128]u8 = undefined;
    const header = try formatSetCookie(&buf, "nxdns_session", "", 0);
    try testing.expectEqualStrings("nxdns_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", header);
}

test "a cookie header without the wanted name reads as absent" {
    try testing.expectEqual(@as(?[]const u8, null), cookieValue("a=1; b=2", "nxdns_session"));
    try testing.expectEqual(@as(?[]const u8, null), cookieValue("", "nxdns_session"));
    try testing.expectEqual(@as(?[]const u8, null), cookieValue("novalue", "novalue"));
}

test "a set-cookie longer than its buffer fails instead of truncating" {
    var buf: [8]u8 = undefined;
    try testing.expectError(error.NoSpace, formatSetCookie(&buf, "nxdns_session", "x", null));
}
