//! `zfin doctor` — health check for the file constellation + environment.
//!
//! Answers "is my zfin setup sane?" without making any changes: it
//! resolves and parse-checks every config file, cross-references
//! accounts/symbols/transfers, audits the environment (API keys, cache,
//! hand-maintained data staleness, server reachability), and prints a
//! plain-English capability summary.
//!
//! Read-only: no provider fetches, no cache writes, no portfolio
//! mutation. It deliberately does NOT use `ctx.svc`. The one network
//! call is an optional `GET {ZFIN_SERVER}/help` to confirm the server
//! is a reachable zfin-server and report its version.
//!
//! Exit code: 0 when every check is OK/INFO/WARN; 1 (via
//! `error.DoctorFailed`) only when a FAIL fired — i.e. a file that
//! exists but does not parse. Missing optional files, cross-reference
//! gaps, stale data, an unreachable server, and absent API keys are all
//! non-fatal. Suitable for CI / cron.
//!
//! Architecture: `run` does all the I/O (resolve paths, read files,
//! parse, probe) and feeds the resolved facts into pure builder
//! functions (`fileStatus`, `coverageCheck`, `capabilityChecks`,
//! `countByStatus`) that produce `Check` data. A separate renderer
//! writes the report. The pure builders carry the test coverage.

const std = @import("std");
const zfin = @import("../root.zig");
const srf = @import("srf");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;

const Config = zfin.Config;
const Lot = @import("../models/portfolio.zig").Lot;
const Date = @import("../Date.zig");
const cache = @import("../cache/store.zig");
const classification = @import("../models/classification.zig");
const analysis = @import("../analytics/analysis.zig");
const transaction_log = @import("../models/transaction_log.zig");
const imported_values = @import("../data/imported_values.zig");
const history = @import("../history.zig");
const staleness = @import("../data/staleness.zig");
const http = @import("../net/http.zig");
const keybinds = @import("../tui/keybinds.zig");
const theme = @import("../tui/theme.zig");
const cache_cmd = @import("cache.zig");

pub const ParsedArgs = struct {};

pub const meta: framework.Meta = .{
    .name = "doctor",
    .group = .infra,
    .synopsis = "Health-check the file constellation + environment (read-only)",
    .help =
    \\Usage: zfin doctor
    \\
    \\Inspect the zfin setup and report problems without changing
    \\anything. Four sections:
    \\
    \\  Files         each config file: present? where? parses?
    \\  Cross-checks  accounts/symbols/transfers reference real entries
    \\  Environment   cache size, hand-maintained data staleness,
    \\                ZFIN_SERVER reachability + version (GET /help)
    \\  Capabilities  which API keys are set and what each enables
    \\
    \\Every check is OK / INFO / WARN / FAIL. Exit code is 0 unless a
    \\file that EXISTS fails to parse (FAIL); missing optional files,
    \\cross-reference gaps, stale data, an unreachable server, and
    \\absent API keys are all non-fatal. Suitable for CI / cron.
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{ UnexpectedArg, DoctorFailed },
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    for (cmd_args) |a| {
        cli.stderrPrint(ctx.io, "Error: unexpected argument to 'doctor': ");
        cli.stderrPrint(ctx.io, a);
        cli.stderrPrint(ctx.io, "\n");
        return error.UnexpectedArg;
    }
    return .{};
}

// ── Report data model ─────────────────────────────────────────

const Status = enum {
    ok,
    info,
    warn,
    fail,

    fn intent(self: Status) fmt.StyleIntent {
        return switch (self) {
            .ok => .positive,
            .info => .muted,
            .warn => .warning,
            .fail => .negative,
        };
    }

    fn label(self: Status) []const u8 {
        return switch (self) {
            .ok => "OK  ",
            .info => "INFO",
            .warn => "WARN",
            .fail => "FAIL",
        };
    }
};

/// One diagnostic line: a status, a short subject, and a one-line
/// context string. `detail` may be borrowed or arena-allocated.
const Check = struct {
    status: Status,
    label: []const u8,
    detail: []const u8 = "",
};

const Section = struct {
    title: []const u8,
    checks: []const Check,
};

// ── Pure builders (the tested core) ───────────────────────────

const FileReq = enum { required, optional };

/// Classify a file's health from whether it was found, whether parsing
/// produced an error, and whether the file is required. A present file
/// that fails to parse is the only FAIL; a missing required file is a
/// WARN (a fresh install legitimately has no portfolio yet); a missing
/// optional file is INFO.
fn fileStatus(found: bool, parse_err: ?[]const u8, req: FileReq) Status {
    if (!found) return switch (req) {
        .required => .warn,
        .optional => .info,
    };
    return if (parse_err != null) .fail else .ok;
}

fn containsStr(haystack: []const []const u8, needle: []const u8) bool {
    for (haystack) |h| if (std.mem.eql(u8, h, needle)) return true;
    return false;
}

/// Render up to `cap` items as "a, b, c (+N more)".
fn joinCapped(arena: std.mem.Allocator, items: []const []const u8, cap: usize) ![]const u8 {
    var buf: std.ArrayList(u8) = .empty;
    const shown = @min(items.len, cap);
    for (items[0..shown], 0..) |it, i| {
        if (i > 0) try buf.appendSlice(arena, ", ");
        try buf.appendSlice(arena, it);
    }
    if (items.len > cap) {
        const more = try std.fmt.allocPrint(arena, " (+{d} more)", .{items.len - cap});
        try buf.appendSlice(arena, more);
    }
    return buf.items;
}

