//! Static asset serving (milestone-8 ruling 24).
//!
//! Production serves from `admin_assets`, the module the build generates from
//! `-Dadmin-dist`: bytes, content type and a strong ETag per file, plus a
//! `<name>.gz` sibling entry where compressing at build time paid off. Serving
//! is a linear scan over a handful of immutable entries — no allocation, no
//! clock, no disk.
//!
//! `ETag`/`If-None-Match` is the whole caching story. There is no
//! `Last-Modified` and no `Date`: std has no RFC 1123 formatter, and a strong
//! content hash validates an embedded immutable asset strictly better than a
//! timestamp would.
//!
//! An unknown path outside `/api` answers with index.html, 200 — the SPA owns
//! client-side routes, and its router needs the shell to load on a deep link.
//! `.gz` entries are reachable only through content negotiation, never as
//! paths of their own; each is a representation of its base file, with its own
//! ETag so a `304` is always judged against the representation that would be
//! served.
//!
//! Dev mode (`nxdns run --admin-dev <dir>`, wired by the CLI) serves from disk
//! with no cache headers, so a UI developer sees an edit on reload.

const std = @import("std");

const assets = @import("admin_assets");
const http_util = @import("http_util.zig");
const server = @import("server.zig");

const log = std.log.scoped(.web_static);

pub const File = assets.File;

/// What the build embedded. Entries are sorted by path and immutable.
pub const embedded: []const File = assets.files;

pub const index_path = "/index.html";

/// A disk asset a dev-mode request may read. Matches the embed limit in
/// tools/gen_admin_assets.zig.
pub const max_disk_asset_bytes = 64 * 1024 * 1024;

pub const Selection = struct {
    file: *const File,
    /// True when `file` is the gzip sibling and the response must carry
    /// `content-encoding: gzip`.
    gzip: bool,
};

/// Resolves a raw request path against `files`: exact match, `/` → index,
/// gzip sibling when the client accepts it. Null means no asset claims the
/// path and the caller decides between the SPA fallback and a 404.
pub fn select(files: []const File, raw_path: []const u8, accept_encoding: []const u8) ?Selection {
    const path = if (raw_path.len == 0 or std.mem.eql(u8, raw_path, "/")) index_path else raw_path;
    // A `.gz` entry is a representation, not an address.
    if (std.mem.endsWith(u8, path, ".gz")) return null;

    const file = find(files, path) orelse return null;

    if (acceptsGzip(accept_encoding)) {
        var buf: [http_util.max_target_len + 3]u8 = undefined;
        const sibling = std.fmt.bufPrint(&buf, "{s}.gz", .{path}) catch return .{ .file = file, .gzip = false };
        if (find(files, sibling)) |gz| return .{ .file = gz, .gzip = true };
    }
    return .{ .file = file, .gzip = false };
}

fn find(files: []const File, path: []const u8) ?*const File {
    for (files) |*file| {
        if (std.mem.eql(u8, file.path, path)) return file;
    }
    return null;
}

/// Whether `accept-encoding` admits gzip. Every comma-separated entry is
/// scanned; a `gzip` entry decides over `*`; `q=0` refuses; an entry whose
/// parameters fall outside the grammar is unusable and refuses. An empty
/// header (or one the connection budget dropped) reads as identity-only,
/// which degrades to the uncompressed entry.
fn acceptsGzip(header: []const u8) bool {
    var gzip_entry: ?bool = null;
    var wildcard_entry: ?bool = null;
    var tokens = std.mem.splitScalar(u8, header, ',');
    while (tokens.next()) |token| {
        var parts = std.mem.splitScalar(u8, token, ';');
        const name = std.mem.trim(u8, parts.next().?, " \t");
        const is_gzip = std.ascii.eqlIgnoreCase(name, "gzip");
        if (!is_gzip and !std.mem.eql(u8, name, "*")) continue;

        // The grammar admits one parameter and it is the weight.
        var acceptable = true;
        var saw_weight = false;
        while (parts.next()) |param| {
            const trimmed = std.mem.trim(u8, param, " \t");
            if (saw_weight or !std.ascii.startsWithIgnoreCase(trimmed, "q=")) {
                acceptable = false;
                break;
            }
            saw_weight = true;
            acceptable = qualityAccepts(trimmed[2..]);
        }
        if (is_gzip) gzip_entry = acceptable else wildcard_entry = acceptable;
    }
    return gzip_entry orelse wildcard_entry orelse false;
}

