//! The five period endpoints: `GET /api/stats` and `/api/stats/timeseries`
//! (ruling 13), and `/api/stats/types`, `/api/stats/routes` and
//! `/api/stats/clients` (milestone 30).
//!
//! One period grammar, four widths, and one window shared by all five: a
//! request for the same period gets the same `since`/`until` from every
//! endpoint, so the totals describe exactly the span the charts draw rather
//! than a neighbouring one.
//!
//! That is window coherence, not identical counts. Each endpoint is its own
//! request against its own snapshot, so queries logged between two of them move
//! one panel and not the other. Only a box with nothing writing to it — a test
//! — can expect the breakdowns to sum to the totals exactly.
//!
//! Buckets are aligned to the UTC grid, not to the moment of the request. Every
//! width divides a day, so flooring the current time to a multiple of the width
//! puts each bucket on the same boundary a human reads off a clock, and two
//! requests a second apart return the same bucket starts. The last bucket is
//! the one in progress; it fills as the period runs.
//!
//! The aggregates run on the web task's own query-log connection (m7 ruling 21),
//! which every connection task shares. SQLite's serialized mode makes one call
//! safe; it does not make a transaction safe, so `WebState.querylog_lock` covers
//! the whole read and a second BEGIN can never land inside the first. Each
//! response takes one deferred read transaction, so its aggregate and the
//! `coverage` beside it describe one database state: retention cannot prune
//! between them and hand a client pre-prune rows tagged with a post-prune
//! watermark. Deferred, not `db.Tx`'s BEGIN IMMEDIATE, which would stall the
//! logger and retention behind an HTTP response.
//!
//! The lock is released before the response is written: the body is already
//! built in the request arena, and holding a database lock across a socket
//! write would let one slow client serialize every other reader.

const std = @import("std");

