//! Web authentication (PLAN §3.11, §12.1, §19; milestone-8 rulings 17, 18, 29).
//!
//! Two independent pieces:
//!
//!   * `verifyPassword` checks an operator's password against the argon2id PHC
//!     string in `web.password_hash`. The PHC string carries its own parameters,
//!     so this file names none: a hash written by an older binary with different
//!     parameters still verifies.
//!   * `Sessions` is the in-memory session table. A successful login mints a
//!     token, the browser carries it in a cookie, and every later request is
//!     authenticated by that cookie alone. Nothing is persisted: a restart logs
//!     every operator out, which is the behaviour a household admin UI wants and
//!     costs no schema.
//!
//! The table is a fixed array of `max_sessions` slots, so no request path
//! allocates. A 33rd login evicts the least recently used session rather than
//! failing: an operator who can prove the password must always get in, and 32
//! concurrent browsers is already far past household scale.
//!
//! Only the SHA-256 digest of a token is stored. A memory disclosure therefore
//! yields no usable cookie, and lookups compare digests with
//! `std.crypto.timing_safe.eql`, which needs fixed-size arrays (slices are not
//! accepted — `timing_safe.zig:12`).
//!
//! Secrets never reach a log line: no password, hash, token or cookie value is
//! formatted anywhere in this file (ruling 29). The login handler logs the
//! client address and the outcome, nothing else.

const std = @import("std");

const model = @import("../config/model.zig");

const Allocator = std.mem.Allocator;
const Sha256 = std.crypto.hash.sha2.Sha256;
const base64 = std.base64.url_safe_no_pad;

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

/// Raw token length. 256 bits of `io.randomSecure` entropy.
pub const token_bytes = 32;

/// Length of the cookie value: base64 (url-safe, unpadded) of `token_bytes`.
pub const cookie_value_len = base64.Encoder.calcSize(token_bytes);

/// The cookie value as it appears on the wire.
pub const Cookie = [cookie_value_len]u8;

pub const cookie_name = "nxdns_session";

/// `Secure` is deliberately absent: nxdns serves plain HTTP on the LAN and TLS
/// termination, where an operator wants it, belongs to their reverse proxy.
/// Setting `Secure` would make the cookie unusable in the supported deployment.
pub const cookie_attributes = "HttpOnly; SameSite=Lax; Path=/";

/// Longest password `verifyPassword` will hash. argon2id costs 19 MiB and a
/// deliberate delay per call, so an unbounded body must not reach it; a
/// passphrase longer than this is refused as if it were wrong.
pub const max_password_len = 256;

/// Authentication is on exactly when a hash exists (ruling 17). An empty hash
/// is the documented "no password set" state, not a misconfiguration; a null
/// one means the settings table holds no hash row at all, which is the same
/// answer.
pub fn authEnabled(web: model.Web) bool {
    const hash = web.password_hash orelse return false;
    return hash.len != 0;
}

pub const Outcome = enum {
    ok,
    /// Wrong password, or a hash this build cannot verify. Both are the same
    /// answer to the client.
    denied,
    /// Verification could not run (out of memory, unreadable PHC string). The
    /// handler answers 500, never 401: a broken hash must not read as a wrong
    /// password.
    unavailable,
};

/// Verifies `password` against the PHC string in `password_hash`.
///
/// `strVerify` requires both an allocator (argon2.zig:600) and an `Io`
/// (argon2.zig:619). It is slow by construction — the caller runs it on the
/// connection task, which is why the API limiter counts login attempts like any
/// other request.
pub fn verifyPassword(
    io: std.Io,
    gpa: Allocator,
    password_hash: []const u8,
    password: []const u8,
) std.Io.Cancelable!Outcome {
    if (password_hash.len == 0) return .denied;
    if (password.len == 0 or password.len > max_password_len) return .denied;

    std.crypto.pwhash.argon2.strVerify(
        password_hash,
        password,
        .{ .allocator = gpa },
        io,
    ) catch |err| switch (err) {
        error.PasswordVerificationFailed => return .denied,
        error.Canceled => return error.Canceled,
        else => {
            log.warn("verifying the web password failed: {s}", .{@errorName(err)});
            return .unavailable;
        },
    };
    return .ok;
}