/// Cross-reference check: every name in `needed` should appear in
/// `known`. OK when all are present (or `needed` is empty); WARN listing
/// the missing ones otherwise. Operates on plain string slices so it's
/// equally usable for account names, held symbols, and transfer
/// endpoints — and trivially unit-testable.
fn coverageCheck(
    arena: std.mem.Allocator,
    label: []const u8,
    needed: []const []const u8,
    known: []const []const u8,
    missing_prefix: []const u8,
) !Check {
    var missing: std.ArrayList([]const u8) = .empty;
    for (needed) |n| {
        if (!containsStr(known, n) and !containsStr(missing.items, n)) {
            try missing.append(arena, n);
        }
    }
    if (missing.items.len == 0) {
        return .{ .status = .ok, .label = label, .detail = "all referenced entries present" };
    }
    const listed = try joinCapped(arena, missing.items, 6);
    return .{
        .status = .warn,
        .label = label,
        .detail = try std.fmt.allocPrint(arena, "{s}: {s}", .{ missing_prefix, listed }),
    };
}

/// Build the per-key capability checks from a resolved `Config`. Pure
/// over `Config` (no I/O), so every branch is unit-testable by
/// constructing a `Config` literal. Present keys → OK with the
/// capability they unlock; absent keys → INFO with the consequence
/// (never WARN — keyless operation is a valid configuration). Key
/// VALUES are never read, only presence.
fn capabilityChecks(arena: std.mem.Allocator, config: Config) ![]const Check {
    var checks: std.ArrayList(Check) = .empty;
    try checks.append(arena, keyCheck("TIINGO_API_KEY", config.tiingo_key, "daily candles", "Yahoo-only candle fallback; some symbols (esp. mutual funds) may not price"));
    try checks.append(arena, keyCheck("POLYGON_API_KEY", config.polygon_key, "dividend/split history + dividend-reinvested total return", "price-only returns; no dividend/split history"));
    try checks.append(arena, keyCheck("FMP_API_KEY", config.fmp_key, "earnings history and estimates", "no earnings data"));
    try checks.append(arena, keyCheck("TWELVEDATA_API_KEY", config.twelvedata_key, "quote fallback after Yahoo", "no quote fallback if Yahoo fails"));
    try checks.append(arena, keyCheck("ZFIN_USER_EMAIL", config.user_email, "ETF profiles and `enrich`", "ETF profiles and `enrich` unavailable"));
    try checks.append(arena, keyCheck("OPENFIGI_API_KEY", config.openfigi_key, "faster CUSIP lookups (higher rate limit)", "CUSIP lookups work at the lower keyless rate limit"));
    // Always-on, keyless capabilities — informational reassurance.
    try checks.append(arena, .{ .status = .ok, .label = "Quotes (Yahoo)", .detail = "always available, no key required" });
    try checks.append(arena, .{ .status = .ok, .label = "Options (CBOE)", .detail = "always available, no key required" });
    return checks.items;
}

fn keyCheck(name: []const u8, value: ?[]const u8, when_present: []const u8, when_absent: []const u8) Check {
    return if (value != null)
        .{ .status = .ok, .label = name, .detail = when_present }
    else
        .{ .status = .info, .label = name, .detail = when_absent };
}

/// Count checks of a given status across a set of sections.
fn countByStatus(sections: []const Section, status: Status) usize {
    var n: usize = 0;
    for (sections) |s| {
        for (s.checks) |c| {
            if (c.status == status) n += 1;
        }
    }
    return n;
}

/// Extract the version token from a zfin-server `/help` response body.
/// The first line is `zfin-server <version> - <description>`; returns
/// `<version>` (e.g. "f3c1690"), or null if the body isn't a
/// zfin-server help page. Pure — testable without a network call.
fn parseServerVersion(body: []const u8) ?[]const u8 {
    const trimmed = std.mem.trimStart(u8, body, " \t\r\n");
    const prefix = "zfin-server ";
    if (!std.mem.startsWith(u8, trimmed, prefix)) return null;
    const rest = trimmed[prefix.len..];
    const end = std.mem.indexOfAny(u8, rest, " \t\r\n") orelse rest.len;
    if (end == 0) return null;
    return rest[0..end];
}

fn trimTrailingSlash(s: []const u8) []const u8 {
    return if (s.len > 0 and s[s.len - 1] == '/') s[0 .. s.len - 1] else s;
}

// ── I/O glue: parse-checks ────────────────────────────────────

/// Validate that bytes form a parseable SRF stream (valid `#!srfv1`
/// header + iterable records). Used for files whose typed parser is
/// infallible (`projections.srf`), so a malformed file would otherwise
/// be silently swallowed into defaults.
fn validateSrf(allocator: std.mem.Allocator, bytes: []const u8) anyerror!void {
    var reader = std.Io.Reader.fixed(bytes);
    var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidSrf;
    defer it.deinit();
    while (it.next() catch return error.InvalidSrf) |_| {}
}

// Discard-style parse wrappers (arena owns everything; no deinit needed).
fn vMetadata(a: std.mem.Allocator, b: []const u8) anyerror!void {
    _ = try classification.parseClassificationFile(a, b);
}
fn vAccounts(a: std.mem.Allocator, b: []const u8) anyerror!void {
    _ = try analysis.parseAccountsFile(a, b);
}
fn vTransfers(a: std.mem.Allocator, b: []const u8) anyerror!void {
    _ = try transaction_log.parseTransactionLogFile(a, b);
}
fn vImported(a: std.mem.Allocator, b: []const u8) anyerror!void {
    _ = try imported_values.parseImportedValues(a, b);
}

/// Read `path` and run `parseFn` over its bytes, producing a `Check`.
/// Missing/unreadable/parse-error all map through `fileStatus`.
fn checkSrfFile(
    io: std.Io,
    arena: std.mem.Allocator,
    label: []const u8,
    path: []const u8,
    req: FileReq,
    comptime parseFn: fn (std.mem.Allocator, []const u8) anyerror!void,
) Check {
    const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)) catch |err| {
        if (err == error.FileNotFound) {
            return .{ .status = fileStatus(false, null, req), .label = label, .detail = if (req == .required) "not found" else "not present" };
        }
        return .{ .status = .fail, .label = label, .detail = std.fmt.allocPrint(arena, "unreadable: {s}", .{@errorName(err)}) catch "unreadable" };
    };
    if (parseFn(arena, bytes)) |_| {
        return .{ .status = fileStatus(true, null, req), .label = label, .detail = path };
    } else |err| {
        return .{ .status = fileStatus(true, @errorName(err), req), .label = label, .detail = std.fmt.allocPrint(arena, "parse error: {s}", .{@errorName(err)}) catch "parse error" };
    }
}

