//! Blocklist download over HTTP/1.1.
//!
//! One `Fetcher` wraps a caller-owned `std.http.Client`, which owns the
//! connection pool and the CA bundle, exactly as `upstream/doh_client.zig`
//! does. This file knows nothing about parsing, files or the database: it GETs
//! a URL and streams the bytes into a writer the caller supplies.
//!
//! The body is never held whole. A blocklist can reach `max_body_bytes`, and
//! the caller writes into a temporary file anyway, so nothing here allocates.
//!
//! There is no timeout parameter and no sleep. `std.http.Client` has no
//! per-request deadline, so the caller runs `fetch` under `io.concurrent` and
//! cancels the future; this file only propagates `error.Canceled`.

const std = @import("std");
const transport = @import("../upstream/transport.zig");

pub const max_body_bytes: usize = 64 * 1024 * 1024;

/// RFC 9110 recommends at least 8000 bytes for the redirect buffer
/// (`std.http.Client` doc comment, Client.zig:1128).
pub const redirect_buffer_len: usize = 8192;

pub const min_transfer_buf: usize = 16 * 1024;

pub const Error = error{
    BadUrl,
    ConnectFailed,
    TlsFailed,
    SendFailed,
    ReceiveFailed,
    HttpStatus,
    BodyTooLarge,
    Timeout,
    Canceled,
    OutOfMemory,
    SystemResources,
    Unexpected,
};

pub const Result = struct {
    bytes_read: u64,
    status: std.http.Status,
};

pub const Fetcher = struct {
    /// Caller-owned; shared across sources, pools connections.
    http: *std.http.Client,
    /// Caller-owned HTTP body transfer buffer, at least `min_transfer_buf`.
    transfer_buf: []u8,
    /// Caller-owned. `receiveHead` follows redirects itself and needs this to
    /// outlive `Request.uri`. At least `redirect_buffer_len`.
    redirect_buf: []u8,
    /// The status of the most recent response head, or null before the first
    /// one. `error.HttpStatus` carries no `Result`, and the operator's message
    /// needs the number, so it is readable here after a failed `fetch`.
    last_status: ?std.http.Status = null,

    /// GETs `url` and streams the body into `w`.
    pub fn fetch(
        self: *Fetcher,
        io: std.Io,
        url: []const u8,
        w: *std.Io.Writer,
    ) Error!Result {
        // `std.http.Client` carries the `std.Io` it was constructed with and
        // takes none per request. The parameter stays in the signature because
        // the manager drives every fetch through one `std.Io`.
        _ = io;

        std.debug.assert(self.transfer_buf.len >= min_transfer_buf);
        std.debug.assert(self.redirect_buf.len >= redirect_buffer_len);

        const uri = try parseUrl(url);

        self.last_status = null;

        var req = self.http.request(.GET, uri, .{
            .keep_alive = true,
            .headers = .{
                // Identity only: a compressed transfer encoding would need
                // `Response.readerDecompressing`, a decompression buffer and a
                // second failure surface, for a download that runs once a day.
                .accept_encoding = .{ .override = "identity" },
            },
        }) catch |err| return mapError(err, .connect);
        defer req.deinit();

        req.sendBodiless() catch |err| return mapError(err, .send);

        var resp = req.receiveHead(self.redirect_buf) catch |err| return mapError(err, .receive);

        self.last_status = resp.head.status;
        if (resp.head.status != .ok) return error.HttpStatus;

        // `content-type` is deliberately not checked: blocklists are served as
        // text/plain, application/octet-stream and text/html alike, and the
        // compiler's invalid-line counters are the honest signal about content.
        if (resp.head.content_length) |declared| {
            if (declared > max_body_bytes) return error.BodyTooLarge;
        }

        const body = resp.reader(self.transfer_buf);
        return .{ .bytes_read = try pumpBody(body, w, max_body_bytes), .status = resp.head.status };
    }
};

/// Streams the body into `w`, refusing more than `cap` bytes. `fetch` always
/// passes `max_body_bytes`; the parameter exists so the refusal is testable
/// without a 64 MiB body.
///
/// `w` is the only destination: `Reader.stream` hands the reader's buffered
/// bytes straight to the writer, so the body is never copied twice and the
/// reader's own buffer is never a destination slice. Reading into that buffer
/// instead — `readSliceShort(self.transfer_buf)` on a reader constructed over
/// `self.transfer_buf` — makes `@memcpy` copy the buffer onto itself
/// (Reader.zig:677) and panics the process on the first read that finds bytes
/// already buffered.
fn pumpBody(body: *std.Io.Reader, w: *std.Io.Writer, cap: usize) Error!u64 {
    var total: usize = 0;
    while (total < cap) {
        total += body.stream(w, .limited(cap - total)) catch |err| switch (err) {
            error.EndOfStream => return total,
            // The caller owns `w` and can read the concrete failure from its
            // own writer; this taxonomy has no member for a failing sink.
            error.WriteFailed => return error.Unexpected,
            else => |e| return mapError(e, .receive),
        };
    }

    // `cap` written exactly. One more byte separates a body that fits from one
    // that was cut off, and it must not reach `w`.
    var probe_buf: [1]u8 = undefined;
    var probe: std.Io.Writer.Discarding = .init(&probe_buf);
    const extra = body.stream(&probe.writer, .limited(1)) catch |err| switch (err) {
        error.EndOfStream => return total,
        error.WriteFailed => return error.Unexpected,
        else => |e| return mapError(e, .receive),
    };
    return if (extra == 0) total else error.BodyTooLarge;
}