/// The password hash the running server authenticates against. `WebState.web`
/// is the boot-time configuration and never changes, but `PUT /api/settings`
/// can replace the password while the process runs, and the revoked credential
/// must stop working before the next restart. The login path and the session
/// gate read this holder, never the boot value.
///
/// A mutex-guarded copy-out rather than an atomic pointer swap: argon2
/// verification holds the hash for tens of milliseconds, so a reader must not
/// borrow the stored slice across a replacement. Copying at most `max_len`
/// bytes under an uncontended mutex is cheap, and it lets `installAndRevoke`
/// free the old allocation immediately instead of deferring reclamation.
///
/// Ownership: the boot value borrows the configuration arena and is never
/// freed here. `installAndRevoke` takes ownership of a gpa allocation and
/// frees the previous hash if this holder owned it; whoever owns the
/// `WebState` calls `deinit`, which frees the last installed one the same way.
pub const LiveHash = struct {
    /// Lock order: this mutex is taken BEFORE `Sessions.mutex`, never after.
    /// Two sites nest them: `confirmSession` holds it across
    /// `Sessions.createWithToken`, and `installAndRevoke` holds it across
    /// `Sessions.clearAll`. No code path may touch this holder while holding
    /// the session table's mutex.
    ///
    /// The single direction is also the revocation argument: a confirm and an
    /// `installAndRevoke` serialize on this mutex, so a confirm either
    /// precedes the transition (the nested `clearAll` wipes the session it
    /// just minted) or follows it (the snapshot's generation is stale and
    /// nothing is minted). No interleaving exists in which a session minted
    /// under the new password is killed by its own transition.
    mutex: std.Io.Mutex = .init,
    hash: []const u8 = "",
    owned: bool = false,
    /// Bumped by every `installAndRevoke`. A login snapshot carries the
    /// generation it copied, and `confirmSession` refuses to mint a session
    /// for a snapshot an install has since replaced.
    generation: u64 = 0,

    /// Every PHC string nxdns produces fits: `config/import.zig` and the
    /// settings handler both hash into a buffer of this size.
    pub const max_len = 256;

    pub fn init(boot_hash: []const u8) LiveHash {
        return .{ .hash = boot_hash };
    }

    /// Whether a password is set right now — ruling 17's gate, live.
    pub fn enabled(self: *LiveHash, io: std.Io) bool {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        return self.hash.len != 0;
    }

    /// What `copy` hands out: the hash to verify against and the generation
    /// it was copied under, for `confirmSession` to check after the slow
    /// verification.
    pub const Snapshot = struct {
        hash: []const u8,
        generation: u64,
    };

    /// Copies the current hash into `buf`. `error.Oversize` means a stored
    /// hash this holder cannot hand out — only a hand-edited database, never
    /// a hash nxdns wrote — and the caller must fail closed as an internal
    /// error, not as a wrong password.
    pub fn copy(self: *LiveHash, io: std.Io, buf: *[max_len]u8) error{Oversize}!Snapshot {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        if (self.hash.len > buf.len) return error.Oversize;
        @memcpy(buf[0..self.hash.len], self.hash);
        return .{ .hash = buf[0..self.hash.len], .generation = self.generation };
    }

    /// Takes ownership of `new_hash`, which must be a `gpa` allocation, frees
    /// the previous hash if this holder owned it, and ends every session in
    /// `sessions` before releasing the mutex. The swap, the generation bump
    /// and the revocation are one mutex-held operation on purpose: were the
    /// mutex released between them, a login verified against the new hash
    /// could confirm in the gap and the trailing `clearAll` would kill that
    /// fresh, legitimate cookie. `sessions` is optional only because a server
    /// can run without a session store; null skips the revocation, nothing
    /// else.
    pub fn installAndRevoke(
        self: *LiveHash,
        io: std.Io,
        gpa: Allocator,
        sessions: ?*Sessions,
        new_hash: []const u8,
    ) void {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        if (self.owned) gpa.free(self.hash);
        self.hash = new_hash;
        self.owned = true;
        self.generation += 1;
        if (sessions) |table| table.clearAll(io);
    }

    /// Mints a session only when no `installAndRevoke` has replaced the hash
    /// since the snapshot at `generation` was taken. Argon2 verification runs
    /// on a copy outside any lock, so a settings PUT can replace the password
    /// while a login is still verifying the old one; minting afterwards would
    /// resurrect the revoked credential. The token bytes and the timestamp
    /// are produced before the mutex is taken: `randomSecure` may stall on
    /// entropy, and a stall inside this lock would block password installs
    /// and every request's `enabled`/`copy` check. Under the ordered locks
    /// only the generation check and the digest insert remain. An
    /// `installAndRevoke` therefore lands either before this call (the
    /// generation differs, null — the login is denied) or after it (its
    /// nested `clearAll` ends the session just minted). Null always means
    /// "the password changed under you", never an error.
    pub fn confirmSession(
        self: *LiveHash,
        io: std.Io,
        sessions: *Sessions,
        generation: u64,
    ) Sessions.CreateError!?Cookie {
        var token: [token_bytes]u8 = undefined;
        try std.Io.randomSecure(io, &token);
        defer std.crypto.secureZero(u8, &token);
        const now_s = nowSeconds(io);

        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        if (self.generation != generation) return null;
        return sessions.createWithToken(io, token, now_s);
    }

    pub fn deinit(self: *LiveHash, gpa: Allocator) void {
        if (self.owned) gpa.free(self.hash);
        self.* = undefined;
    }
};