/// Join a sibling filename onto the anchor portfolio's directory.
fn siblingPath(arena: std.mem.Allocator, anchor: []const u8, name: []const u8) ![]const u8 {
    const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor, std.fs.path.sep)) |idx| idx + 1 else 0;
    return std.fmt.allocPrint(arena, "{s}{s}", .{ anchor[0..dir_end], name });
}

// ── run ───────────────────────────────────────────────────────

pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
    const io = ctx.io;
    const arena = ctx.allocator;
    const out = ctx.out;
    const color = ctx.color;
    const config = ctx.config;

    var sections: std.ArrayList(Section) = .empty;

    // Collected for cross-reference (Section B).
    var all_lots: std.ArrayList(Lot) = .empty;
    var account_map: ?analysis.AccountMap = null;
    var class_map: ?classification.ClassificationMap = null;
    var transfer_log: ?transaction_log.TransactionLog = null;

    // ── Section A: Files ──────────────────────────────────────
    {
        var checks: std.ArrayList(Check) = .empty;
        const source: []const u8 = if (config.zfin_home) |h| h else "cwd";

        // Portfolio file(s) — globbed, union-merged. Parse-check each.
        var anchor: ?[]const u8 = null;
        const pf = config.resolveUserFiles(io, arena, Config.default_portfolio_filename) catch
            Config.ResolvedPaths{ .paths = &.{}, .allocator = arena };
        if (pf.paths.len == 0) {
            try checks.append(arena, .{
                .status = fileStatus(false, null, .required),
                .label = "portfolio*.srf",
                .detail = try std.fmt.allocPrint(arena, "not found (searched {s})", .{source}),
            });
        } else {
            anchor = pf.paths[0].path;
            for (pf.paths) |rp| {
                const c = try checkPortfolioFile(io, arena, rp.path, &all_lots);
                try checks.append(arena, c);
            }
        }

        if (anchor) |a| {
            // Accounts — parsed + kept for cross-reference.
            {
                const r = checkSrfFile(io, arena, "accounts.srf", try siblingPath(arena, a, "accounts.srf"), .optional, vAccounts);
                try checks.append(arena, r);
                if (r.status == .ok) {
                    const path = try siblingPath(arena, a, "accounts.srf");
                    if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
                        account_map = analysis.parseAccountsFile(arena, b) catch null;
                    } else |_| {}
                }
            }
            // Metadata — parsed + kept.
            {
                const r = checkSrfFile(io, arena, "metadata.srf", try siblingPath(arena, a, "metadata.srf"), .optional, vMetadata);
                try checks.append(arena, r);
                if (r.status == .ok) {
                    const path = try siblingPath(arena, a, "metadata.srf");
                    if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
                        class_map = classification.parseClassificationFile(arena, b) catch null;
                    } else |_| {}
                }
            }
            // Transaction log — parsed + kept.
            {
                const r = checkSrfFile(io, arena, "transaction_log.srf", try siblingPath(arena, a, "transaction_log.srf"), .optional, vTransfers);
                try checks.append(arena, r);
                if (r.status == .ok) {
                    const path = try siblingPath(arena, a, "transaction_log.srf");
                    if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
                        transfer_log = transaction_log.parseTransactionLogFile(arena, b) catch null;
                    } else |_| {}
                }
            }
            try checks.append(arena, checkSrfFile(io, arena, "projections.srf", try siblingPath(arena, a, "projections.srf"), .optional, validateSrf));
            // imported_values.srf and the snapshots both live under
            // <portfolio_dir>/history/, NOT directly beside the
            // portfolio file.
            const hist_dir = history.deriveHistoryDir(arena, a) catch null;
            if (hist_dir) |hd| {
                try checks.append(arena, checkSrfFile(io, arena, "history/imported_values.srf", try std.fs.path.join(arena, &.{ hd, "imported_values.srf" }), .optional, vImported));
            } else {
                try checks.append(arena, .{ .status = .info, .label = "history/imported_values.srf", .detail = "could not derive history dir" });
            }
            try checks.append(arena, checkSnapshots(io, arena, hist_dir));
        }

        // keys.srf / theme.srf live under $HOME/.config/zfin.
        try checks.append(arena, checkUserConfigFiles(io, arena, config, .keys));
        try checks.append(arena, checkUserConfigFiles(io, arena, config, .theme));

        try sections.append(arena, .{ .title = "Files", .checks = checks.items });
    }

    // ── Section B: Cross-references ───────────────────────────
    {
        var checks: std.ArrayList(Check) = .empty;

        // Account coverage.
        if (account_map) |am| {
            const lot_accts = try uniqueAccounts(arena, all_lots.items);
            const known = try accountNames(arena, am);
            try checks.append(arena, try coverageCheck(arena, "accounts.srf coverage", lot_accts, known, "accounts in portfolio missing from accounts.srf"));
        } else {
            try checks.append(arena, .{ .status = .info, .label = "accounts.srf coverage", .detail = "skipped (accounts.srf not loaded)" });
        }

        // Metadata (classification) coverage for classifiable holdings.
        if (class_map) |cm| {
            const held = try classifiableSymbols(arena, all_lots.items);
            const classified = try classifiedSymbols(arena, cm);
            try checks.append(arena, try coverageCheck(arena, "metadata.srf coverage", held, classified, "held symbols missing from metadata.srf"));
        } else {
            try checks.append(arena, .{ .status = .info, .label = "metadata.srf coverage", .detail = "skipped (metadata.srf not loaded)" });
        }

        // Transfer-log account references.
        if (transfer_log) |tl| {
            const endpoints = try transferEndpoints(arena, tl);
            const known = try knownAccountNames(arena, account_map, all_lots.items);
            try checks.append(arena, try coverageCheck(arena, "transaction_log.srf references", endpoints, known, "transfer accounts not found in portfolio/accounts.srf"));
        } else {
            try checks.append(arena, .{ .status = .info, .label = "transaction_log.srf references", .detail = "skipped (transaction_log.srf not loaded)" });
        }

        try sections.append(arena, .{ .title = "Cross-checks", .checks = checks.items });
    }

    // ── Section C: Environment ────────────────────────────────
    {
        var checks: std.ArrayList(Check) = .empty;

        // Cache stats.
        var store = cache.Store.init(io, arena, config.cache_dir);
        const ds = store.diskStats();
        if (ds.symbols == 0 and ds.files == 0) {
            try checks.append(arena, .{ .status = .info, .label = "Cache", .detail = try std.fmt.allocPrint(arena, "empty ({s})", .{config.cache_dir}) });
        } else {
            var size_buf: [10]u8 = undefined;
            try checks.append(arena, .{
                .status = .ok,
                .label = "Cache",
                .detail = try std.fmt.allocPrint(arena, "{d} symbols, {d} files, {s} ({s})", .{ ds.symbols, ds.files, cache_cmd.formatSize(&size_buf, ds.bytes), config.cache_dir }),
            });
        }

        // Hand-maintained data staleness.
        for (staleness.entries) |e| {
            switch (staleness.entryStatus(e, ctx.today)) {
                .ok => try checks.append(arena, .{ .status = .ok, .label = e.name, .detail = "current" }),
                .overdue => try checks.append(arena, .{
                    .status = .warn,
                    .label = e.name,
                    .detail = try std.fmt.allocPrint(arena, "overdue for refresh; see {s}", .{e.source_file}),
                }),
            }
        }

        // ZFIN_SERVER: GET /help to confirm it's a reachable
        // zfin-server and report its version. max_retries=0 so a
        // dead host fails fast instead of retry-looping. (No receive
        // timeout exists in the HTTP client, so a connected-but-silent
        // host could still stall — acceptable for an on-demand check.)
        if (config.server_url) |url| {
            try checks.append(arena, serverCheck(io, arena, url));
        } else {
            try checks.append(arena, .{ .status = .info, .label = "ZFIN_SERVER", .detail = "not set (provider fetch only; no server sync)" });
        }

        try sections.append(arena, .{ .title = "Environment", .checks = checks.items });
    }

    // ── Section D: Capabilities ───────────────────────────────
    try sections.append(arena, .{ .title = "Capabilities", .checks = try capabilityChecks(arena, config) });

    // ── Render + exit code ────────────────────────────────────
    try renderReport(out, color, sections.items);
    // Flush explicitly: when we return `error.DoctorFailed` below the
    // dispatcher's post-run flush (main.zig) is skipped, so without
    // this the buffered report would be lost on the FAIL exit path.
    try out.flush();

    if (countByStatus(sections.items, .fail) > 0) return error.DoctorFailed;
}