/// A scheme other than `http`/`https`, an unparseable URL and a URL with no
/// host are one fault to the operator: the source row is unusable.
fn parseUrl(url: []const u8) Error!std.Uri {
    const uri = std.Uri.parse(url) catch return error.BadUrl;
    if (!std.mem.eql(u8, uri.scheme, "http") and
        !std.mem.eql(u8, uri.scheme, "https")) return error.BadUrl;
    const host = uri.host orelse return error.BadUrl;
    if (host.isEmpty()) return error.BadUrl;
    return uri;
}

/// Which call failed. The phase is what decides the classification, and only
/// the call site knows it — guessing it from an error name would be wrong the
/// first time two phases shared an error.
const Phase = enum { connect, send, receive };

// `error.X` in an expression names a member into existence rather than
// referring to one, so the switch in `mapError` would keep compiling — and
// silently stop matching — if std renamed either of these. This is what fails
// the build instead.
comptime {
    for ([_][]const u8{ "TlsInitializationFailed", "CertificateBundleLoadFailure" }) |name| {
        if (!errorSetHas(std.http.Client.RequestError, name)) {
            @compileError("std.http.Client.RequestError no longer names " ++ name);
        }
    }
}

fn errorSetHas(comptime Set: type, comptime name: []const u8) bool {
    for (@typeInfo(Set).error_set.?) |member| {
        if (std.mem.eql(u8, member.name, name)) return true;
    }
    return false;
}

/// The two names are the whole TLS surface this file can reach.
/// `std.http.Client` collapses every handshake fault into
/// `error.TlsInitializationFailed` (Client.zig:1470) and every bundle fault into
/// `error.CertificateBundleLoadFailure`, and this file never unwraps a
/// connection's stashed read cause — it maps the collapsed `error.ReadFailed` by
/// phase — so the record-layer members of `std.crypto.tls.Client.ReadError`
/// cannot arrive here. `doh_client.zig` does unwrap, and names them.
fn mapError(err: anyerror, phase: Phase) Error {
    if (transport.mapLocal(err)) |local| return narrowLocal(local);
    switch (err) {
        error.Timeout => return error.Timeout,
        // Both are ruled out by `parseUrl` before the client is touched.
        error.UnsupportedUriScheme, error.UriMissingHost => return error.BadUrl,
        error.TooManyHttpRedirects => return error.HttpStatus,
        else => {},
    }
    switch (err) {
        error.TlsInitializationFailed, error.CertificateBundleLoadFailure => return error.TlsFailed,
        else => {},
    }
    return switch (phase) {
        .connect => error.ConnectFailed,
        .send => error.SendFailed,
        .receive => error.ReceiveFailed,
    };
}

/// `transport.mapLocal` answers in `transport.ExchangeError`, which is wider
/// than this file's taxonomy. Both file-descriptor quotas are the same
/// exhaustion to a downloader, and `error.BufferTooSmall` cannot occur because
/// this file hands the client no undersized buffer.
fn narrowLocal(local: transport.ExchangeError) Error {
    return switch (local) {
        error.OutOfMemory => error.OutOfMemory,
        error.SystemResources,
        error.ProcessFdQuotaExceeded,
        error.SystemFdQuotaExceeded,
        => error.SystemResources,
        error.Canceled => error.Canceled,
        else => error.Unexpected,
    };
}

const testing = std.testing;

// `Reader.fixed` hands back a reader whose whole payload is already buffered
// (seek 0, end len) — the state that made the previous implementation copy the
// reader's buffer onto itself and abort the process mid-download.

test "pumpBody streams a fully buffered body without aliasing its source" {
    const payload = "0.0.0.0 ads.example.com\n0.0.0.0 tracker.example\n";
    var body: std.Io.Reader = .fixed(payload);
    var out: [payload.len]u8 = undefined;
    var sink: std.Io.Writer = .fixed(&out);

    const n = try pumpBody(&body, &sink, max_body_bytes);
    try testing.expectEqual(@as(u64, payload.len), n);
    try testing.expectEqualStrings(payload, out[0..payload.len]);
}

test "pumpBody accepts a body of exactly the cap" {
    const payload = "abcdefgh";
    var body: std.Io.Reader = .fixed(payload);
    var out: [payload.len]u8 = undefined;
    var sink: std.Io.Writer = .fixed(&out);

    const n = try pumpBody(&body, &sink, payload.len);
    try testing.expectEqual(@as(u64, payload.len), n);
    try testing.expectEqualStrings(payload, out[0..payload.len]);
}