/// One live session. `last_used` drives the LRU eviction and moves on every
/// successful validation; `expires_at` is `issued_at + ttl`, so a session ends
/// at its TTL however busy it was. `issued_at` is kept so `setTtl` can
/// recompute `expires_at` for live sessions from their origin rather than from
/// the moment of the change.
const Slot = struct {
    used: bool,
    digest: [Sha256.digest_length]u8,
    issued_at: i64,
    expires_at: i64,
    last_used: i64,
};

pub const Sessions = struct {
    /// Concurrent connection tasks share one table, so every field below is
    /// written under this mutex.
    ///
    /// `lockUncancelable` throughout: the critical sections are scans of 32
    /// slots with no I/O in them, and the callers are request handlers whose
    /// cancellation should land on the socket, not inside the session table.
    ///
    /// Lock order: when held together with `LiveHash.mutex`, that mutex comes
    /// first (`LiveHash.confirmSession` and `LiveHash.installAndRevoke` are
    /// the sites that nest them). No code path may take `LiveHash.mutex`
    /// while holding this one.
    mutex: std.Io.Mutex,
    slots: [max_sessions]Slot,
    ttl_seconds: i64,

    pub const max_sessions = 32;

    pub const CreateError = std.Io.RandomSecureError;

    /// `ttl_hours` is `web.session_ttl_hours`; `validate.zig` rejects zero.
    pub fn init(ttl_hours: u16) Sessions {
        std.debug.assert(ttl_hours > 0);
        return .{
            .mutex = .init,
            .slots = @splat(.{
                .used = false,
                .digest = @splat(0),
                .issued_at = 0,
                .expires_at = 0,
                .last_used = 0,
            }),
            .ttl_seconds = @as(i64, ttl_hours) * 3600,
        };
    }

    /// Mints a session from a caller-supplied token and clock and returns the
    /// cookie value to send back; the table keeps only the token's digest.
    /// `LiveHash.confirmSession` supplies real entropy gathered before any
    /// lock; a test supplies fixed bytes and is deterministic without seeding
    /// any global randomness.
    pub fn createWithToken(
        self: *Sessions,
        io: std.Io,
        token: [token_bytes]u8,
        now_s: i64,
    ) Cookie {
        var digest: [Sha256.digest_length]u8 = undefined;
        Sha256.hash(&token, &digest, .{});

        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        self.sweepLocked(now_s);
        const slot = self.freeSlotLocked() orelse self.lruSlotLocked();
        slot.* = .{
            .used = true,
            .digest = digest,
            .issued_at = now_s,
            .expires_at = now_s + self.ttl_seconds,
            .last_used = now_s,
        };

        var cookie: Cookie = undefined;
        const encoded = base64.Encoder.encode(&cookie, &token);
        std.debug.assert(encoded.len == cookie.len);
        return cookie;
    }

    /// True when `cookie_value` names a live session, which it then touches.
    /// Every malformed, unknown or expired value is the same `false`.
    pub fn validate(self: *Sessions, io: std.Io, cookie_value: []const u8) bool {
        return self.validateAt(io, cookie_value, nowSeconds(io));
    }

    pub fn validateAt(self: *Sessions, io: std.Io, cookie_value: []const u8, now_s: i64) bool {
        const digest = digestOf(cookie_value) orelse return false;

        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        self.sweepLocked(now_s);
        const slot = self.findLocked(digest) orelse return false;
        slot.last_used = now_s;
        return true;
    }

    /// Drops the named session. True when one was dropped, which is what lets
    /// the logout handler answer the same way for a stale cookie as for a live
    /// one if it chooses to.
    pub fn logout(self: *Sessions, io: std.Io, cookie_value: []const u8) bool {
        const digest = digestOf(cookie_value) orelse return false;

        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        const slot = self.findLocked(digest) orelse return false;
        slot.used = false;
        slot.digest = @splat(0);
        return true;
    }

    /// Ends every session. `PUT /api/settings` calls this when it changes
    /// `web.password_hash`: a password change must not leave the sessions it was
    /// meant to revoke alive.
    pub fn clearAll(self: *Sessions, io: std.Io) void {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        for (&self.slots) |*slot| {
            slot.used = false;
            slot.digest = @splat(0);
        }
    }

    /// Sessions that have not expired by `now_s`. Expired slots are reclaimed on
    /// the way, so this is also the sweep the accessors perform.
    pub fn count(self: *Sessions, io: std.Io, now_s: i64) u32 {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        self.sweepLocked(now_s);
        var live: u32 = 0;
        for (self.slots) |slot| {
            if (slot.used) live += 1;
        }
        return live;
    }

    /// Installs a new TTL and re-dates every live session from its
    /// `issued_at`, so the change is retroactive rather than sliding.
    ///
    /// The semantics are SERVER-SIDE only. Shortening takes effect for every
    /// session at once, including ones that are already over the new age — the
    /// next sweep drops them. Lengthening extends how long the table honours a
    /// session, but the browser still holds the cookie's ORIGINAL `Max-Age`:
    /// the cookie is never refreshed, so a session does not become sliding and
    /// a lengthened session ends when the browser drops the cookie.
    pub fn setTtl(self: *Sessions, io: std.Io, ttl_hours: u16) void {
        std.debug.assert(ttl_hours > 0);
        const ttl_seconds = @as(i64, ttl_hours) * 3600;

        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        self.ttl_seconds = ttl_seconds;
        for (&self.slots) |*slot| {
            if (!slot.used) continue;
            slot.expires_at = slot.issued_at + ttl_seconds;
        }
    }

    /// The live TTL in seconds, for the login cookie's `Max-Age`.
    pub fn ttlSeconds(self: *Sessions, io: std.Io) i64 {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        return self.ttl_seconds;
    }

    fn findLocked(self: *Sessions, digest: [Sha256.digest_length]u8) ?*Slot {
        var found: ?*Slot = null;
        for (&self.slots) |*slot| {
            if (!slot.used) continue;
            // Every live slot is compared, so the work done does not depend on
            // which one matches.
            if (std.crypto.timing_safe.eql([Sha256.digest_length]u8, slot.digest, digest)) {
                found = slot;
            }
        }
        return found;
    }

    fn sweepLocked(self: *Sessions, now_s: i64) void {
        for (&self.slots) |*slot| {
            if (slot.used and now_s >= slot.expires_at) {
                slot.used = false;
                slot.digest = @splat(0);
            }
        }
    }

    fn freeSlotLocked(self: *Sessions) ?*Slot {
        for (&self.slots) |*slot| {
            if (!slot.used) return slot;
        }
        return null;
    }

    /// The table is full, so the oldest session makes room. Ties go to the
    /// lowest index; with 32 slots the choice among equally old sessions carries
    /// no meaning.
    fn lruSlotLocked(self: *Sessions) *Slot {
        var oldest: *Slot = &self.slots[0];
        for (self.slots[1..]) |*slot| {
            if (slot.last_used < oldest.last_used) oldest = slot;
        }
        return oldest;
    }
};