/// A well-formed nonzero qvalue: `0` or `1`, optionally `.` and up to three
/// digits, never exceeding 1. Malformed reads as not acceptable.
fn qualityAccepts(value: []const u8) bool {
    if (value.len == 0 or value.len > 5) return false;
    if (value[0] != '0' and value[0] != '1') return false;
    if (value.len > 1 and value[1] != '.') return false;
    var nonzero = value[0] == '1';
    if (value.len > 2) for (value[2..]) |c| {
        if (!std.ascii.isDigit(c)) return false;
        if (value[0] == '1' and c != '0') return false;
        if (c != '0') nonzero = true;
    };
    return nonzero;
}

/// Whether an `if-none-match` header names `etag` (which carries its quotes).
/// Weak validators compare by content: a `W/` prefix on the wire still matches,
/// because the bytes behind a content hash are the content.
fn etagMatches(header: []const u8, etag: []const u8) bool {
    var tokens = std.mem.splitScalar(u8, header, ',');
    while (tokens.next()) |token| {
        var candidate = std.mem.trim(u8, token, " \t");
        if (std.mem.eql(u8, candidate, "*")) return true;
        if (std.mem.startsWith(u8, candidate, "W/")) candidate = candidate[2..];
        if (std.mem.eql(u8, candidate, etag)) return true;
    }
    return false;
}

/// The SPA fallback handler (ruling 24): every non-`/api` path no route
/// claimed. W9 wires it as `WebState.fallback`.
pub fn fallback(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    _ = state;
    _ = io;

    if (request.method != .GET and request.method != .HEAD)
        return http_util.respondError(request, .not_found, "not found");

    const selection = select(embedded, request.raw_path, request.accept_encoding) orelse
        select(embedded, index_path, request.accept_encoding) orelse
        return http_util.respondError(request, .not_found, "not found");

    return respondAsset(request, selection);
}

fn respondAsset(request: *http_util.Request, selection: Selection) http_util.HandlerError!void {
    const file = selection.file;

    if (etagMatches(request.if_none_match, file.etag)) {
        return request.http.respond("", .{
            .status = .not_modified,
            .extra_headers = &.{
                .{ .name = "etag", .value = file.etag },
                .{ .name = "vary", .value = "accept-encoding" },
            },
        });
    }

    var headers_buf: [3]std.http.Header = .{
        .{ .name = "etag", .value = file.etag },
        .{ .name = "vary", .value = "accept-encoding" },
        .{ .name = "content-encoding", .value = "gzip" },
    };
    const headers: []const std.http.Header = headers_buf[0..if (selection.gzip) 3 else 2];
    return http_util.respondBytes(request, .ok, file.bytes, file.content_type, headers);
}

/// Joins decoded path segments back into a relative disk path, or null when
/// any segment could escape the root. Segments were split before percent
/// decoding, so a decoded segment may contain `/` — that and `..` are the two
/// traversal shapes, and both are refused rather than normalized.
fn diskRelativePath(buf: []u8, segments: []const []const u8) ?[]const u8 {
    if (segments.len == 0) return index_path[1..];
    var writer: std.Io.Writer = .fixed(buf);
    for (segments, 0..) |segment, index| {
        if (std.mem.eql(u8, segment, "..") or std.mem.eql(u8, segment, ".")) return null;
        if (std.mem.findScalar(u8, segment, '/') != null) return null;
        if (std.mem.findScalar(u8, segment, '\\') != null) return null;
        if (std.mem.findScalar(u8, segment, 0) != null) return null;
        if (index != 0) writer.writeAll("/") catch return null;
        writer.writeAll(segment) catch return null;
    }
    return writer.buffered();
}