/// Deserialize a portfolio file, append its lots to `all_lots` for
/// later cross-referencing, and report parse status.
fn checkPortfolioFile(io: std.Io, arena: std.mem.Allocator, path: []const u8, all_lots: *std.ArrayList(Lot)) !Check {
    const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)) catch |err| {
        if (err == error.FileNotFound) return .{ .status = .warn, .label = "portfolio.srf", .detail = "not found" };
        return .{ .status = .fail, .label = "portfolio.srf", .detail = std.fmt.allocPrint(arena, "unreadable: {s}", .{@errorName(err)}) catch "unreadable" };
    };
    const pf = cache.deserializePortfolio(arena, bytes) catch |err| {
        return .{ .status = .fail, .label = path, .detail = std.fmt.allocPrint(arena, "parse error: {s}", .{@errorName(err)}) catch "parse error" };
    };
    try all_lots.appendSlice(arena, pf.lots);
    return .{ .status = .ok, .label = path, .detail = std.fmt.allocPrint(arena, "{d} lots", .{pf.lots.len}) catch "" };
}

/// Enumerate the `history/` dir and parse-check each
/// `*-portfolio.srf` snapshot. A broken snapshot is a FAIL.
fn checkSnapshots(io: std.Io, arena: std.mem.Allocator, hist_dir_opt: ?[]const u8) Check {
    const hist_dir = hist_dir_opt orelse
        return .{ .status = .info, .label = "history/ snapshots", .detail = "could not derive history dir" };
    var dir = std.Io.Dir.cwd().openDir(io, hist_dir, .{ .iterate = true }) catch
        return .{ .status = .info, .label = "history/ snapshots", .detail = "no history/ directory" };
    defer dir.close(io);

    var total: usize = 0;
    var bad: usize = 0;
    var first_bad: ?[]const u8 = null;
    var iter = dir.iterate();
    while (iter.next(io) catch null) |entry| {
        if (entry.kind != .file) continue;
        if (!std.mem.endsWith(u8, entry.name, history.snapshot_suffix)) continue;
        total += 1;
        const path = std.fmt.allocPrint(arena, "{s}/{s}", .{ hist_dir, entry.name }) catch continue;
        const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024)) catch {
            bad += 1;
            if (first_bad == null) first_bad = arena.dupe(u8, entry.name) catch entry.name;
            continue;
        };
        if (history.parseSnapshotBytes(arena, bytes)) |_| {} else |_| {
            bad += 1;
            if (first_bad == null) first_bad = arena.dupe(u8, entry.name) catch entry.name;
        }
    }

    if (total == 0) return .{ .status = .info, .label = "history/ snapshots", .detail = "no snapshots yet" };
    if (bad == 0) return .{ .status = .ok, .label = "history/ snapshots", .detail = std.fmt.allocPrint(arena, "{d} snapshots, all parse", .{total}) catch "" };
    return .{ .status = .fail, .label = "history/ snapshots", .detail = std.fmt.allocPrint(arena, "{d}/{d} failed to parse (e.g. {s})", .{ bad, total, first_bad orelse "?" }) catch "parse failures" };
}