/// Decodes a cookie value back to the token and hashes it. Null when the value
/// is not exactly one unpadded base64 encoding of `token_bytes` bytes.
fn digestOf(cookie_value: []const u8) ?[Sha256.digest_length]u8 {
    if (cookie_value.len != cookie_value_len) return null;
    const decoded_len = base64.Decoder.calcSizeForSlice(cookie_value) catch return null;
    if (decoded_len != token_bytes) return null;

    var token: [token_bytes]u8 = undefined;
    base64.Decoder.decode(&token, cookie_value) catch return null;
    defer std.crypto.secureZero(u8, &token);

    var digest: [Sha256.digest_length]u8 = undefined;
    Sha256.hash(&token, &digest, .{});
    return digest;
}

/// Session lifetimes are wall-clock hours, so they follow the operator's clock
/// rather than the machine's uptime.
fn nowSeconds(io: std.Io) i64 {
    return std.Io.Clock.real.now(io).toSeconds();
}

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

const testing = std.testing;

fn tokenOf(n: u8) [token_bytes]u8 {
    return @splat(n);
}

test "authEnabled follows the presence of a hash" {
    try testing.expect(!authEnabled(.{}));
    try testing.expect(!authEnabled(.{ .password_hash = null }));
    try testing.expect(!authEnabled(.{ .password_hash = "" }));
    try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }));
}