/// Dev-mode disk serving for `--admin-dev` (ruling 24). No cache headers: the
/// point of the flag is that an edit shows up on the next reload. The CLI
/// wiring (W9) closes over the directory and passes it here.
pub fn serveFromDisk(
    root: []const u8,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    if (request.method != .GET and request.method != .HEAD)
        return http_util.respondError(request, .not_found, "not found");

    var path_buf: [http_util.max_target_len]u8 = undefined;
    const relative = diskRelativePath(&path_buf, request.path.segments()) orelse
        return http_util.respondError(request, .not_found, "not found");

    var dir = std.Io.Dir.cwd().openDir(io, root, .{}) catch |err| {
        log.warn("admin-dev directory '{s}' is unreadable: {t}", .{ root, err });
        return http_util.respondError(request, .internal_server_error, "admin-dev directory unavailable");
    };
    defer dir.close(io);

    if (readDiskFile(dir, io, request, relative)) |bytes|
        return http_util.respondBytes(request, .ok, bytes, contentType(relative), &.{});

    // SPA fallback, same rule as the embedded path.
    const index = readDiskFile(dir, io, request, index_path[1..]) orelse
        return http_util.respondError(request, .not_found, "not found");
    return http_util.respondBytes(request, .ok, index, contentType(index_path), &.{});
}

fn readDiskFile(
    dir: std.Io.Dir,
    io: std.Io,
    request: *http_util.Request,
    sub_path: []const u8,
) ?[]const u8 {
    if (!resolvesUnderRoot(dir, io, sub_path)) return null;
    return dir.readFileAlloc(io, sub_path, request.arena, .limited(max_disk_asset_bytes)) catch |err| {
        switch (err) {
            error.FileNotFound, error.IsDir => {},
            else => log.warn("admin-dev read of '{s}' failed: {t}", .{ sub_path, err }),
        }
        return null;
    };
}

/// The lexical checks in `diskRelativePath` cannot see a symlink inside the
/// tree pointing out of it, so the target's canonical path must sit under the
/// root's. Racy against a concurrent rename, which loopback operator tooling
/// tolerates; any failure to resolve reads as a 404.
fn resolvesUnderRoot(dir: std.Io.Dir, io: std.Io, sub_path: []const u8) bool {
    var root_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;
    var target_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;
    const root_len = dir.realPath(io, &root_buf) catch return false;
    const target_len = dir.realPathFile(io, sub_path, &target_buf) catch return false;
    const root = root_buf[0..root_len];
    const target = target_buf[0..target_len];
    return target.len > root.len + 1 and
        std.mem.startsWith(u8, target, root) and target[root.len] == '/';
}