const coverage = @import("../coverage.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
const server = @import("../server.zig");

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

/// The four periods ruling 13 defines. The tag names are the wire spellings.
pub const Period = enum {
    @"1h",
    @"24h",
    @"7d",
    @"30d",

    pub fn parse(text: []const u8) ?Period {
        return std.meta.stringToEnum(Period, text);
    }

    /// Ruling 13: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h.
    pub fn bucketSeconds(self: Period) u32 {
        return switch (self) {
            .@"1h" => 60,
            .@"24h" => 30 * 60,
            .@"7d" => 60 * 60,
            .@"30d" => 6 * 60 * 60,
        };
    }

    pub fn bucketCount(self: Period) u32 {
        return switch (self) {
            .@"1h" => 60,
            .@"24h" => 48,
            .@"7d" => 168,
            .@"30d" => 120,
        };
    }

    pub fn label(self: Period) []const u8 {
        return @tagName(self);
    }
};

pub const default_period: Period = .@"24h";

/// The widest period's bucket count, so one stack array serves every request.
pub const max_buckets = 168;

comptime {
    for (std.enums.values(Period)) |period| {
        std.debug.assert(period.bucketCount() <= max_buckets);
        // The UTC alignment argument holds only while every width divides a day.
        std.debug.assert(86_400 % period.bucketSeconds() == 0);
    }
}

pub const Window = struct {
    /// Inclusive, on the bucket grid.
    since: i64,
    /// Exclusive: the end of the bucket that `now` falls in.
    until: i64,
    bucket_seconds: u32,
    bucket_count: u32,
};

pub fn window(period: Period, now_unix: i64) Window {
    const width: i64 = period.bucketSeconds();
    const count: i64 = period.bucketCount();
    const until = @divFloor(now_unix, width) * width + width;
    return .{
        .since = until - width * count,
        .until = until,
        .bucket_seconds = period.bucketSeconds(),
        .bucket_count = period.bucketCount(),
    };
}

pub const TotalsBody = struct {
    period: []const u8,
    since: i64,
    until: i64,
    queries: u64,
    blocked: u64,
    clients: u64,
    avg_response_time_us: ?i64,
    /// Judged against `since`, which is the window this body reports on — so a
    /// dashboard can say "history starts here" instead of charting a pruned
    /// stretch as a quiet one.
    coverage: coverage.Coverage,
};

pub const TimeseriesBody = struct {
    period: []const u8,
    since: i64,
    until: i64,
    bucket_seconds: u32,
    buckets: []const queries_repo.Bucket,
    coverage: coverage.Coverage,
};

pub const TypesBody = struct {
    period: []const u8,
    since: i64,
    until: i64,
    types: []const queries_repo.TypeCount,
    coverage: coverage.Coverage,
};

pub const RoutesBody = struct {
    period: []const u8,
    since: i64,
    until: i64,
    routes: []const queries_repo.RouteCount,
    coverage: coverage.Coverage,
};

pub const ClientsBody = struct {
    period: []const u8,
    since: i64,
    until: i64,
    bucket_seconds: u32,
    clients: []const queries_repo.ClientSeries,
    other: []const u64,
    coverage: coverage.Coverage,
};

/// Everything one response reads from the query log, so the caller can end the
/// transaction and drop the lock before it serializes anything.
fn Read(comptime T: type) type {
    return struct {
        data: T,
        coverage: coverage.Coverage,
    };
}

const ReadScope = server.QuerylogRead;

fn readTotals(
    state: *server.WebState,
    io: std.Io,
    database: *db.Db,
    span: Window,
) db.Error!Read(queries_repo.StatsTotals) {
    var scope = try ReadScope.open(state, io, database);
    errdefer scope.abort();
    const read: Read(queries_repo.StatsTotals) = .{
        .data = try queries_repo.statsTotals(database, span.since, span.until),
        .coverage = try coverage.read(database, span.since),
    };
    try scope.commit();
    return read;
}

fn readTimeseries(
    state: *server.WebState,
    io: std.Io,
    database: *db.Db,
    span: Window,
    out: []queries_repo.Bucket,
) db.Error!Read(usize) {
    var scope = try ReadScope.open(state, io, database);
    errdefer scope.abort();
    const read: Read(usize) = .{
        .data = try queries_repo.timeseries(database, span.since, span.bucket_seconds, out),
        .coverage = try coverage.read(database, span.since),
    };
    try scope.commit();
    return read;
}

fn readTypes(
    state: *server.WebState,
    io: std.Io,
    database: *db.Db,
    arena: std.mem.Allocator,
    span: Window,
) db.Error!Read([]const queries_repo.TypeCount) {
    var scope = try ReadScope.open(state, io, database);
    errdefer scope.abort();
    const list = try queries_repo.statsTypes(database, arena, span.since, span.until);
    const read: Read([]const queries_repo.TypeCount) = .{
        .data = list.items,
        .coverage = try coverage.read(database, span.since),
    };
    try scope.commit();
    return read;
}

fn readRoutes(
    state: *server.WebState,
    io: std.Io,
    database: *db.Db,
    arena: std.mem.Allocator,
    span: Window,
) db.Error!Read([]const queries_repo.RouteCount) {
    var scope = try ReadScope.open(state, io, database);
    errdefer scope.abort();
    const list = try queries_repo.statsRoutes(database, arena, span.since, span.until);
    const read: Read([]const queries_repo.RouteCount) = .{
        .data = list.items,
        .coverage = try coverage.read(database, span.since),
    };
    try scope.commit();
    return read;
}

fn readClients(
    state: *server.WebState,
    io: std.Io,
    database: *db.Db,
    arena: std.mem.Allocator,
    span: Window,
) db.Error!Read(queries_repo.ClientsBreakdown) {
    var scope = try ReadScope.open(state, io, database);
    errdefer scope.abort();
    const read: Read(queries_repo.ClientsBreakdown) = .{
        .data = try queries_repo.statsClients(
            database,
            arena,
            span.since,
            span.bucket_seconds,
            span.bucket_count,
        ),
        .coverage = try coverage.read(database, span.since),
    };
    try scope.commit();
    return read;
}

pub fn totals(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    const period = periodParam(request.query) catch return badPeriod(request);
    const database = state.querylog_db orelse return unavailable(request);
    const span = window(period, std.Io.Clock.real.now(io).toSeconds());

    const read = readTotals(state, io, database, span) catch |err| {
        return internal(request, "stats totals", err);
    };

    return http_util.respondJson(request, .ok, TotalsBody{
        .period = period.label(),
        .since = span.since,
        .until = span.until,
        .queries = read.data.queries,
        .blocked = read.data.blocked,
        .clients = read.data.distinct_clients,
        .avg_response_time_us = read.data.avg_response_time_us,
        .coverage = read.coverage,
    }, &.{});
}

pub fn timeseries(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    const period = periodParam(request.query) catch return badPeriod(request);
    const database = state.querylog_db orelse return unavailable(request);
    const span = window(period, std.Io.Clock.real.now(io).toSeconds());

    var buckets: [max_buckets]queries_repo.Bucket = undefined;
    const out = buckets[0..span.bucket_count];
    const read = readTimeseries(state, io, database, span, out) catch |err| {
        return internal(request, "stats timeseries", err);
    };

    return http_util.respondJson(request, .ok, TimeseriesBody{
        .period = period.label(),
        .since = span.since,
        .until = span.until,
        .bucket_seconds = span.bucket_seconds,
        .buckets = out[0..read.data],
        .coverage = read.coverage,
    }, &.{});
}

pub fn types(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    const period = periodParam(request.query) catch return badPeriod(request);
    const database = state.querylog_db orelse return unavailable(request);
    const span = window(period, std.Io.Clock.real.now(io).toSeconds());

    const read = readTypes(state, io, database, request.arena, span) catch |err| {
        return internal(request, "stats types", err);
    };

    return http_util.respondJson(request, .ok, TypesBody{
        .period = period.label(),
        .since = span.since,
        .until = span.until,
        .types = read.data,
        .coverage = read.coverage,
    }, &.{});
}

pub fn routes(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    const period = periodParam(request.query) catch return badPeriod(request);
    const database = state.querylog_db orelse return unavailable(request);
    const span = window(period, std.Io.Clock.real.now(io).toSeconds());

    const read = readRoutes(state, io, database, request.arena, span) catch |err| {
        return internal(request, "stats routes", err);
    };

    return http_util.respondJson(request, .ok, RoutesBody{
        .period = period.label(),
        .since = span.since,
        .until = span.until,
        .routes = read.data,
        .coverage = read.coverage,
    }, &.{});
}

pub fn clients(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    const period = periodParam(request.query) catch return badPeriod(request);
    const database = state.querylog_db orelse return unavailable(request);
    const span = window(period, std.Io.Clock.real.now(io).toSeconds());

    const read = readClients(state, io, database, request.arena, span) catch |err| {
        return internal(request, "stats clients", err);
    };

    return http_util.respondJson(request, .ok, ClientsBody{
        .period = period.label(),
        .since = span.since,
        .until = span.until,
        .bucket_seconds = span.bucket_seconds,
        .clients = read.data.clients,
        .other = read.data.other,
        .coverage = read.coverage,
    }, &.{});
}

pub const PeriodError = error{BadPeriod};

/// An absent `period` is the default; anything else it cannot read is a 400,
/// never a silent fallback — a typo must not return a window nobody asked for.
fn periodParam(query: []const u8) PeriodError!Period {
    var buf: [8]u8 = undefined;
    const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod;
    const text = found orelse return default_period;
    return Period.parse(text) orelse error.BadPeriod;
}

fn badPeriod(request: *http_util.Request) http_util.HandlerError!void {
    return http_util.respondError(request, .bad_request, "period must be one of 1h, 24h, 7d, 30d");
}

fn unavailable(request: *http_util.Request) http_util.HandlerError!void {
    return http_util.respondError(request, .service_unavailable, "query log unavailable");
}

/// The one thing this file logs. A failed aggregate is a fault in the box, not
/// a property of the request, and the client is told nothing beyond "internal
/// error" (ruling 8, PLAN §19).
fn internal(
    request: *http_util.Request,
    what: []const u8,
    err: db.Error,
) http_util.HandlerError!void {
    log.warn("{s} failed: {s}", .{ what, @errorName(err) });
    return http_util.respondError(request, .internal_server_error, "internal error");
}

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

const querylog_schema = @import("../../storage/querylog_schema.zig");
const testing = std.testing;

test "the period grammar accepts exactly the four spellings" {
    try testing.expectEqual(Period.@"1h", Period.parse("1h").?);
    try testing.expectEqual(Period.@"24h", Period.parse("24h").?);
    try testing.expectEqual(Period.@"7d", Period.parse("7d").?);
    try testing.expectEqual(Period.@"30d", Period.parse("30d").?);
    try testing.expectEqual(@as(?Period, null), Period.parse("12h"));
    try testing.expectEqual(@as(?Period, null), Period.parse("1H"));
    try testing.expectEqual(@as(?Period, null), Period.parse(""));
}

test "an absent period defaults and a bad one is rejected" {
    try testing.expectEqual(default_period, try periodParam(""));
    try testing.expectEqual(default_period, try periodParam("limit=5"));
    try testing.expectEqual(Period.@"7d", try periodParam("period=7d"));
    try testing.expectError(error.BadPeriod, periodParam("period=12h"));
    try testing.expectError(error.BadPeriod, periodParam("period=%2"));
    // Longer than any spelling: rejected rather than truncated to "1h".
    try testing.expectError(error.BadPeriod, periodParam("period=1hhhhhhhhhh"));
}

test "each period spans its own bucket width times its count" {
    for (std.enums.values(Period)) |period| {
        const span = window(period, 1_700_000_000);
        const width: i64 = period.bucketSeconds();
        try testing.expectEqual(width * @as(i64, period.bucketCount()), span.until - span.since);
    }
}

test "the window sits on the UTC grid and ends with the bucket in progress" {
    // 2023-11-14T22:13:20Z, which is not on any bucket boundary.
    const now: i64 = 1_700_000_000;
    const span = window(.@"24h", now);

    try testing.expectEqual(@as(i64, 0), @rem(span.since, 1800));
    try testing.expectEqual(@as(i64, 0), @rem(span.until, 1800));
    try testing.expect(span.until > now);
    try testing.expect(span.until - now <= 1800);
    try testing.expectEqual(@as(u32, 48), span.bucket_count);
}

test "two requests inside one bucket see the same window" {
    // A bucket boundary, so the offsets below stay inside one minute.
    const boundary: i64 = 1_700_000_000 - @rem(1_700_000_000, 60);
    const first = window(.@"1h", boundary);
    const second = window(.@"1h", boundary + 59);
    try testing.expectEqual(first.since, second.since);
    try testing.expectEqual(first.until, second.until);

    const next = window(.@"1h", boundary + 60);
    try testing.expectEqual(first.until + 60, next.until);
}

test "a timestamp exactly on a boundary starts a new bucket" {
    const span = window(.@"7d", 1_700_000_000 - 1_700_000_000 % 3600);
    try testing.expectEqual(@as(i64, 0), @rem(span.since, 3600));
    try testing.expectEqual(@as(u32, 168), span.bucket_count);
}

fn openLog() !db.Db {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    errdefer database.close();
    try db.applyPragmas(&database, .{});
    try database.exec(querylog_schema.ddl);
    return database;
}

fn writeRow(writer: *queries_repo.BatchWriter, timestamp: i64, blocked: bool, cached: ?bool) !void {
    const rows = [_]queries_repo.Row{.{
        .timestamp = timestamp,
        .domain = "example.com",
        .client_ip = "192.0.2.10",
        .qtype = 1,
        .qclass = 1,
        .rcode = 0,
        .blocked = blocked,
        .response_time_us = 1000,
        .cache_hit = cached,
        .upstream = null,
        .group_id = 1,
        .group_name = "default",
        .policy_action = if (blocked) .block else .allow,
        .policy_reason = if (blocked) .blocklist_domain else .no_match,
        .matched = null,
        .source_id = null,
        .source_name = null,
        .cname_target = null,
        .safe_search_target = null,
        .route_kind = if (blocked) .blocked else .upstream,
        .forward_zone = null,
    }};
    try writer.writeBatch(&rows);
}

test "the totals and the buckets agree over the same window" {
    var database = try openLog();
    defer database.close();

    const now: i64 = 1_700_000_000;
    const span = window(.@"1h", now);

    var writer = try queries_repo.BatchWriter.init(&database);
    defer writer.deinit();
    // One row in the first bucket, two in the last, one just outside.
    try writeRow(&writer, span.since, false, false);
    try writeRow(&writer, span.until - 1, true, false);
    try writeRow(&writer, span.until - 2, false, true);
    try writeRow(&writer, span.since - 1, false, false);

    const result = try queries_repo.statsTotals(&database, span.since, span.until);
    try testing.expectEqual(@as(u64, 3), result.queries);
    try testing.expectEqual(@as(u64, 1), result.blocked);
    try testing.expectEqual(@as(u64, 1), result.distinct_clients);
    try testing.expectEqual(@as(?i64, 1000), result.avg_response_time_us);

    var buckets: [max_buckets]queries_repo.Bucket = undefined;
    const out = buckets[0..span.bucket_count];
    const written = try queries_repo.timeseries(&database, span.since, span.bucket_seconds, out);
    try testing.expectEqual(@as(usize, 60), written);

    var summed: u64 = 0;
    var blocked: u64 = 0;
    for (out) |bucket| {
        summed += bucket.queries;
        blocked += bucket.blocked;
    }
    try testing.expectEqual(result.queries, summed);
    try testing.expectEqual(result.blocked, blocked);

    try testing.expectEqual(span.since, out[0].ts);
    try testing.expectEqual(@as(u64, 1), out[0].queries);
    try testing.expectEqual(@as(u64, 2), out[59].queries);
    try testing.expectEqual(span.until - span.bucket_seconds, out[59].ts);
}

test "an empty window reports zeros with a null mean" {
    var database = try openLog();
    defer database.close();

    const span = window(.@"30d", 1_700_000_000);
    const result = try queries_repo.statsTotals(&database, span.since, span.until);
    try testing.expectEqual(@as(u64, 0), result.queries);
    try testing.expectEqual(@as(?i64, null), result.avg_response_time_us);

    var buckets: [max_buckets]queries_repo.Bucket = undefined;
    const out = buckets[0..span.bucket_count];
    try testing.expectEqual(@as(usize, 120), try queries_repo.timeseries(
        &database,
        span.since,
        span.bucket_seconds,
        out,
    ));
    for (out) |bucket| try testing.expectEqual(@as(u64, 0), bucket.queries);
}