test "a session created with a known token validates through its cookie value" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var sessions: Sessions = .init(24);
    const cookie = sessions.createWithToken(io, tokenOf(7), 1_700_000_000);

    try testing.expectEqual(@as(usize, 43), cookie.len);
    for (cookie) |c| {
        try testing.expect(std.ascii.isAlphanumeric(c) or c == '-' or c == '_');
    }

    try testing.expect(sessions.validateAt(io, &cookie, 1_700_000_001));
    try testing.expectEqual(@as(u32, 1), sessions.count(io, 1_700_000_001));

    // The cookie value carries the token, so an independent encoding of the
    // same token is the same session.
    var expected: Cookie = undefined;
    _ = base64.Encoder.encode(&expected, &tokenOf(7));
    try testing.expectEqualStrings(&expected, &cookie);
}

test "confirmSession with real entropy yields a validating cookie" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var live: LiveHash = .init("boot-hash");
    defer live.deinit(testing.allocator);
    var sessions: Sessions = .init(24);

    const cookie = (try live.confirmSession(io, &sessions, 0)).?;
    try testing.expect(sessions.validate(io, &cookie));

    const second = (try live.confirmSession(io, &sessions, 0)).?;
    try testing.expect(!std.mem.eql(u8, &cookie, &second));
    try testing.expect(sessions.validate(io, &cookie));
    try testing.expect(sessions.validate(io, &second));
}