/// GET {url}/help and classify it as a reachable zfin-server (with
/// version), a host that responded but isn't a recognized
/// zfin-server, or an unreachable host. Read-only request; no retries.
fn serverCheck(io: std.Io, arena: std.mem.Allocator, url: []const u8) Check {
    const help_url = std.fmt.allocPrint(arena, "{s}/help", .{trimTrailingSlash(url)}) catch
        return .{ .status = .warn, .label = "ZFIN_SERVER", .detail = "out of memory building probe URL" };
    var client = http.Client.init(io, arena);
    client.max_retries = 0; // fail fast; doctor shouldn't retry-loop on a dead host
    defer client.deinit();
    if (client.get(help_url)) |resp| {
        var r = resp;
        defer r.deinit();
        if (parseServerVersion(r.body)) |ver| {
            return .{ .status = .ok, .label = "ZFIN_SERVER", .detail = std.fmt.allocPrint(arena, "reachable: zfin-server {s} ({s})", .{ ver, url }) catch "reachable" };
        }
        return .{ .status = .warn, .label = "ZFIN_SERVER", .detail = std.fmt.allocPrint(arena, "responded, but not a zfin-server /help page ({s})", .{url}) catch "unexpected /help response" };
    } else |err| switch (err) {
        // The server answered with an HTTP status (it's reachable; the
        // /help route just isn't a 200 zfin help page).
        error.NotFound, error.Unauthorized, error.RateLimited, error.PaymentRequired, error.ServerError, error.InvalidResponse => return .{
            .status = .warn,
            .label = "ZFIN_SERVER",
            .detail = std.fmt.allocPrint(arena, "responded ({s}) but /help unavailable ({s})", .{ @errorName(err), url }) catch "no /help",
        },
        // Transport-level failure: genuinely unreachable.
        else => return .{
            .status = .warn,
            .label = "ZFIN_SERVER",
            .detail = std.fmt.allocPrint(arena, "unreachable: {s} ({s})", .{ @errorName(err), url }) catch "unreachable",
        },
    }
}

const UserConfigKind = enum { keys, theme };

/// Check `$HOME/.config/zfin/{keys,theme}.srf`. These resolve from
/// $HOME only (not ZFIN_HOME / cwd), mirroring the TUI loader.
fn checkUserConfigFiles(io: std.Io, arena: std.mem.Allocator, config: Config, kind: UserConfigKind) Check {
    const filename = switch (kind) {
        .keys => "keys.srf",
        .theme => "theme.srf",
    };
    const home = if (config.environ_map) |em| em.get("HOME") else null;
    if (home == null) {
        return .{ .status = .info, .label = filename, .detail = "HOME unset; using built-in defaults" };
    }
    const path = std.fs.path.join(arena, &.{ home.?, ".config", "zfin", filename }) catch
        return .{ .status = .info, .label = filename, .detail = "path join failed" };
    // Distinguish missing from present-but-broken.
    std.Io.Dir.cwd().access(io, path, .{}) catch
        return .{ .status = .info, .label = filename, .detail = "not present; using built-in defaults" };

    switch (kind) {
        .keys => switch (keybinds.loadFromFileChecked(io, arena, path)) {
            .keymap => |km| {
                if (km.warnings.len > 0) {
                    return .{ .status = .warn, .label = filename, .detail = std.fmt.allocPrint(arena, "loaded with {d} warning(s)", .{km.warnings.len}) catch "loaded with warnings" };
                }
                return .{ .status = .ok, .label = filename, .detail = path };
            },
            .err => |e| return .{ .status = .fail, .label = filename, .detail = std.fmt.allocPrint(arena, "invalid: {s}", .{@errorName(e)}) catch "invalid" },
            .fallback => return .{ .status = .fail, .label = filename, .detail = "present but unparseable (fell back to defaults)" },
        },
        .theme => {
            if (theme.loadFromFile(io, arena, path) != null) {
                return .{ .status = .ok, .label = filename, .detail = path };
            }
            return .{ .status = .fail, .label = filename, .detail = "present but unparseable (fell back to defaults)" };
        },
    }
}

// ── Cross-reference extraction (struct → name slices) ─────────

fn uniqueAccounts(arena: std.mem.Allocator, lots: []const Lot) ![]const []const u8 {
    var list: std.ArrayList([]const u8) = .empty;
    for (lots) |lot| {
        const acct = lot.account orelse continue;
        if (!containsStr(list.items, acct)) try list.append(arena, acct);
    }
    return list.items;
}

fn accountNames(arena: std.mem.Allocator, am: analysis.AccountMap) ![]const []const u8 {
    var list: std.ArrayList([]const u8) = .empty;
    for (am.entries) |e| try list.append(arena, e.account);
    return list.items;
}

/// Accounts known from accounts.srf OR appearing on a lot — the union
/// against which transfer endpoints are validated.
fn knownAccountNames(arena: std.mem.Allocator, am: ?analysis.AccountMap, lots: []const Lot) ![]const []const u8 {
    var list: std.ArrayList([]const u8) = .empty;
    if (am) |m| for (m.entries) |e| {
        if (!containsStr(list.items, e.account)) try list.append(arena, e.account);
    };
    for (lots) |lot| {
        const acct = lot.account orelse continue;
        if (!containsStr(list.items, acct)) try list.append(arena, acct);
    }
    return list.items;
}