/// Extension → MIME type for dev-mode disk serving. The embedded entries carry
/// the same mapping, stamped by tools/gen_admin_assets.zig; a test below keeps
/// the two from drifting.
pub fn contentType(path: []const u8) []const u8 {
    const map = [_]struct { ext: []const u8, mime: []const u8 }{
        .{ .ext = ".html", .mime = "text/html; charset=utf-8" },
        .{ .ext = ".js", .mime = "text/javascript" },
        .{ .ext = ".mjs", .mime = "text/javascript" },
        .{ .ext = ".css", .mime = "text/css" },
        .{ .ext = ".svg", .mime = "image/svg+xml" },
        .{ .ext = ".png", .mime = "image/png" },
        .{ .ext = ".ico", .mime = "image/x-icon" },
        .{ .ext = ".json", .mime = "application/json" },
        .{ .ext = ".map", .mime = "application/json" },
        .{ .ext = ".webmanifest", .mime = "application/manifest+json" },
        .{ .ext = ".txt", .mime = "text/plain; charset=utf-8" },
        .{ .ext = ".woff2", .mime = "font/woff2" },
        .{ .ext = ".woff", .mime = "font/woff" },
        .{ .ext = ".wasm", .mime = "application/wasm" },
    };
    for (map) |entry| {
        if (std.mem.endsWith(u8, path, entry.ext)) return entry.mime;
    }
    return "application/octet-stream";
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const testing = std.testing;

const test_files = [_]File{
    .{ .path = "/index.html", .bytes = "<html>", .content_type = "text/html; charset=utf-8", .etag = "\"aaaa\"" },
    .{ .path = "/index.html.gz", .bytes = "gz!", .content_type = "text/html; charset=utf-8", .etag = "\"bbbb\"" },
    .{ .path = "/app.css", .bytes = "body{}", .content_type = "text/css", .etag = "\"cccc\"" },
};

test "an exact path selects its file and the root selects the index" {
    const css = select(&test_files, "/app.css", "").?;
    try testing.expectEqualStrings("/app.css", css.file.path);
    try testing.expect(!css.gzip);

    try testing.expectEqualStrings("/index.html", select(&test_files, "/", "").?.file.path);
    try testing.expectEqualStrings("/index.html", select(&test_files, "", "").?.file.path);
    try testing.expect(select(&test_files, "/missing.js", "gzip") == null);
}

test "a gzip sibling is chosen only when the client accepts gzip" {
    const plain = select(&test_files, "/index.html", "").?;
    try testing.expect(!plain.gzip);
    try testing.expectEqualStrings("\"aaaa\"", plain.file.etag);

    const gz = select(&test_files, "/index.html", "gzip, br").?;
    try testing.expect(gz.gzip);
    try testing.expectEqualStrings("\"bbbb\"", gz.file.etag);
    try testing.expectEqualStrings("text/html; charset=utf-8", gz.file.content_type);

    // No sibling: the css stays identity even for a gzip client.
    try testing.expect(!select(&test_files, "/app.css", "gzip").?.gzip);
}

test "a .gz path is not addressable directly" {
    try testing.expect(select(&test_files, "/index.html.gz", "gzip") == null);
}

test "accept-encoding parsing scans every entry per the grammar" {
    const cases = [_]struct { header: []const u8, accepts: bool }{
        .{ .header = "gzip", .accepts = true },
        .{ .header = "GZIP", .accepts = true },
        .{ .header = "br, gzip;q=0.5", .accepts = true },
        .{ .header = " deflate , gzip ", .accepts = true },
        .{ .header = "*", .accepts = true },
        .{ .header = "*;q=0.5", .accepts = true },
        .{ .header = "gzip;q=0.001", .accepts = true },
        .{ .header = "gzip;q=1", .accepts = true },
        .{ .header = "gzip;q=1.000", .accepts = true },
        .{ .header = "gzip;Q=0.5", .accepts = true },
        .{ .header = "", .accepts = false },
        .{ .header = "br, deflate", .accepts = false },
        .{ .header = "gzip;q=0", .accepts = false },
        .{ .header = "gzip;q=0.000", .accepts = false },
        // A specific gzip entry decides over the wildcard, in either order.
        .{ .header = "*;q=0, gzip", .accepts = true },
        .{ .header = "gzip, *;q=0", .accepts = true },
        .{ .header = "gzip;q=0, *", .accepts = false },
        .{ .header = "*, gzip;q=0", .accepts = false },
        .{ .header = "*;q=0", .accepts = false },
        // Malformed entries are unusable, never acceptable.
        .{ .header = "gzip;q=invalid", .accepts = false },
        .{ .header = "gzip;q=", .accepts = false },
        .{ .header = "gzip;q=1.5", .accepts = false },
        .{ .header = "gzip;q=0.5000", .accepts = false },
        .{ .header = "gzip;q=0..5", .accepts = false },
        .{ .header = "gzip;level=9", .accepts = false },
        .{ .header = "gzip;q=0.5;q=1", .accepts = false },
        // A malformed gzip entry still decides over a usable wildcard.
        .{ .header = "*, gzip;q=invalid", .accepts = false },
    };
    for (cases) |case| {
        testing.expectEqual(case.accepts, acceptsGzip(case.header)) catch |err| {
            std.debug.print("header: '{s}'\n", .{case.header});
            return err;
        };
    }
}

test "if-none-match matches exact, listed, weak and wildcard validators" {
    try testing.expect(etagMatches("\"aaaa\"", "\"aaaa\""));
    try testing.expect(etagMatches("\"xxxx\", \"aaaa\"", "\"aaaa\""));
    try testing.expect(etagMatches("W/\"aaaa\"", "\"aaaa\""));
    try testing.expect(etagMatches("*", "\"aaaa\""));
    try testing.expect(!etagMatches("\"xxxx\"", "\"aaaa\""));
    try testing.expect(!etagMatches("", "\"aaaa\""));
    try testing.expect(!etagMatches("aaaa", "\"aaaa\""));
}

test "disk paths join segments and refuse every traversal shape" {
    var buf: [256]u8 = undefined;

    const nested = diskRelativePath(&buf, &.{ "assets", "app.js" }).?;
    try testing.expectEqualStrings("assets/app.js", nested);

    try testing.expectEqualStrings("index.html", diskRelativePath(&buf, &.{}).?);

    try testing.expect(diskRelativePath(&buf, &.{ "..", "secret" }) == null);
    try testing.expect(diskRelativePath(&buf, &.{"."}) == null);
    // `%2F` decodes inside a segment; a joined `/` must not appear.
    try testing.expect(diskRelativePath(&buf, &.{"../etc"}) == null);
    try testing.expect(diskRelativePath(&buf, &.{"a\\b"}) == null);

    var tiny: [4]u8 = undefined;
    try testing.expect(diskRelativePath(&tiny, &.{"toolong.html"}) == null);
}

test "dev-mode disk reads refuse a symlink that escapes the root" {
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    var root = try tmp.dir.createDirPathOpen(io, "root", .{});
    defer root.close(io);

    try root.writeFile(io, .{ .sub_path = "inside.txt", .data = "ok" });
    try tmp.dir.writeFile(io, .{ .sub_path = "outside.txt", .data = "secret" });
    try root.symLink(io, "../outside.txt", "escape.txt", .{});
    try root.symLink(io, "..", "updir", .{ .is_directory = true });

    try testing.expect(resolvesUnderRoot(root, io, "inside.txt"));
    try testing.expect(!resolvesUnderRoot(root, io, "escape.txt"));
    // A symlinked directory escapes through an intermediate component, which
    // no-follow on the final open would miss.
    try testing.expect(!resolvesUnderRoot(root, io, "updir/outside.txt"));
    try testing.expect(!resolvesUnderRoot(root, io, "missing.txt"));
}

test "the embedded dist has an index and consistent gzip siblings" {
    try testing.expect(embedded.len > 0);

    const index = find(embedded, index_path).?;
    try testing.expectEqualStrings("text/html; charset=utf-8", index.content_type);
    try testing.expect(index.bytes.len > 0);

    for (embedded) |file| {
        try testing.expect(file.etag.len >= 3);
        try testing.expectEqual(@as(u8, '"'), file.etag[0]);
        try testing.expectEqual(@as(u8, '"'), file.etag[file.etag.len - 1]);

        var occurrences: usize = 0;
        for (embedded) |other| {
            if (std.mem.eql(u8, other.path, file.path)) occurrences += 1;
        }
        try testing.expectEqual(@as(usize, 1), occurrences);

        if (std.mem.endsWith(u8, file.path, ".gz")) {
            const base = find(embedded, file.path[0 .. file.path.len - 3]).?;
            try testing.expect(file.bytes.len < base.bytes.len);
            // The gzip member header: build-time compression, not an accident.
            try testing.expectEqual(@as(u8, 0x1f), file.bytes[0]);
            try testing.expectEqual(@as(u8, 0x8b), file.bytes[1]);

            // A sibling is served under its base file's identity, so equal
            // content is the only thing that makes the substitution honest.
            var input: std.Io.Reader = .fixed(file.bytes);
            const window = try testing.allocator.alloc(u8, std.compress.flate.max_window_len);
            defer testing.allocator.free(window);
            var decompress: std.compress.flate.Decompress = .init(&input, .gzip, window);
            const plain = try decompress.reader.allocRemaining(
                testing.allocator,
                .limited(max_disk_asset_bytes),
            );
            defer testing.allocator.free(plain);
            try testing.expectEqualSlices(u8, base.bytes, plain);
        }
    }
}

test "embedded entries agree with the dev-mode content type map" {
    for (embedded) |file| {
        const base = if (std.mem.endsWith(u8, file.path, ".gz"))
            file.path[0 .. file.path.len - 3]
        else
            file.path;
        try testing.expectEqualStrings(contentType(base), file.content_type);
    }
}

test "every embedded etag is a quoted 32-digit hash" {
    for (embedded) |file| {
        try testing.expectEqual(@as(usize, 34), file.etag.len);
        try testing.expectEqual(@as(u8, '"'), file.etag[0]);
        try testing.expectEqual(@as(u8, '"'), file.etag[33]);
        for (file.etag[1..33]) |c| try testing.expect(std.ascii.isHex(c));
    }
}