test "a wrong token of the right length is rejected" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var sessions: Sessions = .init(24);
    const cookie = sessions.createWithToken(io, tokenOf(1), 1_000);

    var other: Cookie = undefined;
    _ = base64.Encoder.encode(&other, &tokenOf(2));
    try testing.expectEqual(cookie.len, other.len);
    try testing.expect(!sessions.validateAt(io, &other, 1_000));

    // One flipped character of a live cookie is not that session either.
    var tampered = cookie;
    tampered[0] = if (tampered[0] == 'A') 'B' else 'A';
    try testing.expect(!sessions.validateAt(io, &tampered, 1_000));

    try testing.expect(sessions.validateAt(io, &cookie, 1_000));
}

test "malformed cookie values are rejected without touching the table" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var sessions: Sessions = .init(24);
    _ = sessions.createWithToken(io, tokenOf(3), 1_000);

    try testing.expect(!sessions.validateAt(io, "", 1_000));
    try testing.expect(!sessions.validateAt(io, "short", 1_000));
    // 43 characters, one of them outside the url-safe alphabet.
    try testing.expect(!sessions.validateAt(io, "*" ** 43, 1_000));
    // The padded encoding is the right token but the wrong length.
    var padded: [44]u8 = undefined;
    _ = std.base64.url_safe.Encoder.encode(&padded, &tokenOf(3));
    try testing.expect(!sessions.validateAt(io, &padded, 1_000));

    try testing.expectEqual(@as(u32, 1), sessions.count(io, 1_000));
}

test "a session expires at its ttl and frees its slot" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var sessions: Sessions = .init(2);
    const cookie = sessions.createWithToken(io, tokenOf(9), 0);

    try testing.expect(sessions.validateAt(io, &cookie, 7199));
    // Use does not extend the lifetime.
    try testing.expect(!sessions.validateAt(io, &cookie, 7200));
    try testing.expectEqual(@as(u32, 0), sessions.count(io, 7200));
}

test "shortening the ttl expires an over-age live session at once" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    // Two hours, so a session minted at 0 is live at 3_600.
    var sessions: Sessions = .init(2);
    const cookie = sessions.createWithToken(io, tokenOf(9), 0);
    try testing.expect(sessions.validateAt(io, &cookie, 3_600));

    // One hour re-dates it from `issued_at`, which puts its expiry at 3_600.
    sessions.setTtl(io, 1);
    try testing.expect(!sessions.validateAt(io, &cookie, 3_600));
    try testing.expectEqual(@as(u32, 0), sessions.count(io, 3_600));
}

test "lengthening the ttl extends a live session server-side" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var sessions: Sessions = .init(1);
    const cookie = sessions.createWithToken(io, tokenOf(9), 0);
    try testing.expect(!sessions.validateAt(io, &cookie, 3_600));

    // Re-dating from `issued_at` rather than from now: three hours after
    // minting, not three hours from here.
    var extended: Sessions = .init(1);
    const live = extended.createWithToken(io, tokenOf(9), 0);
    extended.setTtl(io, 3);
    try testing.expect(extended.validateAt(io, &live, 3_600));
    try testing.expect(extended.validateAt(io, &live, 10_799));
    try testing.expect(!extended.validateAt(io, &live, 10_800));
}

test "a session minted after setTtl uses the new ttl" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var sessions: Sessions = .init(24);
    sessions.setTtl(io, 2);
    try testing.expectEqual(@as(i64, 7_200), sessions.ttlSeconds(io));

    const cookie = sessions.createWithToken(io, tokenOf(4), 100);
    try testing.expect(sessions.validateAt(io, &cookie, 7_299));
    try testing.expect(!sessions.validateAt(io, &cookie, 7_300));
}