/// Symbols of classifiable holdings (stocks/ETFs). Cash, options, and
/// CDs don't need a metadata entry, mirroring `analysis`'s unclassified
/// semantics.
///
/// Uses `lot.priceSymbol()` (the `ticker::` alias when set, else the
/// raw symbol) because positions/allocations aggregate under the
/// ticker (see `Portfolio.positionsAsOf`), and the classification
/// engine matches metadata against the allocation's symbol. So a
/// `DI-SPX` lot with `ticker::SPY` is classified via SPY's entry, and
/// doctor must check SPY, not DI-SPX.
fn classifiableSymbols(arena: std.mem.Allocator, lots: []const Lot) ![]const []const u8 {
    var list: std.ArrayList([]const u8) = .empty;
    for (lots) |lot| {
        if (lot.security_type != .stock) continue;
        const sym = lot.priceSymbol();
        if (!containsStr(list.items, sym)) try list.append(arena, sym);
    }
    return list.items;
}

fn classifiedSymbols(arena: std.mem.Allocator, cm: classification.ClassificationMap) ![]const []const u8 {
    var list: std.ArrayList([]const u8) = .empty;
    for (cm.entries) |e| {
        if (!containsStr(list.items, e.symbol)) try list.append(arena, e.symbol);
    }
    return list.items;
}

fn transferEndpoints(arena: std.mem.Allocator, tl: transaction_log.TransactionLog) ![]const []const u8 {
    var list: std.ArrayList([]const u8) = .empty;
    for (tl.transfers) |t| {
        if (!containsStr(list.items, t.from)) try list.append(arena, t.from);
        if (!containsStr(list.items, t.to)) try list.append(arena, t.to);
    }
    return list.items;
}

// ── Renderer ──────────────────────────────────────────────────

fn renderReport(out: *std.Io.Writer, color: bool, sections: []const Section) !void {
    try cli.printBold(out, color, "zfin doctor\n", .{});
    for (sections) |section| {
        try out.print("\n", .{});
        try cli.printBold(out, color, "{s}\n", .{section.title});
        for (section.checks) |c| {
            try out.print("  [", .{});
            try cli.printIntent(out, color, c.status.intent(), "{s}", .{c.status.label()});
            try out.print("] {s}", .{c.label});
            if (c.detail.len > 0) try out.print(": {s}", .{c.detail});
            try out.print("\n", .{});
        }
    }

    const oks = countByStatus(sections, .ok);
    const warns = countByStatus(sections, .warn);
    const fails = countByStatus(sections, .fail);
    try out.print("\n", .{});
    try cli.printBold(out, color, "Summary: ", .{});
    try out.print("{d} OK, ", .{oks});
    try cli.printIntent(out, color, if (warns > 0) .warning else .normal, "{d} warning(s)", .{warns});
    try out.print(", ", .{});
    try cli.printIntent(out, color, if (fails > 0) .negative else .normal, "{d} failure(s)", .{fails});
    try out.print("\n", .{});
}

// ── Tests ─────────────────────────────────────────────────────

const testing = std.testing;

test "parseArgs: any argument is rejected" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--whatever"};
    try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "parseArgs: no args ok" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{};
    _ = try parseArgs(&ctx, &args);
}

test "fileStatus: missing required is warn, missing optional is info" {
    try testing.expectEqual(Status.warn, fileStatus(false, null, .required));
    try testing.expectEqual(Status.info, fileStatus(false, null, .optional));
}

test "fileStatus: present + parses is ok; present + parse error is fail" {
    try testing.expectEqual(Status.ok, fileStatus(true, null, .required));
    try testing.expectEqual(Status.ok, fileStatus(true, null, .optional));
    try testing.expectEqual(Status.fail, fileStatus(true, "InvalidData", .required));
    try testing.expectEqual(Status.fail, fileStatus(true, "InvalidData", .optional));
}

test "coverageCheck: all present is ok" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const needed = [_][]const u8{ "Sample IRA", "Sample Brokerage" };
    const known = [_][]const u8{ "Sample IRA", "Sample Brokerage", "Sample Roth" };
    const c = try coverageCheck(arena.allocator(), "accounts", &needed, &known, "missing");
    try testing.expectEqual(Status.ok, c.status);
}

test "coverageCheck: missing entries produce a warn listing them" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const needed = [_][]const u8{ "Sample IRA", "Sample HSA" };
    const known = [_][]const u8{"Sample IRA"};
    const c = try coverageCheck(arena.allocator(), "accounts", &needed, &known, "missing from accounts.srf");
    try testing.expectEqual(Status.warn, c.status);
    try testing.expect(std.mem.indexOf(u8, c.detail, "Sample HSA") != null);
    try testing.expect(std.mem.indexOf(u8, c.detail, "missing from accounts.srf") != null);
}

test "coverageCheck: empty needed is ok" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const known = [_][]const u8{"Sample IRA"};
    const c = try coverageCheck(arena.allocator(), "accounts", &.{}, &known, "missing");
    try testing.expectEqual(Status.ok, c.status);
}

test "coverageCheck: dedupes repeated missing names" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const needed = [_][]const u8{ "Sample HSA", "Sample HSA", "Sample HSA" };
    const c = try coverageCheck(arena.allocator(), "accounts", &needed, &.{}, "missing");
    try testing.expectEqual(Status.warn, c.status);
    // "Sample HSA" should appear exactly once.
    try testing.expectEqual(@as(usize, 1), std.mem.count(u8, c.detail, "Sample HSA"));
}

test "capabilityChecks: present keys are ok, absent keys are info (never warn)" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const cfg: Config = .{
        .cache_dir = "/tmp/x",
        .tiingo_key = "set",
        .polygon_key = null,
    };
    const checks = try capabilityChecks(arena.allocator(), cfg);

    // No capability check is ever a warn/fail — keyless is valid.
    for (checks) |c| try testing.expect(c.status == .ok or c.status == .info);

    // Find TIINGO (set → ok) and POLYGON (unset → info).
    var saw_tiingo_ok = false;
    var saw_polygon_info = false;
    for (checks) |c| {
        if (std.mem.eql(u8, c.label, "TIINGO_API_KEY")) {
            saw_tiingo_ok = c.status == .ok;
        }
        if (std.mem.eql(u8, c.label, "POLYGON_API_KEY")) {
            saw_polygon_info = c.status == .info;
        }
    }
    try testing.expect(saw_tiingo_ok);
    try testing.expect(saw_polygon_info);
}