test "pumpBody refuses a body one byte over the cap" {
    const payload = "abcdefgh";
    var body: std.Io.Reader = .fixed(payload);
    var out: [payload.len]u8 = undefined;
    var sink: std.Io.Writer = .fixed(&out);

    try testing.expectError(
        error.BodyTooLarge,
        pumpBody(&body, &sink, payload.len - 1),
    );
}

test "pumpBody reports an empty body as zero bytes" {
    var body: std.Io.Reader = .fixed("");
    var sink_buf: [0]u8 = .{};
    var discarding: std.Io.Writer.Discarding = .init(&sink_buf);

    try testing.expectEqual(@as(u64, 0), try pumpBody(&body, &discarding.writer, max_body_bytes));
}

fn undefinedFetcher(transfer_buf: []u8, redirect_buf: []u8) Fetcher {
    // `http` is never driven: every test below asserts a rejection that
    // happens before the first client call.
    const http: *std.http.Client = undefined;
    return .{ .http = http, .transfer_buf = transfer_buf, .redirect_buf = redirect_buf };
}

test "fetch rejects a non-http scheme before any client use" {
    var transfer_buf: [min_transfer_buf]u8 = undefined;
    var redirect_buf: [redirect_buffer_len]u8 = undefined;
    var f = undefinedFetcher(&transfer_buf, &redirect_buf);
    var sink_buf: [0]u8 = .{};
    var discarding: std.Io.Writer.Discarding = .init(&sink_buf);
    try testing.expectError(
        error.BadUrl,
        f.fetch(undefined, "ftp://example.com/list.txt", &discarding.writer),
    );
}

test "fetch rejects a url with no scheme before any client use" {
    var transfer_buf: [min_transfer_buf]u8 = undefined;
    var redirect_buf: [redirect_buffer_len]u8 = undefined;
    var f = undefinedFetcher(&transfer_buf, &redirect_buf);
    var sink_buf: [0]u8 = .{};
    var discarding: std.Io.Writer.Discarding = .init(&sink_buf);
    try testing.expectError(error.BadUrl, f.fetch(undefined, "x", &discarding.writer));
}

test "fetch rejects a url with no host before any client use" {
    var transfer_buf: [min_transfer_buf]u8 = undefined;
    var redirect_buf: [redirect_buffer_len]u8 = undefined;
    var f = undefinedFetcher(&transfer_buf, &redirect_buf);
    var sink_buf: [0]u8 = .{};
    var discarding: std.Io.Writer.Discarding = .init(&sink_buf);
    try testing.expectError(error.BadUrl, f.fetch(undefined, "https://", &discarding.writer));
}

test "parseUrl accepts http and https urls" {
    const plain = try parseUrl("http://example.com/hosts.txt");
    try testing.expectEqualStrings("http", plain.scheme);
    const secure = try parseUrl("https://example.com:8443/hosts.txt");
    try testing.expectEqualStrings("https", secure.scheme);
    try testing.expectEqual(@as(?u16, 8443), secure.port);
}

test "parseUrl rejects the url forms the source table can hold" {
    try testing.expectError(error.BadUrl, parseUrl("ftp://example.com/list"));
    try testing.expectError(error.BadUrl, parseUrl("file:///etc/hosts"));
    try testing.expectError(error.BadUrl, parseUrl("x"));
    try testing.expectError(error.BadUrl, parseUrl(""));
    try testing.expectError(error.BadUrl, parseUrl("https://"));
}

test "mapError maps local errors before phase errors" {
    try testing.expectEqual(error.OutOfMemory, mapError(error.OutOfMemory, .connect));
    try testing.expectEqual(error.Canceled, mapError(error.Canceled, .receive));
    try testing.expectEqual(error.Unexpected, mapError(error.Unexpected, .send));
    try testing.expectEqual(error.SystemResources, mapError(error.SystemResources, .connect));
    try testing.expectEqual(
        error.SystemResources,
        mapError(error.ProcessFdQuotaExceeded, .connect),
    );
    try testing.expectEqual(
        error.SystemResources,
        mapError(error.SystemFdQuotaExceeded, .connect),
    );
}

test "mapError maps the reachable tls errors regardless of phase" {
    try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect));
    try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .receive));
    try testing.expectEqual(error.TlsFailed, mapError(error.CertificateBundleLoadFailure, .connect));
}

test "mapError maps a redirect overrun to HttpStatus" {
    try testing.expectEqual(error.HttpStatus, mapError(error.TooManyHttpRedirects, .receive));
}

test "mapError maps a connect timeout to Timeout" {
    try testing.expectEqual(error.Timeout, mapError(error.Timeout, .connect));
}

test "mapError maps remaining errors by phase" {
    try testing.expectEqual(error.ConnectFailed, mapError(error.ConnectionRefused, .connect));
    try testing.expectEqual(error.SendFailed, mapError(error.WriteFailed, .send));
    try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive));
    try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive));
}

test "caps are the values the memory budget was sized against" {
    try testing.expectEqual(@as(usize, 64 * 1024 * 1024), max_body_bytes);
    try testing.expectEqual(@as(usize, 8192), redirect_buffer_len);
}