test "the thirty-third session evicts the least recently used one" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var sessions: Sessions = .init(24);
    var cookies: [Sessions.max_sessions]Cookie = undefined;
    for (&cookies, 0..) |*cookie, i| {
        cookie.* = sessions.createWithToken(io, tokenOf(@intCast(i)), 1_000 + @as(i64, @intCast(i)));
    }
    try testing.expectEqual(@as(u32, Sessions.max_sessions), sessions.count(io, 2_000));

    // Touching the oldest session makes a later one the eviction candidate.
    try testing.expect(sessions.validateAt(io, &cookies[0], 2_000));

    const newcomer = sessions.createWithToken(io, tokenOf(200), 2_001);
    try testing.expectEqual(@as(u32, Sessions.max_sessions), sessions.count(io, 2_001));
    try testing.expect(sessions.validateAt(io, &newcomer, 2_001));
    try testing.expect(sessions.validateAt(io, &cookies[0], 2_001));
    try testing.expect(!sessions.validateAt(io, &cookies[1], 2_001));
    for (cookies[2..]) |cookie| {
        try testing.expect(sessions.validateAt(io, &cookie, 2_001));
    }
}

test "an expired slot is reused before any live session is evicted" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var sessions: Sessions = .init(1);
    var cookies: [Sessions.max_sessions]Cookie = undefined;
    for (&cookies, 0..) |*cookie, i| {
        cookie.* = sessions.createWithToken(io, tokenOf(@intCast(i)), @intCast(i));
    }
    // The first session expires an hour after it was made; the rest are younger.
    const newcomer = sessions.createWithToken(io, tokenOf(100), 3_600);
    try testing.expect(!sessions.validateAt(io, &cookies[0], 3_600));
    try testing.expect(sessions.validateAt(io, &cookies[1], 3_600));
    try testing.expect(sessions.validateAt(io, &newcomer, 3_600));
}

test "logout drops one session and leaves the others" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var sessions: Sessions = .init(24);
    const first = sessions.createWithToken(io, tokenOf(1), 1_000);
    const second = sessions.createWithToken(io, tokenOf(2), 1_000);

    try testing.expect(sessions.logout(io, &first));
    try testing.expect(!sessions.validateAt(io, &first, 1_000));
    try testing.expect(sessions.validateAt(io, &second, 1_000));

    // Logging the same cookie out twice is not an error, just no longer a hit.
    try testing.expect(!sessions.logout(io, &first));
    try testing.expect(!sessions.logout(io, "nonsense"));
    try testing.expectEqual(@as(u32, 1), sessions.count(io, 1_000));

    // The freed slot is available again.
    const third = sessions.createWithToken(io, tokenOf(3), 1_001);
    try testing.expect(sessions.validateAt(io, &third, 1_001));
    try testing.expectEqual(@as(u32, 2), sessions.count(io, 1_001));
}

test "clearAll ends every session" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var sessions: Sessions = .init(24);
    var cookies: [4]Cookie = undefined;
    for (&cookies, 0..) |*cookie, i| {
        cookie.* = sessions.createWithToken(io, tokenOf(@intCast(i)), 1_000);
    }

    sessions.clearAll(io);
    try testing.expectEqual(@as(u32, 0), sessions.count(io, 1_000));
    for (cookies) |cookie| {
        try testing.expect(!sessions.validateAt(io, &cookie, 1_000));
    }

    // The store keeps working after a clear.
    const fresh = sessions.createWithToken(io, tokenOf(9), 1_001);
    try testing.expect(sessions.validateAt(io, &fresh, 1_001));
}

test "the live hash starts as the boot value and follows installAndRevoke" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var live: LiveHash = .init("boot-hash");
    defer live.deinit(gpa);
    try testing.expect(live.enabled(io));

    var buf: [LiveHash.max_len]u8 = undefined;
    const boot = try live.copy(io, &buf);
    try testing.expectEqualStrings("boot-hash", boot.hash);
    try testing.expectEqual(@as(u64, 0), boot.generation);

    // The boot value is borrowed; the first install must not free it. With no
    // session store the revocation half is skipped.
    live.installAndRevoke(io, gpa, null, try gpa.dupe(u8, "first-replacement"));
    const first = try live.copy(io, &buf);
    try testing.expectEqualStrings("first-replacement", first.hash);
    try testing.expectEqual(@as(u64, 1), first.generation);

    // The second install frees the first — the leak detector is the assertion.
    live.installAndRevoke(io, gpa, null, try gpa.dupe(u8, "second-replacement"));
    const second = try live.copy(io, &buf);
    try testing.expectEqualStrings("second-replacement", second.hash);
    try testing.expectEqual(@as(u64, 2), second.generation);
}