test "capabilityChecks: includes the always-on keyless capabilities" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const cfg: Config = .{ .cache_dir = "/tmp/x" };
    const checks = try capabilityChecks(arena.allocator(), cfg);
    var saw_yahoo = false;
    var saw_cboe = false;
    for (checks) |c| {
        if (std.mem.indexOf(u8, c.label, "Yahoo") != null) saw_yahoo = true;
        if (std.mem.indexOf(u8, c.label, "CBOE") != null) saw_cboe = true;
    }
    try testing.expect(saw_yahoo);
    try testing.expect(saw_cboe);
}

test "countByStatus: tallies across sections" {
    const a = [_]Check{
        .{ .status = .ok, .label = "a" },
        .{ .status = .warn, .label = "b" },
    };
    const b = [_]Check{
        .{ .status = .fail, .label = "c" },
        .{ .status = .ok, .label = "d" },
    };
    const sections = [_]Section{
        .{ .title = "A", .checks = &a },
        .{ .title = "B", .checks = &b },
    };
    try testing.expectEqual(@as(usize, 2), countByStatus(&sections, .ok));
    try testing.expectEqual(@as(usize, 1), countByStatus(&sections, .warn));
    try testing.expectEqual(@as(usize, 1), countByStatus(&sections, .fail));
    try testing.expectEqual(@as(usize, 0), countByStatus(&sections, .info));
}

test "renderReport: writes sections, labels, and a summary (no color)" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const checks = [_]Check{
        .{ .status = .ok, .label = "portfolio.srf", .detail = "10 lots" },
        .{ .status = .fail, .label = "metadata.srf", .detail = "parse error: InvalidData" },
    };
    const sections = [_]Section{.{ .title = "Files", .checks = &checks }};
    try renderReport(&w, false, &sections);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "Files") != null);
    try testing.expect(std.mem.indexOf(u8, out, "[OK  ] portfolio.srf: 10 lots") != null);
    try testing.expect(std.mem.indexOf(u8, out, "[FAIL] metadata.srf: parse error: InvalidData") != null);
    try testing.expect(std.mem.indexOf(u8, out, "1 OK") != null);
    try testing.expect(std.mem.indexOf(u8, out, "1 failure(s)") != null);
    // color=false → no ANSI escapes.
    try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

test "fileStatus drives exit: a parse failure yields a FAIL the summary counts" {
    const checks = [_]Check{.{ .status = fileStatus(true, "InvalidData", .optional), .label = "x" }};
    const sections = [_]Section{.{ .title = "Files", .checks = &checks }};
    try testing.expect(countByStatus(&sections, .fail) == 1);
}

// ── Cross-reference extraction helpers ────────────────────────

fn testLot(symbol: []const u8, sec: @import("../models/portfolio.zig").LotType, account: ?[]const u8) Lot {
    return .{
        .symbol = symbol,
        .shares = 1,
        .open_date = Date.fromYmd(2024, 1, 1),
        .open_price = 1.0,
        .security_type = sec,
        .account = account,
    };
}

test "uniqueAccounts: dedupes and skips null accounts" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const lots = [_]Lot{
        testLot("VTI", .stock, "Sample IRA"),
        testLot("CASH", .cash, null),
        testLot("BND", .stock, "Sample Brokerage"),
        testLot("VTI", .stock, "Sample IRA"),
    };
    const got = try uniqueAccounts(arena.allocator(), &lots);
    try testing.expectEqual(@as(usize, 2), got.len);
    try testing.expectEqualStrings("Sample IRA", got[0]);
    try testing.expectEqualStrings("Sample Brokerage", got[1]);
}

test "classifiableSymbols: only stock/ETF lots, deduped (cash/option/cd excluded)" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const lots = [_]Lot{
        testLot("VTI", .stock, "A"),
        testLot("SPY 250101C00500000", .option, "A"),
        testLot("CASH", .cash, "A"),
        testLot("912828ZT0", .cd, "A"),
        testLot("VTI", .stock, "B"),
        testLot("AAPL", .stock, "A"),
    };
    const got = try classifiableSymbols(arena.allocator(), &lots);
    try testing.expectEqual(@as(usize, 2), got.len);
    try testing.expectEqualStrings("VTI", got[0]);
    try testing.expectEqualStrings("AAPL", got[1]);
}

test "classifiableSymbols: resolves the ticker:: alias (DI-SPX/ticker::SPY → SPY)" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    var di = testLot("DI-SPX", .stock, "A");
    di.ticker = "SPY"; // direct-indexing proxy aliased to SPY
    var plain = testLot("02315N402", .stock, "A"); // CUSIP, no ticker alias
    _ = &plain;
    const lots = [_]Lot{ di, plain };
    const got = try classifiableSymbols(arena.allocator(), &lots);
    // DI-SPX resolves to its ticker SPY; the unaliased CUSIP stays itself.
    try testing.expectEqual(@as(usize, 2), got.len);
    try testing.expectEqualStrings("SPY", got[0]);
    try testing.expectEqualStrings("02315N402", got[1]);
}

test "accountNames / knownAccountNames: from AccountMap and union with lots" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample IRA", .tax_type = .taxable },
        .{ .account = "Sample Roth", .tax_type = .roth },
    };
    const am: analysis.AccountMap = .{ .entries = &entries, .allocator = arena.allocator() };

    const names = try accountNames(arena.allocator(), am);
    try testing.expectEqual(@as(usize, 2), names.len);

    const lots = [_]Lot{ testLot("VTI", .stock, "Sample Brokerage"), testLot("BND", .stock, "Sample IRA") };
    const known = try knownAccountNames(arena.allocator(), am, &lots);
    // Union: IRA, Roth (from map) + Brokerage (from lot); IRA not duplicated.
    try testing.expectEqual(@as(usize, 3), known.len);
    try testing.expect(containsStr(known, "Sample Brokerage"));
    try testing.expect(containsStr(known, "Sample Roth"));
}