test "an empty live hash reads as authentication off" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var live: LiveHash = .{};
    defer live.deinit(testing.allocator);
    try testing.expect(!live.enabled(io));

    var buf: [LiveHash.max_len]u8 = undefined;
    try testing.expectEqual(@as(usize, 0), (try live.copy(io, &buf)).hash.len);
}

test "confirmSession mints for the copied generation and refuses a stale one" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var live: LiveHash = .init("boot-hash");
    defer live.deinit(gpa);
    var sessions: Sessions = .init(24);

    var buf: [LiveHash.max_len]u8 = undefined;
    const snapshot = try live.copy(io, &buf);

    const cookie = (try live.confirmSession(io, &sessions, snapshot.generation)).?;
    try testing.expect(sessions.validate(io, &cookie));

    // An install between copy and confirm makes the snapshot stale: no
    // session, and the ones the install revoked stay revoked.
    live.installAndRevoke(io, gpa, &sessions, try gpa.dupe(u8, "new-hash"));
    try testing.expectEqual(@as(?Cookie, null), try live.confirmSession(io, &sessions, snapshot.generation));
    try testing.expectEqual(@as(u32, 0), sessions.count(io, 0));

    // A snapshot of the new hash confirms again.
    const fresh = try live.copy(io, &buf);
    try testing.expect(try live.confirmSession(io, &sessions, fresh.generation) != null);
}

test "a session confirmed before installAndRevoke does not survive it" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var live: LiveHash = .init("boot-hash");
    defer live.deinit(gpa);
    var sessions: Sessions = .init(24);

    var buf: [LiveHash.max_len]u8 = undefined;
    const snapshot = try live.copy(io, &buf);
    const cookie = (try live.confirmSession(io, &sessions, snapshot.generation)).?;
    try testing.expect(sessions.validate(io, &cookie));

    // The transition lands after the confirm: the nested clearAll ends the
    // session just minted, so the ordering leaves no cookie alive either way.
    live.installAndRevoke(io, gpa, &sessions, try gpa.dupe(u8, "new-hash"));
    try testing.expect(!sessions.validate(io, &cookie));
    try testing.expectEqual(@as(u32, 0), sessions.count(io, 0));
}

test "a boot hash too long to copy is reported, not truncated" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var live: LiveHash = .init("x" ** (LiveHash.max_len + 1));
    defer live.deinit(testing.allocator);
    try testing.expect(live.enabled(io));

    var buf: [LiveHash.max_len]u8 = undefined;
    try testing.expectError(error.Oversize, live.copy(io, &buf));
}

test "verifyPassword accepts the password behind an import-path hash" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    // The same parameters `config/import.zig` hashes with (owasp_2id, PHC).
    var buf: [256]u8 = undefined;
    const hash = try std.crypto.pwhash.argon2.strHash("correct horse battery staple", .{
        .allocator = gpa,
        .params = .owasp_2id,
        .mode = .argon2id,
        .encoding = .phc,
    }, &buf, io);
    try testing.expect(std.mem.startsWith(u8, hash, "$argon2id$"));

    try testing.expectEqual(Outcome.ok, try verifyPassword(io, gpa, hash, "correct horse battery staple"));
    try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, hash, "correct horse battery stapl"));
    try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, hash, ""));
    try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, hash, "x" ** (max_password_len + 1)));
}

test "verifyPassword denies with no hash and reports an unreadable one" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, "", "anything"));
    try testing.expectEqual(Outcome.unavailable, try verifyPassword(io, gpa, "not a phc string", "anything"));
}