test "classifiedSymbols: from ClassificationMap, deduped" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    var entries = [_]classification.ClassificationEntry{
        .{ .symbol = "VTI" },
        .{ .symbol = "AAPL" },
        .{ .symbol = "VTI" },
    };
    const cm: classification.ClassificationMap = .{ .entries = &entries, .allocator = arena.allocator() };
    const got = try classifiedSymbols(arena.allocator(), cm);
    try testing.expectEqual(@as(usize, 2), got.len);
}

test "transferEndpoints: collects from/to deduped" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    var transfers = [_]transaction_log.TransferRecord{
        .{ .transfer = Date.fromYmd(2024, 1, 1), .amount = 100, .from = "Sample IRA", .to = "Sample Brokerage", .dest_lot = .{ .cash = {} } },
        .{ .transfer = Date.fromYmd(2024, 2, 1), .amount = 50, .from = "Sample IRA", .to = "Sample HSA", .dest_lot = .{ .cash = {} } },
    };
    const tl: transaction_log.TransactionLog = .{ .transfers = &transfers, .allocator = arena.allocator() };
    const got = try transferEndpoints(arena.allocator(), tl);
    // IRA, Brokerage, HSA — IRA appears in both records but once here.
    try testing.expectEqual(@as(usize, 3), got.len);
    try testing.expect(containsStr(got, "Sample IRA"));
    try testing.expect(containsStr(got, "Sample HSA"));
}

test "cross-ref end to end: missing account surfaces as a warn" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const lots = [_]Lot{ testLot("VTI", .stock, "Sample IRA"), testLot("BND", .stock, "Sample HSA") };
    var entries = [_]analysis.AccountTaxEntry{.{ .account = "Sample IRA", .tax_type = .taxable }};
    const am: analysis.AccountMap = .{ .entries = &entries, .allocator = arena.allocator() };

    const lot_accts = try uniqueAccounts(arena.allocator(), &lots);
    const known = try accountNames(arena.allocator(), am);
    const c = try coverageCheck(arena.allocator(), "accounts.srf coverage", lot_accts, known, "accounts in portfolio missing from accounts.srf");
    try testing.expectEqual(Status.warn, c.status);
    try testing.expect(std.mem.indexOf(u8, c.detail, "Sample HSA") != null);
}

test "siblingPath: joins a filename onto the anchor's directory" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    try testing.expectEqualStrings("/home/u/data/accounts.srf", try siblingPath(a, "/home/u/data/portfolio.srf", "accounts.srf"));
    // Bare filename (no separator) → sibling is just the name.
    try testing.expectEqualStrings("accounts.srf", try siblingPath(a, "portfolio.srf", "accounts.srf"));
}

test "validateSrf: accepts a valid stream, rejects a headerless one" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    try validateSrf(arena.allocator(), "#!srfv1\nsymbol::VTI\n");
    try testing.expectError(error.InvalidSrf, validateSrf(arena.allocator(), "no magic header here"));
}

test "checkSrfFile: present + parses → ok" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(testing.io, .{ .sub_path = "metadata.srf", .data = "#!srfv1\nsymbol::VTI,asset_class::US Large Cap\n" });
    const dir = try tmp.dir.realPathFileAlloc(testing.io, ".", a);
    const path = try std.fmt.allocPrint(a, "{s}/metadata.srf", .{dir});
    const c = checkSrfFile(testing.io, a, "metadata.srf", path, .optional, vMetadata);
    try testing.expectEqual(Status.ok, c.status);
}

test "checkSrfFile: present + unparseable → fail" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(testing.io, .{ .sub_path = "metadata.srf", .data = "this is not srf\n" });
    const dir = try tmp.dir.realPathFileAlloc(testing.io, ".", a);
    const path = try std.fmt.allocPrint(a, "{s}/metadata.srf", .{dir});
    const c = checkSrfFile(testing.io, a, "metadata.srf", path, .optional, vMetadata);
    try testing.expectEqual(Status.fail, c.status);
    try testing.expect(std.mem.indexOf(u8, c.detail, "parse error") != null);
}

test "checkSrfFile: missing optional → info, missing required → warn" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const dir = try tmp.dir.realPathFileAlloc(testing.io, ".", a);
    const missing = try std.fmt.allocPrint(a, "{s}/nope.srf", .{dir});
    try testing.expectEqual(Status.info, checkSrfFile(testing.io, a, "nope.srf", missing, .optional, vMetadata).status);
    try testing.expectEqual(Status.warn, checkSrfFile(testing.io, a, "nope.srf", missing, .required, vMetadata).status);
}

test "parseServerVersion: extracts version from a zfin-server /help body" {
    const body =
        \\zfin-server f3c1690 - financial data API
        \\
        \\Endpoints:
        \\  GET /{SYMBOL}/returns           Trailing returns
    ;
    try testing.expectEqualStrings("f3c1690", parseServerVersion(body).?);
}

test "parseServerVersion: tolerates leading whitespace" {
    try testing.expectEqualStrings("abc123", parseServerVersion("\n  zfin-server abc123 - x").?);
}

test "parseServerVersion: non-zfin body returns null" {
    try testing.expectEqual(@as(?[]const u8, null), parseServerVersion("<html>404 Not Found</html>"));
    try testing.expectEqual(@as(?[]const u8, null), parseServerVersion(""));
    // Prefix present but no version token after it.
    try testing.expectEqual(@as(?[]const u8, null), parseServerVersion("zfin-server "));
}

test "trimTrailingSlash: drops a single trailing slash" {
    try testing.expectEqualStrings("https://h", trimTrailingSlash("https://h/"));
    try testing.expectEqualStrings("https://h", trimTrailingSlash("https://h"));
}
