//! Flagless portfolio hygiene check for the `audit` command.
//!
//! When `zfin audit` runs with no brokerage flag, this is what
//! executes: stale-manual-price detection, working-tree-vs-HEAD
//! price/date mismatch detection, account-cadence overdue checks,
//! stale hand-declared `harvested` figures, automatic brokerage-file
//! discovery + reconciliation, and the large-new-lot transfer nudge.
//!
//! The reconciliation in Section 5 delegates to the per-broker
//! modules (`fidelity.zig`, `schwab.zig`) and the shared display in
//! `common.zig`; this file owns only the hygiene logic, so a future
//! `zfin doctor` can reuse it without pulling in the reconciler
//! surface.

const std = @import("std");
const zfin = @import("../../root.zig");
const cli = @import("../common.zig");
const framework = @import("../framework.zig");
const contributions = @import("../contributions.zig");
const Money = @import("../../Money.zig");
const analysis = @import("../../analytics/analysis.zig");
const portfolio_mod = @import("../../models/portfolio.zig");
const Date = @import("../../Date.zig");
const srf = @import("srf");
const git = @import("../../git.zig");
const test_git = @import("../../testutil/git.zig");

const common = @import("common.zig");
const fidelity = @import("../../analytics/reconcile/fidelity.zig");
const schwab = @import("schwab.zig");
const discover = zfin.brokerage.discover;
const fmt = cli.fmt;

// ── Hygiene check (flagless audit) ──────────────────────────

/// Constants for hygiene check behavior. Kept as named constants for
/// easy future tuning.
/// How recent a brokerage export must be for the hygiene check to pick it
/// up. Passed to `discover.brokerFiles` rather than living inside it, because
/// callers disagree: a hygiene report wants only today's downloads, while a
/// reconciler is better served by seeing a stale file and saying so.
const audit_file_max_age_hours = 24;
pub const default_stale_days: u32 = 3;
const stale_warning_multiplier: u32 = 2; // yellow -> red at 2× threshold
/// Safety cap on how many portfolio commits the account-cadence walk
/// scans when resolving each account's last-update timestamp. The walk
/// normally early-exits once every account resolves; this bounds the
/// pathological case where an account never changed in tracked history
/// (so it never resolves) from forcing a scan of the entire repo.
const max_history_commits_scanned: usize = 500;
/// Age at which `zfin audit` starts nagging that an account's
/// hand-declared `harvested` figure (see `AccountTaxEntry.harvested`)
/// needs a refresh.
///
/// Not configurable, and deliberately so: unlike `update_cadence` -
/// which nags every account by default and therefore *needs* a
/// per-account silence knob - this section only ever considers accounts
/// that explicitly declare `harvested`. Not wanting the nag and not
/// wanting the field are the same choice, so the opt-out already exists.
///
/// 90 days is the same interval as `UpdateCadence.quarterly`, but is
/// deliberately an independent constant: retuning the account
/// reconciliation cadence shouldn't silently move this.
const harvested_stale_days: u32 = 90;
/// Age at which `zfin audit` starts nagging that an account's declared
/// `tax_mix_*` carve-outs (see `AccountTaxEntry.tax_mix_taxable`) need a
/// refresh.
///
/// Same opt-in-by-declaring logic as `harvested_stale_days`, and the
/// same 90 days, but again an independent constant: the two figures
/// drift for unrelated reasons. A tax mix drifts as payroll
/// contributions land in different sleeves, which is roughly quarterly
/// in effect; a harvested total drifts whenever the sleeve churns.
///
/// Note this nag is the *only* consequence of a stale mix. Unlike the
/// harvested annotation, the mix never stops applying - see
/// `AccountTaxEntry.tax_mix_date`.
const tax_mix_stale_days: u32 = 90;

/// Compute which accounts have been modified between two parsed portfolios.
/// Returns a set of account names that have any lot-level differences.
/// Compares by serializing each lot to a canonical string per account,
/// sorting, and checking for equality. Simple and robust -- any field
/// change in a lot produces a different string.
fn findModifiedAccounts(
    allocator: std.mem.Allocator,
    old_portfolio: zfin.Portfolio,
    new_portfolio: zfin.Portfolio,
) !std.StringHashMap(void) {
    var modified = std.StringHashMap(void).init(allocator);
    errdefer modified.deinit();

    // Collect serialized lot strings grouped by account
    var old_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
    defer {
        var it = old_accts.valueIterator();
        while (it.next()) |v| {
            for (v.items) |s| allocator.free(s);
            v.deinit(allocator);
        }
        old_accts.deinit();
    }
    var new_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
    defer {
        var it = new_accts.valueIterator();
        while (it.next()) |v| {
            for (v.items) |s| allocator.free(s);
            v.deinit(allocator);
        }
        new_accts.deinit();
    }

    for (old_portfolio.lots) |lot| {
        const acct = lot.account orelse continue;
        const entry = try old_accts.getOrPut(acct);
        if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
        try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
    }
    for (new_portfolio.lots) |lot| {
        const acct = lot.account orelse continue;
        const entry = try new_accts.getOrPut(acct);
        if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
        try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
    }

    // Compare per account: sort both lists, then check equality
    var all = std.StringHashMap(void).init(allocator);
    defer all.deinit();
    {
        var it = old_accts.keyIterator();
        while (it.next()) |k| try all.put(k.*, {});
    }
    {
        var it = new_accts.keyIterator();
        while (it.next()) |k| try all.put(k.*, {});
    }

    var acct_it = all.keyIterator();
    while (acct_it.next()) |acct_key| {
        const acct = acct_key.*;
        const old_ptr = old_accts.getPtr(acct);
        const new_ptr = new_accts.getPtr(acct);
        const old_len = if (old_ptr) |p| p.items.len else 0;
        const new_len = if (new_ptr) |p| p.items.len else 0;

        if (old_len != new_len) {
            try modified.put(acct, {});
            continue;
        }
        if (old_len == 0) continue;

        const old_items = old_ptr.?.items;
        const new_items = new_ptr.?.items;
        std.mem.sort([]const u8, old_items, {}, strLessThan);
        std.mem.sort([]const u8, new_items, {}, strLessThan);

        var differs = false;
        for (old_items, new_items) |a, b| {
            if (!std.mem.eql(u8, a, b)) {
                differs = true;
                break;
            }
        }
        if (differs) try modified.put(acct, {});
    }

    return modified;
}

fn strLessThan(_: void, a: []const u8, b: []const u8) bool {
    return std.mem.order(u8, a, b) == .lt;
}

/// Serialize a lot to a canonical SRF string for comparison.
/// Uses the SRF serializer with comptime reflection, so any new
/// field added to Lot is automatically included.
fn lotToString(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 {
    const lots = [_]portfolio_mod.Lot{lot};
    return std.fmt.allocPrint(allocator, "{f}", .{srf.fmt(portfolio_mod.Lot, &lots, .{ .emit_directives = false })});
}

/// Resolve, per account, the committer timestamp (Unix epoch seconds)
/// of the newest commit in which that account's lots changed in
/// `ri.rel_path`. Walks the file's git history newest-to-oldest,
/// diffing adjacent commit pairs and attributing each change to the
/// newer commit of the pair; results are written into `out`.
///
/// The walk covers full history (no cadence-derived cutoff): an
/// account more than 2x its cadence overdue - exactly the ones we most
/// want an age readout for - would otherwise always fall outside a
/// 2x-cadence window and degrade to "no update history found". It
/// stops early once every account in `all_accounts` has a timestamp,
/// or after scanning `max_history_commits_scanned` commits (the safety
/// valve for a never-changed account).
///
/// Accounts with no detectable change in tracked history are simply
/// absent from `out` (e.g. an account present since before the first
/// portfolio commit, whose initial add has no parent commit to diff
/// against); the caller renders those as "no update history found".
///
/// Keys written into `out` are borrowed from `all_accounts` (stable
/// working-copy account-name pointers), never from the transient
/// historical portfolio strings. Git unavailability degrades to an
/// empty result rather than an error.
fn findLastUpdateTimestamps(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    ri: git.RepoInfo,
    all_accounts: *const std.StringHashMap(void),
    out: *std.StringHashMap(i64),
) !void {
    const commits = git.listCommitsTouching(io, allocator, env, ri.root, ri.rel_path, null) catch &.{};
    defer git.freeCommitTouches(allocator, commits);

    var prev_data: ?[]const u8 = null;
    defer if (prev_data) |pd| allocator.free(pd);

    for (commits, 0..) |ct, ci| {
        // Stop once every account is resolved, or at the scan cap.
        if (out.count() >= all_accounts.count()) break;
        if (ci >= max_history_commits_scanned) break;

        const rev_data = git.show(io, allocator, env, ri.root, ct.commit, ri.rel_path) catch continue;

        if (ci > 0) {
            if (prev_data) |pd| {
                // rev_data is older, pd is newer (commits are newest-first).
                var old_pf = zfin.cache.deserializePortfolio(allocator, rev_data) catch {
                    allocator.free(rev_data);
                    continue;
                };
                defer old_pf.deinit();
                var new_pf = zfin.cache.deserializePortfolio(allocator, pd) catch {
                    allocator.free(rev_data);
                    continue;
                };
                defer new_pf.deinit();

                var mods = findModifiedAccounts(allocator, old_pf, new_pf) catch {
                    allocator.free(rev_data);
                    continue;
                };
                defer mods.deinit();

                // The newer commit's timestamp is when these accounts changed.
                const update_ts = commits[ci - 1].timestamp;

                // Match against stable working-copy account names.
                var acct_iter = all_accounts.keyIterator();
                while (acct_iter.next()) |stable_name| {
                    if (out.contains(stable_name.*)) continue;
                    if (mods.contains(stable_name.*)) {
                        try out.put(stable_name.*, update_ts);
                    }
                }
            }
        }

        if (prev_data) |pd| allocator.free(pd);
        prev_data = rev_data;
    }
}

/// Staleness color based on age vs threshold.
/// Returns CLR_MUTED for within threshold, warning for 1-2x, negative for >2x.
fn stalenessColor(age_days: i32, threshold: u32) [3]u8 {
    const t: i32 = @intCast(threshold);
    if (age_days <= t) return cli.CLR_MUTED;
    if (age_days <= t * @as(i32, stale_warning_multiplier)) return cli.CLR_WARNING;
    return cli.CLR_NEGATIVE;
}

/// One stale (or undated) manual price found during the hygiene scan.
/// String fields borrow from the scanned portfolio's lots and are
/// valid for the lifetime of that portfolio.
const StaleManualPrice = struct {
    account: []const u8,
    symbol: []const u8,
    note: ?[]const u8,
    price: f64,
    /// `null` when the lot carries a manual `price` but no
    /// `price_date` - the most-stale case (it can't even be aged),
    /// not the least.
    price_date: ?Date,
    /// Days since `price_date`; `null` when undated.
    age_days: ?i32,
};

/// Collect manual-priced lots that are stale (older than `stale_days`)
/// or undated, for the "Stale manual prices" hygiene section.
///
/// Staleness is purely a property of the `price` / `price_date` the
/// user typed on the lot - it has nothing to do with the account's
/// `update_cadence` (that's the reconciliation cadence, a separate
/// concept handled by the "Accounts overdue" section). The single
/// threshold is `stale_days` (the `--stale-days` flag, default 3).
///
/// Restricted to open `security_type == .stock` lots: CDs/cash/options
/// carry `price` as a fixed face value that never goes "stale by age,"
/// and closed lots aren't worth nagging about. Undated manual prices
/// are always included regardless of `stale_days` - a manual price
/// with no `price_date` can't be aged, which is the worst case, not a
/// pass. Caller owns the returned list; string fields borrow from
/// `portfolio`.
fn collectStaleManualPrices(
    allocator: std.mem.Allocator,
    portfolio: zfin.Portfolio,
    as_of: Date,
    stale_days: u32,
) !std.ArrayList(StaleManualPrice) {
    var out = std.ArrayList(StaleManualPrice).empty;
    errdefer out.deinit(allocator);

    const threshold: i32 = @intCast(stale_days);
    for (portfolio.lots) |lot| {
        if (lot.security_type != .stock) continue;
        const price = lot.price orelse continue;
        if (!lot.isOpen(as_of)) continue;
        const account = lot.account orelse "(no account)";

        if (lot.price_date) |pd| {
            const age = as_of.days - pd.days;
            if (age <= threshold) continue; // fresh enough
            try out.append(allocator, .{
                .account = account,
                .symbol = lot.symbol,
                .note = lot.note,
                .price = price,
                .price_date = pd,
                .age_days = age,
            });
        } else {
            try out.append(allocator, .{
                .account = account,
                .symbol = lot.symbol,
                .note = lot.note,
                .price = price,
                .price_date = null,
                .age_days = null,
            });
        }
    }
    return out;
}

/// Sort stale manual prices by account, then symbol - so the display
/// can group lines under per-account headers.
fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool {
    const acc = std.mem.order(u8, a.account, b.account);
    if (acc != .eq) return acc == .lt;
    return std.mem.order(u8, a.symbol, b.symbol) == .lt;
}

/// One account whose hand-declared, hand-dated figure has gone stale
/// (or was never dated). Shared by the "Stale harvested figures" and
/// "Stale tax-mix figures" sections, which have identical shape: one
/// row per account, worst first. `account` borrows from the
/// `AccountMap` it was collected from and is valid for that map's
/// lifetime.
const StaleDeclared = struct {
    account: []const u8,
    /// Days since the figure's date; `null` when the figure was declared
    /// with no date at all - the worst case, since an undated figure
    /// can't be aged.
    age_days: ?i32,
    /// Extra clause appended after the age, or `""` for none.
    ///
    /// Lets the harvested section explain that its annotation has
    /// retired without the renderer needing to know what an annotation
    /// is. Static strings only.
    note: []const u8 = "",
};

/// Collect accounts whose `harvested` figure is older than
/// `harvested_stale_days`, or that declare `harvested` with no
/// `harvested_date`, for the "Stale harvested figures" hygiene section.
///
/// Only accounts that explicitly declare `harvested` are considered -
/// that declaration IS the opt-in to being nagged, which is why the
/// threshold needs no per-account override.
///
/// Future-dated entries fall out as fresh (their age is negative).
/// That's deliberate: `zfin doctor` already reports a future
/// `harvested_date` as the config typo it is, and surfacing the same
/// mistake a second time in staleness clothing would be worse than
/// reporting it once.
///
/// Caller owns the returned list; `account` fields borrow from
/// `account_map`.
fn collectStaleHarvested(
    allocator: std.mem.Allocator,
    account_map: analysis.AccountMap,
    as_of: Date,
) !std.ArrayList(StaleDeclared) {
    var out = std.ArrayList(StaleDeclared).empty;
    errdefer out.deinit(allocator);

    const threshold: i32 = @intCast(harvested_stale_days);
    for (account_map.entries) |e| {
        if (e.harvested == null) continue;

        // Whether the figure still renders anywhere is derived by asking
        // `format.fmtHarvestAnnotation` whether it would emit anything,
        // NOT by comparing the age against 365. Those two disagree at
        // leap-year boundaries, because the formatter gates on the
        // calendar-exact `as_of.subtractYears(1)`. Going through the
        // formatter makes the "no longer displayed" note definitionally
        // true - the nag cannot contradict what the user sees.
        //
        // SAFETY: immediately overwritten by fmtHarvestAnnotation below.
        var ann_buf: [fmt.harvest_annotation_max_len]u8 = undefined;
        const hidden = fmt.fmtHarvestAnnotation(&ann_buf, e.harvested, e.harvested_date, as_of).len == 0;
        const note: []const u8 = if (hidden) " - no longer displayed" else "";

        if (e.harvested_date) |on| {
            const age = as_of.days - on.days;
            if (age <= threshold) continue; // fresh enough (or future-dated)
            try out.append(allocator, .{ .account = e.account, .age_days = age, .note = note });
        } else {
            try out.append(allocator, .{ .account = e.account, .age_days = null, .note = note });
        }
    }
    return out;
}

/// Collect accounts whose `tax_mix_*` carve-outs are older than
/// `tax_mix_stale_days`, or that declare a mix with no `tax_mix_date`,
/// for the "Stale tax-mix figures" hygiene section.
///
/// Same opt-in shape as `collectStaleHarvested`: only accounts that
/// declare a mix are considered, and future-dated entries fall out as
/// fresh so `doctor` owns reporting that typo.
///
/// Accounts whose mix was *rejected* still count. The user clearly
/// meant to declare one, and `doctor` explains why it didn't take -
/// silently dropping the row would hide the account from both reports.
///
/// Caller owns the returned list; `account` fields borrow from
/// `account_map`.
fn collectStaleTaxMix(
    allocator: std.mem.Allocator,
    account_map: analysis.AccountMap,
    as_of: Date,
) !std.ArrayList(StaleDeclared) {
    var out = std.ArrayList(StaleDeclared).empty;
    errdefer out.deinit(allocator);

    const threshold: i32 = @intCast(tax_mix_stale_days);
    for (account_map.entries) |e| {
        if (!e.hasTaxMix()) continue;

        // No `note` counterpart to harvested's "no longer displayed":
        // a stale tax mix keeps applying, by design. See
        // `AccountTaxEntry.tax_mix_date`.
        if (e.tax_mix_date) |on| {
            const age = as_of.days - on.days;
            if (age <= threshold) continue; // fresh enough (or future-dated)
            try out.append(allocator, .{ .account = e.account, .age_days = age });
        } else {
            try out.append(allocator, .{ .account = e.account, .age_days = null });
        }
    }
    return out;
}

/// Sort stale hand-declared figures worst-first: undated entries (which
/// can't be aged at all), then oldest, then account name as a stable
/// tiebreak.
///
/// Deliberately unlike `staleLessThan`, which sorts alphabetically:
/// that section emits many rows per account and needs them grouped,
/// while these emit exactly one row per account, so severity order
/// is strictly more useful.
fn staleDeclaredLessThan(_: void, a: StaleDeclared, b: StaleDeclared) bool {
    if (a.age_days == null and b.age_days != null) return true;
    if (a.age_days != null and b.age_days == null) return false;
    if (a.age_days) |ad| {
        const bd = b.age_days.?;
        if (ad != bd) return ad > bd;
    }
    return std.mem.order(u8, a.account, b.account) == .lt;
}

/// How many accounts declare a `harvested` figure at all. Lets the
/// display distinguish "nothing to report because you don't use this
/// feature" (print nothing) from "nothing to report because everything
/// is current" (print a reassuring `(none)`).
fn countHarvestedAccounts(account_map: analysis.AccountMap) usize {
    var n: usize = 0;
    for (account_map.entries) |e| {
        if (e.harvested != null) n += 1;
    }
    return n;
}

/// How many accounts declare `tax_mix_*` carve-outs at all. Same
/// use-the-feature-or-stay-quiet gate as `countHarvestedAccounts`.
fn countTaxMixAccounts(account_map: analysis.AccountMap) usize {
    var n: usize = 0;
    for (account_map.entries) |e| {
        if (e.hasTaxMix()) n += 1;
    }
    return n;
}

/// Render one "stale hand-declared figure" hygiene section.
///
/// The two callers have identical shape - one row per account, worst
/// first - so the layout, the age coloring, and the reassuring `(none)`
/// live here. `title` is the whole section header; `undated_msg` is what
/// a row with no date says instead of an age.
///
/// `rows` is expected pre-sorted by `staleDeclaredLessThan`.
fn printStaleDeclaredSection(
    out: *std.Io.Writer,
    color: bool,
    rows: []const StaleDeclared,
    threshold_days: u32,
    title: []const u8,
    undated_msg: []const u8,
) !void {
    try out.print("\n", .{});
    try cli.printFg(out, color, cli.CLR_MUTED, "  {s}\n", .{title});

    if (rows.len == 0) {
        try cli.printFg(out, color, cli.CLR_POSITIVE, "    (none)\n", .{});
        return;
    }
    for (rows) |e| {
        try out.print("    {s:<32} ", .{e.account});
        if (e.age_days) |ad| {
            const clr = stalenessColor(ad, threshold_days);
            try cli.printFg(out, color, clr, "last updated {d} days ago{s}\n", .{ @as(u32, @intCast(ad)), e.note });
        } else {
            try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s}\n", .{undated_msg});
        }
    }
}

/// A lot whose manual `price` moved between HEAD and the working tree
/// while its `price_date` stayed identical - the "bumped the price,
/// forgot the date" mistake. String fields borrow from the working-
/// tree portfolio.
const PriceDateMismatch = struct {
    account: []const u8,
    symbol: []const u8,
    old_price: f64,
    new_price: f64,
    price_date: Date,
};

/// Build a lot identity that survives a manual-price edit: a lot keeps
/// its symbol, account, open_date, and open_price when you only change
/// `price`/`price_date`. Used to pair HEAD lots with working-tree lots.
/// Caller owns the returned slice.
fn lotIdentityKey(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 {
    return std.fmt.allocPrint(allocator, "{s}\x00{s}\x00{d}\x00{d:.6}", .{
        lot.symbol,
        lot.account orelse "",
        lot.open_date.days,
        lot.open_price,
    });
}

/// Find lots whose manual `price` changed between `committed` (HEAD)
/// and `working` (on-disk) while `price_date` stayed identical.
///
/// This is the working-tree-vs-HEAD detector for the recurring "I
/// updated the price but forgot the date" mistake, run when `audit`
/// fires before a commit. Only open `security_type == .stock` lots
/// with a non-null `price_date` on both sides participate: the undated
/// case is reported by `collectStaleManualPrices`, and legitimate
/// back-dating (moving the date to a past close) is *not* flagged
/// because the date field changed. Lots are paired by
/// `lotIdentityKey`; newly-added or removed lots are ignored. Caller
/// owns the returned list; string fields borrow from `working`.
fn findPriceDateMismatches(
    allocator: std.mem.Allocator,
    committed: zfin.Portfolio,
    working: zfin.Portfolio,
    as_of: Date,
) !std.ArrayList(PriceDateMismatch) {
    var out = std.ArrayList(PriceDateMismatch).empty;
    errdefer out.deinit(allocator);

    // Index HEAD lots by stable identity -> their (price, price_date).
    const HeadLot = struct { price: ?f64, price_date: ?Date };
    var head = std.StringHashMap(HeadLot).init(allocator);
    defer {
        var it = head.keyIterator();
        while (it.next()) |k| allocator.free(k.*);
        head.deinit();
    }
    for (committed.lots) |lot| {
        if (lot.security_type != .stock) continue;
        const key = try lotIdentityKey(allocator, lot);
        const gop = try head.getOrPut(key);
        if (gop.found_existing) {
            // Duplicate identity (rare) - ambiguous, don't guess.
            allocator.free(key);
            continue;
        }
        gop.value_ptr.* = .{ .price = lot.price, .price_date = lot.price_date };
    }

    for (working.lots) |lot| {
        if (lot.security_type != .stock) continue;
        if (!lot.isOpen(as_of)) continue;
        const new_price = lot.price orelse continue;
        const new_date = lot.price_date orelse continue; // undated -> stale-price section's job
        const key = try lotIdentityKey(allocator, lot);
        defer allocator.free(key);
        const prior = head.get(key) orelse continue; // newly-added lot
        const old_price = prior.price orelse continue; // price added, not moved
        const old_date = prior.price_date orelse continue; // was undated before
        // The mistake: price moved, date did not.
        if (old_date.days == new_date.days and @abs(old_price - new_price) >= 0.005) {
            try out.append(allocator, .{
                .account = lot.account orelse "(no account)",
                .symbol = lot.symbol,
                .old_price = old_price,
                .new_price = new_price,
                .price_date = new_date,
            });
        }
    }
    return out;
}

/// Sort price/date mismatches by account, then symbol - for grouped
/// per-account display.
fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
    const acc = std.mem.order(u8, a.account, b.account);
    if (acc != .eq) return acc == .lt;
    return std.mem.order(u8, a.symbol, b.symbol) == .lt;
}

/// Render one unmatched large-lot warning. Formats the line the
/// user needs to paste into `transaction_log.srf` if the lot was
/// an internal movement rather than a real external contribution.
/// Leaves `from::<SOURCE>` as a placeholder - the audit doesn't
/// know which account the money came from.
///
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
/// (or cash_contribution) destinations use `dest_lot::cash`. The
/// template defaults to `type::cash` (cash moved in, then invested -
/// the common case). If the securities themselves were moved between
/// accounts, change it to `type::in_kind` and fill in the `from::`
/// account that the shares left.
fn printLargeLotWarning(
    out: *std.Io.Writer,
    lot: contributions.UnmatchedLargeLot,
    color: bool,
) !void {
    var val_buf: [32]u8 = undefined;
    var date_buf: [10]u8 = undefined;
    const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?";
    const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??";
    const kind_label: []const u8 = switch (lot.security_type) {
        .stock => "STOCK",
        .cash => "CASH",
        .cd => "CD",
        .option => "OPTION",
        else => "LOT",
    };

    const sym_for_display = if (lot.symbol.len > 0) lot.symbol else "cash";
    try out.print(
        "    {s}: new {s} lot {s} ",
        .{ lot.account, kind_label, sym_for_display },
    );
    try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str});
    try out.print(" on {s}\n", .{date_str});

    try cli.printFg(out, color, cli.CLR_MUTED, "      If this was an external contribution: no action needed.\n", .{});
    try cli.printFg(out, color, cli.CLR_MUTED, "      If this was an internal transfer, add to transaction_log.srf:\n", .{});

    // Amount formatted with cents precision so the suggested
    // `amount:num:N` exactly matches the lot's value. The matcher
    // has a $1 tolerance so a whole-dollar suggestion would usually
    // pair, but pasting a value that lies about the actual lot is
    // a poor user experience - `transaction_log.srf` should record
    // what actually moved.
    if (lot.security_type == .cash) {
        try cli.printFg(
            out,
            color,
            cli.CLR_MUTED,
            "        transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::cash\n",
            .{ date_str, lot.value, lot.account },
        );
    } else {
        try cli.printFg(
            out,
            color,
            cli.CLR_MUTED,
            "        transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
            .{ date_str, lot.value, lot.account, lot.symbol, date_str },
        );
    }
}

/// Append the account numbers present in `results` to `dst`, duping
/// each string into `allocator` so it outlives the per-file buffer the
/// reconciler's result slices borrow from. Used by the flagless audit
/// to union the present accounts across every discovered export before
/// computing the "accounts not found in any export" advisory once, so a
/// single-account positions CSV no longer flags every sibling account
/// as missing. `T` is the reconciler's comparison type
/// (`common.AccountComparison` or `schwab.SchwabAccountComparison`) -
/// both expose an `account_number` field, which `common.presentNumbers`
/// collects.
fn accumulatePresent(
    allocator: std.mem.Allocator,
    dst: *std.ArrayList([]const u8),
    comptime T: type,
    results: []const T,
) !void {
    const present = try common.presentNumbers(allocator, T, results);
    defer allocator.free(present);
    for (present) |num| try dst.append(allocator, try allocator.dupe(u8, num));
}

/// Run the flagless portfolio hygiene check.
pub fn runHygieneCheck(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    svc: *zfin.DataService,
    portfolio_path: []const u8,
    stale_days: u32,
    verbose: bool,
    as_of: Date,
    now_s: i64,
    color: bool,
    refresh: framework.RefreshPolicy,
    out: *std.Io.Writer,
) !void {
    // Load portfolio
    const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch {
        cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
        return;
    };
    defer allocator.free(pf_data);

    var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
        cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
        return;
    };
    defer portfolio.deinit();

    // Load accounts.srf
    var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse {
        cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
        return;
    };
    defer account_map.deinit();

    try cli.printBold(out, color, "  Portfolio hygiene\n", .{});

    // ── Section 1: Stale manual prices ──
    //
    // Manual prices on stock/fund lots whose `price_date` is older than
    // `--stale-days` (default 3), plus manual prices with no
    // `price_date` at all (the most-stale case). Staleness is a
    // property of the price the user typed on the lot - not of the
    // account; grouping by account here is display-only organization.
    // CDs/cash/options are excluded - their `price` is a fixed face
    // value, not an age-stale quote - as are closed lots.
    {
        var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days);
        defer stale.deinit(allocator);
        std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan);

        try out.print("\n", .{});
        try cli.printFg(out, color, cli.CLR_MUTED, "  Stale manual prices (>{d} days - --stale-days to configure)\n", .{stale_days});

        if (stale.items.len == 0) {
            try cli.printFg(out, color, cli.CLR_POSITIVE, "    (none)\n", .{});
        } else {
            var current_account: ?[]const u8 = null;
            for (stale.items) |e| {
                if (current_account == null or !std.mem.eql(u8, current_account.?, e.account)) {
                    current_account = e.account;
                    try cli.printFg(out, color, cli.CLR_HEADER, "    {s}\n", .{e.account});
                }

                var price_buf: [24]u8 = undefined;
                const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(e.price)}) catch "$?";
                const note_display = e.note orelse "";

                if (e.price_date) |pd| {
                    var date_buf: [10]u8 = undefined;
                    const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??";
                    try out.print("      {s:<14} {s:<16} {s:>12}   {s}  ", .{ e.symbol, note_display, price_str, date_str });
                    const clr = stalenessColor(e.age_days.?, stale_days);
                    try cli.printFg(out, color, clr, "({d} days)\n", .{@as(u32, @intCast(e.age_days.?))});
                } else {
                    try out.print("      {s:<14} {s:<16} {s:>12}   ", .{ e.symbol, note_display, price_str });
                    try cli.printFg(out, color, cli.CLR_NEGATIVE, "(no price_date set)\n", .{});
                }
            }
        }
    }

    // ── Section 2: Account cadence check ──
    {
        // Try to get committed version via git
        const repo_info: ?git.RepoInfo = git.findRepo(io, allocator, env, portfolio_path) catch null;
        defer if (repo_info) |ri| {
            allocator.free(ri.root);
            allocator.free(ri.rel_path);
        };

        // Parse committed portfolio for diff (working copy vs HEAD)
        var committed_portfolio: ?zfin.Portfolio = null;
        defer if (committed_portfolio) |*cp| cp.deinit();

        var committed_data: ?[]const u8 = null;
        defer if (committed_data) |d| allocator.free(d);

        if (repo_info) |ri| {
            committed_data = git.show(io, allocator, env, ri.root, "HEAD", ri.rel_path) catch null;
            if (committed_data) |cd| {
                committed_portfolio = zfin.cache.deserializePortfolio(allocator, cd) catch null;
            }
        }

        // ── Section 1b: manual price changed without bumping price_date ──
        //
        // Catches the recurring "I updated the price but forgot the
        // date" mistake at the moment it matters - when `audit` runs
        // against the working tree before a commit. Diffs the on-disk
        // portfolio against HEAD; a lot whose `price` moved while its
        // `price_date` stayed put is flagged. Silent when there's no
        // committed version to compare against (not a repo / new file)
        // and when no such mismatch exists.
        if (committed_portfolio) |cp| {
            var mismatches = try findPriceDateMismatches(allocator, cp, portfolio, as_of);
            defer mismatches.deinit(allocator);
            std.mem.sort(PriceDateMismatch, mismatches.items, {}, mismatchLessThan);

            if (mismatches.items.len > 0) {
                try out.print("\n", .{});
                try cli.printFg(out, color, cli.CLR_MUTED, "  Manual price changed without updating price_date (working tree vs HEAD)\n", .{});
                var current_account: ?[]const u8 = null;
                for (mismatches.items) |m| {
                    if (current_account == null or !std.mem.eql(u8, current_account.?, m.account)) {
                        current_account = m.account;
                        try cli.printFg(out, color, cli.CLR_HEADER, "    {s}\n", .{m.account});
                    }
                    var old_buf: [24]u8 = undefined;
                    var new_buf: [24]u8 = undefined;
                    var date_buf: [10]u8 = undefined;
                    const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?";
                    const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?";
                    const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??";
                    try out.print("      {s:<14} {s} -> {s}   ", .{ m.symbol, old_str, new_str });
                    try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} - bump it\n", .{date_str});
                }
            }
        }

        // Find accounts modified in working copy (uncommitted changes)
        var working_copy_modified = std.StringHashMap(void).init(allocator);
        defer working_copy_modified.deinit();

        if (committed_portfolio) |cp| {
            working_copy_modified = findModifiedAccounts(allocator, cp, portfolio) catch std.StringHashMap(void).init(allocator);
        }

        // Collect all unique account names from working copy portfolio
        // (these pointers are stable for the lifetime of the function)
        var all_accounts = std.StringHashMap(void).init(allocator);
        defer all_accounts.deinit();
        for (portfolio.lots) |lot| {
            if (lot.account) |acct| {
                try all_accounts.put(acct, {});
            }
        }

        // Find last update time for each account via git history.
        // Walks the portfolio file's full history (no cadence-derived
        // cutoff), keyed by stable working-copy account names. Absent
        // entries render as "no update history found" below.
        var last_update_ts = std.StringHashMap(i64).init(allocator);
        defer last_update_ts.deinit();

        if (repo_info) |ri| {
            try findLastUpdateTimestamps(io, allocator, env, ri, &all_accounts, &last_update_ts);
        }

        // Display overdue accounts
        var overdue_header_shown = false;
        var updated_accounts = std.ArrayList([]const u8).empty;
        defer updated_accounts.deinit(allocator);

        // Check accounts updated in working copy
        var wc_it = working_copy_modified.keyIterator();
        while (wc_it.next()) |key| {
            try updated_accounts.append(allocator, key.*);
        }

        // Check overdue accounts
        var acct_it = all_accounts.keyIterator();
        while (acct_it.next()) |acct_key| {
            const acct_name = acct_key.*;

            // Skip if already updated in working copy
            if (working_copy_modified.contains(acct_name)) continue;

            // Look up cadence from accounts.srf
            var cadence = analysis.UpdateCadence.weekly; // default
            for (account_map.entries) |entry| {
                if (std.mem.eql(u8, entry.account, acct_name)) {
                    cadence = entry.update_cadence;
                    break;
                }
            }

            const threshold_days = cadence.thresholdDays() orelse continue; // skip 'none'

            // Find last update time
            var age_days: ?i32 = null;
            if (last_update_ts.get(acct_name)) |ts| {
                const age_s = now_s - ts;
                age_days = @intCast(@divFloor(age_s, std.time.s_per_day));
            }

            // If we have no git history for this account, it's definitely overdue
            const days = age_days orelse @as(i32, @intCast(threshold_days + 1));
            if (days <= @as(i32, @intCast(threshold_days))) continue;

            if (!overdue_header_shown) {
                try out.print("\n", .{});
                try cli.printFg(out, color, cli.CLR_MUTED, "  Accounts overdue for update (weekly default - set update_cadence in accounts.srf)\n", .{});
                overdue_header_shown = true;
            }

            try out.print("    {s:<32} {s:<10}", .{ acct_name, cadence.label() });
            if (age_days) |ad| {
                const clr = stalenessColor(ad, threshold_days);
                try cli.printFg(out, color, clr, "last updated {d} days ago\n", .{@as(u32, @intCast(ad))});
            } else {
                try cli.printFg(out, color, cli.CLR_NEGATIVE, "no update history found\n", .{});
            }
        }

        // Display accounts updated in working copy
        if (updated_accounts.items.len > 0) {
            try out.print("\n", .{});
            try cli.printFg(out, color, cli.CLR_MUTED, "  Accounts updated (working copy)\n", .{});
            for (updated_accounts.items) |acct| {
                try cli.printFg(out, color, cli.CLR_POSITIVE, "    {s}\n", .{acct});
            }
        }
    }

    // ── Section 3: Stale harvested figures ──
    //
    // Accounts whose hand-declared `harvested` figure (accounts.srf) is
    // older than `harvested_stale_days`, or that declare it with no
    // `harvested_date`. Unlike Section 2 this ages an explicit date
    // field rather than walking git history, so the two are separate
    // sections - one "days ago" column with two meanings would mislead.
    //
    // The nag has no upper bound on purpose. Past 12 months the
    // annotation stops rendering (see `format.fmtHarvestAnnotation`),
    // and this section is then the only thing that explains where it
    // went - so it escalates its wording instead of going quiet.
    {
        const declared = countHarvestedAccounts(account_map);
        // Silent for portfolios that don't use the feature at all; once
        // an account declares a figure, say so either way.
        if (declared > 0) {
            var stale_harvest = try collectStaleHarvested(allocator, account_map, as_of);
            defer stale_harvest.deinit(allocator);
            std.mem.sort(StaleDeclared, stale_harvest.items, {}, staleDeclaredLessThan);

            var title_buf: [128]u8 = undefined;
            const title = try std.fmt.bufPrint(
                &title_buf,
                "Stale harvested figures (>{d} days - refresh 'harvested' in accounts.srf)",
                .{harvested_stale_days},
            );
            try printStaleDeclaredSection(
                out,
                color,
                stale_harvest.items,
                harvested_stale_days,
                title,
                "no harvested_date set",
            );
        }
    }

    // ── Section 3b: Stale tax-mix figures ──
    //
    // Same shape as Section 3, for the `tax_mix_*` carve-outs (see
    // `analysis.TaxMix`). Kept separate from harvested for the same
    // reason Section 3 is kept separate from Section 2: two different
    // "days ago" meanings in one column mislead.
    //
    // Worth nagging about precisely because a stale mix never stops
    // applying - it quietly feeds the By Tax Type breakdown forever. The
    // alternative (retiring it like the harvested annotation) would
    // silently move the user's pre-tax vs post-tax picture, which is
    // worse than showing a slightly stale split.
    {
        const declared = countTaxMixAccounts(account_map);
        if (declared > 0) {
            var stale_mix = try collectStaleTaxMix(allocator, account_map, as_of);
            defer stale_mix.deinit(allocator);
            std.mem.sort(StaleDeclared, stale_mix.items, {}, staleDeclaredLessThan);

            var title_buf: [128]u8 = undefined;
            const title = try std.fmt.bufPrint(
                &title_buf,
                "Stale tax-mix figures (>{d} days - refresh 'tax_mix_*' in accounts.srf)",
                .{tax_mix_stale_days},
            );
            try printStaleDeclaredSection(
                out,
                color,
                stale_mix.items,
                tax_mix_stale_days,
                title,
                "no tax_mix_date set (mix still applies)",
            );
        }
    }

    // ── Section 4: Discover brokerage files ──

    // Resolve audit directories
    const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse ".";

    var all_files = std.ArrayList(discover.DiscoveredFile).empty;
    defer {
        for (all_files.items) |f| allocator.free(f.path);
        all_files.deinit(allocator);
    }

    // Check $ZFIN_AUDIT_FILES first
    const env_audit_dir = if (svc.config.environ_map) |em| em.get("ZFIN_AUDIT_FILES") else null;
    if (env_audit_dir) |edir| {
        const env_files = try discover.brokerFiles(io, allocator, edir, "$ZFIN_AUDIT_FILES", now_s, audit_file_max_age_hours);
        defer allocator.free(env_files);
        for (env_files) |f| try all_files.append(allocator, f);
    }

    // Then check {portfolio_dir}/audit/
    const default_audit_dir = std.fs.path.join(allocator, &.{ portfolio_dir, "audit" }) catch null;
    defer if (default_audit_dir) |d| allocator.free(d);

    if (default_audit_dir) |adir| {
        const dir_files = try discover.brokerFiles(io, allocator, adir, "audit/", now_s, audit_file_max_age_hours);
        defer allocator.free(dir_files);
        for (dir_files) |f| try all_files.append(allocator, f);
    }

    // Display discovered files
    if (all_files.items.len > 0) {
        try out.print("\n", .{});
        try cli.printFg(out, color, cli.CLR_MUTED, "  Brokerage files (last {d} hours)\n", .{audit_file_max_age_hours});
        for (all_files.items) |f| {
            const kind_label: []const u8 = switch (f.kind) {
                .fidelity_csv => "fidelity",
                .schwab_csv => "schwab csv",
                .schwab_summary => "schwab summary",
            };
            try out.print("    {s:<52} {s}\n", .{ f.path, kind_label });
        }
    }

    // ── Section 5: Auto-reconcile discovered files ──

    if (all_files.items.len > 0) {
        // Build prices map (shared by all reconciliations)
        var prices = std.StringHashMap(f64).init(allocator);
        defer prices.deinit();
        {
            const pos_syms = try portfolio.stockSymbols(allocator);
            defer allocator.free(pos_syms);
            if (pos_syms.len > 0) {
                var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, refresh, color);
                defer load_result.deinit();
                var pit = load_result.prices.iterator();
                while (pit.next()) |entry| {
                    try prices.put(entry.key_ptr.*, entry.value_ptr.*);
                }
            }
            for (portfolio.lots) |lot| {
                if (lot.price) |p| {
                    if (!prices.contains(lot.priceSymbol())) {
                        try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
                    }
                }
            }
        }

        try out.print("\n", .{});
        try cli.printBold(out, color, "  Reconciliation\n", .{});

        // Present account numbers per institution, unioned across every
        // discovered file. The "accounts not found" advisory is computed
        // once from these unions AFTER the loop - not per file - so a
        // single-account positions CSV no longer flags every other
        // account in the institution as missing (it's present in a
        // sibling export or the summary). Strings are duped because the
        // borrowed account-number slices point into each file's
        // `file_data`, which is freed per loop iteration.
        var fidelity_present: std.ArrayList([]const u8) = .empty;
        var schwab_present: std.ArrayList([]const u8) = .empty;
        defer {
            for (fidelity_present.items) |s| allocator.free(s);
            fidelity_present.deinit(allocator);
            for (schwab_present.items) |s| allocator.free(s);
            schwab_present.deinit(allocator);
        }

        for (all_files.items) |f| {
            const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue;
            defer allocator.free(file_data);

            switch (f.kind) {
                .schwab_summary => {
                    const results = schwab.reconcileSummary(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| {
                        try cli.printFg(out, color, cli.CLR_WARNING, "    {s}: detected as schwab summary but could not parse ({s}); skipped\n", .{ f.path, @errorName(err) });
                        continue;
                    };
                    defer allocator.free(results);

                    if (verbose or schwab.hasSchwabDiscrepancies(results)) {
                        try out.print("\n", .{});
                        try schwab.displaySchwabResults(results, color, out);
                        try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
                    } else {
                        var acct_count: usize = 0;
                        for (results) |r| {
                            if (r.account_name.len > 0) acct_count += 1;
                        }
                        try cli.printFg(out, color, cli.CLR_POSITIVE, "    schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
                        // Always show ratio suggestions even in compact
                        // mode - direct-indexing drift may cause a
                        // non-zero delta that still deserves a nudge.
                        try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
                    }

                    try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
                },
                .fidelity_csv => {
                    const results = fidelity.reconcile(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| {
                        try cli.printFg(out, color, cli.CLR_WARNING, "    {s}: detected as fidelity CSV but could not parse ({s}); skipped\n", .{ f.path, @errorName(err) });
                        continue;
                    };
                    defer {
                        for (results) |r| allocator.free(r.comparisons);
                        allocator.free(results);
                    }

                    if (verbose or common.hasAccountDiscrepancies(results)) {
                        try out.print("\n", .{});
                        try common.displayResults(results, color, out);
                        try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
                    } else {
                        try cli.printFg(out, color, cli.CLR_POSITIVE, "    fidelity: {d} accounts, no discrepancies\n", .{results.len});
                        // Always show ratio suggestions even in compact mode
                        try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
                    }

                    try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
                },
                .schwab_csv => {
                    const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| {
                        try cli.printFg(out, color, cli.CLR_WARNING, "    {s}: detected as schwab CSV but could not parse ({s}); skipped\n", .{ f.path, @errorName(err) });
                        continue;
                    };
                    defer {
                        for (results) |r| allocator.free(r.comparisons);
                        allocator.free(results);
                    }

                    if (verbose or common.hasAccountDiscrepancies(results)) {
                        try out.print("\n", .{});
                        try common.displayResults(results, color, out);
                        try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
                    } else {
                        try cli.printFg(out, color, cli.CLR_POSITIVE, "    schwab: {d} accounts, no discrepancies\n", .{results.len});
                        try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
                    }

                    try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
                },
            }
        }

        // One advisory per institution that contributed an export,
        // computed against the unioned present-set. "any export"
        // reflects that absence is now relative to ALL discovered files
        // (see `displayAbsentAccounts`), so an account covered by any
        // sibling CSV or the summary no longer surfaces here.
        if (fidelity_present.items.len > 0) {
            const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", fidelity_present.items, prices, as_of);
            defer allocator.free(absent);
            try common.displayAbsentAccounts(absent, color, "any export", out);
        }
        if (schwab_present.items.len > 0) {
            const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", schwab_present.items, prices, as_of);
            defer allocator.free(absent);
            try common.displayAbsentAccounts(absent, color, "any export", out);
        }
    }

    // ── Section 6: Large new lots - confirm source ──
    //
    // Cross-check any new_* Change with value >= threshold against
    // `transaction_log.srf` (via the shared contributions pipeline).
    // Surfaces lots that look like significant external contributions
    // OR unrecorded internal transfers - nudges the user to either
    // confirm or add a transfer record.
    //
    // Silent when every large lot matched a transfer record, when
    // there are no new lots at all, or when the pipeline can't run
    // (not in a git repo). Threshold is per-account: an account's
    // `audit_large_lot_threshold` in accounts.srf wins, otherwise the
    // filter's built-in default applies.
    if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_path, &account_map, as_of, color, refresh)) |found| {
        var found_mut = found;
        defer found_mut.deinit();

        if (found_mut.lots.len > 0) {
            try out.print("\n", .{});
            try cli.printFg(out, color, cli.CLR_MUTED, "  Large new lots - confirm source\n", .{});
            for (found_mut.lots) |lot| {
                try printLargeLotWarning(out, lot, color);
            }
        }
    }

    // ── Section 7: Unhandled stock splits ──
    //
    // Held symbols with a split AFTER a lot's purchase date that haven't
    // opted into automatic split adjustment. Each needs a
    // `splits_current_through` on its metadata.srf row, or its shares
    // (and every value derived from them) are misstated across the
    // split. Cache-only detection; silent when everything is handled.
    if (portfolio.stockSymbols(allocator)) |split_syms| {
        defer allocator.free(split_syms);
        const unhandled = cli.findUnhandledSplits(svc, allocator, portfolio.lots, split_syms, portfolio_path, as_of, cli.fetchOptionsFromPolicy(refresh));
        defer allocator.free(unhandled);
        if (unhandled.len > 0) {
            try out.print("\n", .{});
            try cli.printFg(out, color, cli.CLR_MUTED, "  Unhandled stock splits\n", .{});
            for (unhandled) |nudge| {
                try out.print("    {s}: stock split on {f}\n", .{ nudge.symbol, nudge.date });
                try cli.printFg(out, color, cli.CLR_MUTED, "      Add 'splits_current_through::YYYY-MM-DD' (your reconcile date) to {s}'s metadata.srf row.\n", .{nudge.symbol});
            }
        }
    } else |_| {}

    try out.print("\n", .{});
}

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

test "accumulatePresent: unions account numbers across calls" {
    const allocator = std.testing.allocator;
    var dst: std.ArrayList([]const u8) = .empty;
    defer {
        for (dst.items) |s| allocator.free(s);
        dst.deinit(allocator);
    }

    const batch1 = [_]common.AccountComparison{
        .{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
    };
    const batch2 = [_]common.AccountComparison{
        .{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "5678", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
    };

    try accumulatePresent(allocator, &dst, common.AccountComparison, &batch1);
    try accumulatePresent(allocator, &dst, common.AccountComparison, &batch2);

    try std.testing.expectEqual(@as(usize, 2), dst.items.len);
    try std.testing.expectEqualStrings("1234", dst.items[0]);
    try std.testing.expectEqualStrings("5678", dst.items[1]);
}

test "accumulatePresent: result strings are owned copies (survive source free)" {
    // The whole point of duping: the borrowed account_number points into
    // a per-file buffer freed each loop iteration. Prove the accumulator
    // keeps its own copy by freeing the source out from under it.
    const allocator = std.testing.allocator;
    var dst: std.ArrayList([]const u8) = .empty;
    defer {
        for (dst.items) |s| allocator.free(s);
        dst.deinit(allocator);
    }

    const num = try allocator.dupe(u8, "9012");
    {
        const batch = [_]common.AccountComparison{
            .{ .account_name = "X", .brokerage_name = "X", .account_number = num, .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
        };
        try accumulatePresent(allocator, &dst, common.AccountComparison, &batch);
    }
    allocator.free(num); // source gone; dst must hold its own copy

    try std.testing.expectEqual(@as(usize, 1), dst.items.len);
    try std.testing.expectEqualStrings("9012", dst.items[0]);
}

test "stalenessColor: within threshold" {
    try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(2, 3));
    try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(3, 3));
}

test "stalenessColor: warning zone (1-2x threshold)" {
    try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(4, 3));
    try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(6, 3));
}

test "stalenessColor: critical zone (>2x threshold)" {
    try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(7, 3));
    try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(30, 3));
}

test "UpdateCadence thresholdDays" {
    try std.testing.expectEqual(@as(?u32, 7), analysis.UpdateCadence.weekly.thresholdDays());
    try std.testing.expectEqual(@as(?u32, 30), analysis.UpdateCadence.monthly.thresholdDays());
    try std.testing.expectEqual(@as(?u32, 90), analysis.UpdateCadence.quarterly.thresholdDays());
    try std.testing.expect(analysis.UpdateCadence.none.thresholdDays() == null);
}

test "findModifiedAccounts: detects share changes" {
    const allocator = std.testing.allocator;

    var old_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
        .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" },
    };
    var new_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 110, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // shares changed
        .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, // unchanged
    };

    const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
    const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };

    var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
    defer modified.deinit();

    try std.testing.expect(modified.contains("Acct A"));
    try std.testing.expect(!modified.contains("Acct B"));
}

test "findModifiedAccounts: detects new lots" {
    const allocator = std.testing.allocator;

    var old_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
    };
    var new_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
        .{ .symbol = "VTI", .shares = 200, .open_date = Date.fromYmd(2025, 3, 1), .open_price = 200.0, .account = "Acct A" },
    };

    const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
    const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };

    var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
    defer modified.deinit();

    try std.testing.expect(modified.contains("Acct A"));
}

test "findModifiedAccounts: detects price changes" {
    const allocator = std.testing.allocator;

    var old_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9) },
    };
    var new_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18) },
    };

    const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
    const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };

    var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
    defer modified.deinit();

    try std.testing.expect(modified.contains("401k"));
}

test "findModifiedAccounts: detects removed lots" {
    const allocator = std.testing.allocator;

    var old_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
        .{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Acct A" },
    };
    var new_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
        // VTI removed
    };

    const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
    const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };

    var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
    defer modified.deinit();

    try std.testing.expect(modified.contains("Acct A"));
}

test "findModifiedAccounts: no changes" {
    const allocator = std.testing.allocator;

    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
    };

    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var modified = try findModifiedAccounts(allocator, pf, pf);
    defer modified.deinit();

    try std.testing.expectEqual(@as(u32, 0), modified.count());
}

// ── collectStaleManualPrices ─────────────────────────────────

test "collectStaleManualPrices: dated stale stock lot is flagged with account + age" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);

    try std.testing.expectEqual(@as(usize, 1), stale.items.len);
    try std.testing.expectEqualStrings("Sample 529", stale.items[0].account);
    try std.testing.expectEqualStrings("F529A", stale.items[0].symbol);
    try std.testing.expectEqual(@as(?i32, 31), stale.items[0].age_days);
}

test "collectStaleManualPrices: price within threshold is skipped" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 30) },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}

test "collectStaleManualPrices: undated manual price is always flagged" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = null },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), stale.items.len);
    try std.testing.expectEqual(@as(?Date, null), stale.items[0].price_date);
    try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
}

test "collectStaleManualPrices: CDs and cash are excluded" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2020, 1, 1) },
        .{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cash, .price = 5000.0, .price_date = Date.fromYmd(2020, 1, 1) },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}

test "collectStaleManualPrices: closed lot is excluded" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2020, 1, 1), .close_date = Date.fromYmd(2026, 1, 1), .close_price = 26.0 },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}

test "collectStaleManualPrices: lot without a manual price is skipped" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}

// ── collectStaleHarvested ────────────────────────────────────

/// Build an in-memory AccountMap from a literal SRF string, mirroring
/// the helper in `analytics/analysis.zig`. Exercises the real parse
/// path so these tests can't drift from how `accounts.srf` is read.
fn testAccountMap(comptime data: []const u8) !analysis.AccountMap {
    return analysis.parseAccountsFile(std.testing.allocator, data);
}

test "collectStaleHarvested: no account declares harvested -> empty" {
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample Brokerage,tax_type::taxable
    );
    defer am.deinit();

    var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
    try std.testing.expectEqual(@as(usize, 0), countHarvestedAccounts(am));
}

test "collectStaleHarvested: figure within the window is fresh" {
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24
    );
    defer am.deinit();

    // 31 days old.
    var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
    // ...but it IS declared, so the section still prints "(none)".
    try std.testing.expectEqual(@as(usize, 1), countHarvestedAccounts(am));
}

test "collectStaleHarvested: exactly at the threshold is still fresh" {
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-01-01
    );
    defer am.deinit();

    const on = Date.fromYmd(2026, 1, 1);
    const at_threshold = on.addDays(@intCast(harvested_stale_days));
    var stale = try collectStaleHarvested(allocator, am, at_threshold);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);

    // One day later it trips.
    var stale2 = try collectStaleHarvested(allocator, am, at_threshold.addDays(1));
    defer stale2.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), stale2.items.len);
    try std.testing.expectEqual(@as(?i32, @intCast(harvested_stale_days + 1)), stale2.items[0].age_days);
    try std.testing.expectEqualStrings("", stale2.items[0].note);
}

test "collectStaleHarvested: undated figure is flagged with a null age" {
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300
    );
    defer am.deinit();

    var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), stale.items.len);
    try std.testing.expectEqualStrings("Sample Tax Loss", stale.items[0].account);
    try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
    // An undated figure never renders, so it earns the retired note too.
    try std.testing.expect(stale.items[0].note.len > 0);
}

test "collectStaleHarvested: future-dated figure is left to doctor" {
    // A future `harvested_date` is a config typo, and `zfin doctor`
    // already reports it as one. Surfacing the same mistake here in
    // staleness clothing would double-nag for a single error.
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2027-06-24
    );
    defer am.deinit();

    var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}

test "collectStaleHarvested: note agrees with fmtHarvestAnnotation across the 12-month edge" {
    // The load-bearing invariant: the "no longer displayed" message must
    // be true. The note is derived from the formatter rather than from a
    // day count precisely so the two can't disagree at a leap-year
    // boundary. Walk a window that straddles the cutoff and assert
    // agreement on every day.
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2024-02-29
    );
    defer am.deinit();

    const on = Date.fromYmd(2024, 2, 29);
    var offset: i32 = 300;
    while (offset <= 430) : (offset += 1) {
        const as_of = on.addDays(offset);
        var stale = try collectStaleHarvested(allocator, am, as_of);
        defer stale.deinit(allocator);
        try std.testing.expectEqual(@as(usize, 1), stale.items.len); // always past 90d here

        // SAFETY: immediately overwritten by fmtHarvestAnnotation.
        var buf: [fmt.harvest_annotation_max_len]u8 = undefined;
        const rendered = fmt.fmtHarvestAnnotation(&buf, 45_300, on, as_of);
        try std.testing.expectEqual(rendered.len == 0, stale.items[0].note.len > 0);
    }
}

test "collectStaleHarvested: note is set once the annotation retires" {
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2024-06-24
    );
    defer am.deinit();

    // ~400 days later: stale AND no longer rendered.
    var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2025, 7, 29));
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), stale.items.len);
    try std.testing.expectEqual(@as(?i32, 400), stale.items[0].age_days);
    try std.testing.expectEqualStrings(" - no longer displayed", stale.items[0].note);
}

test "staleDeclaredLessThan: undated first, then oldest, then account name" {
    const undated_b: StaleDeclared = .{ .account = "B", .age_days = null };
    const undated_a: StaleDeclared = .{ .account = "A", .age_days = null };
    const old: StaleDeclared = .{ .account = "C", .age_days = 400 };
    const newer: StaleDeclared = .{ .account = "D", .age_days = 100 };

    // Undated outranks any dated entry, however old.
    try std.testing.expect(staleDeclaredLessThan({}, undated_b, old));
    try std.testing.expect(!staleDeclaredLessThan({}, old, undated_b));
    // Among dated, older first.
    try std.testing.expect(staleDeclaredLessThan({}, old, newer));
    try std.testing.expect(!staleDeclaredLessThan({}, newer, old));
    // Ties break on account name, so output is stable.
    try std.testing.expect(staleDeclaredLessThan({}, undated_a, undated_b));
    try std.testing.expect(!staleDeclaredLessThan({}, undated_b, undated_a));
}

test "collectStaleHarvested + sort: mixed accounts come out worst-first" {
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample Fresh,tax_type::taxable,harvested:num:1000,harvested_date::2026-07-01
        \\account::Sample Mild,tax_type::taxable,harvested:num:2000,harvested_date::2026-01-01
        \\account::Sample Ancient,tax_type::taxable,harvested:num:3000,harvested_date::2024-01-01
        \\account::Sample Undated,tax_type::taxable,harvested:num:4000
        \\account::Sample None,tax_type::taxable
    );
    defer am.deinit();

    var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    std.mem.sort(StaleDeclared, stale.items, {}, staleDeclaredLessThan);

    try std.testing.expectEqual(@as(usize, 4), countHarvestedAccounts(am));
    try std.testing.expectEqual(@as(usize, 3), stale.items.len); // Fresh and None excluded
    try std.testing.expectEqualStrings("Sample Undated", stale.items[0].account);
    try std.testing.expectEqualStrings("Sample Ancient", stale.items[1].account);
    try std.testing.expectEqualStrings("Sample Mild", stale.items[2].account);
    // Only the ancient one has retired from display.
    try std.testing.expect(stale.items[1].note.len > 0);
    try std.testing.expectEqualStrings("", stale.items[2].note);
}

// ── collectStaleTaxMix ───────────────────────────────────────

test "collectStaleTaxMix: no account declares a mix -> empty" {
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample 401k,tax_type::traditional
        \\account::Sample Brokerage,tax_type::taxable
    );
    defer am.deinit();

    var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
    try std.testing.expectEqual(@as(usize, 0), countTaxMixAccounts(am));
}

test "collectStaleTaxMix: a mix within the window is fresh" {
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2026-07-01
    );
    defer am.deinit();

    var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
    try std.testing.expectEqual(@as(usize, 1), countTaxMixAccounts(am));
}

test "collectStaleTaxMix: exactly at the threshold is still fresh" {
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2026-01-01
    );
    defer am.deinit();

    const on = Date.fromYmd(2026, 1, 1);
    const at_threshold = on.addDays(@intCast(tax_mix_stale_days));
    var stale = try collectStaleTaxMix(allocator, am, at_threshold);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);

    // One day later it trips.
    var stale2 = try collectStaleTaxMix(allocator, am, at_threshold.addDays(1));
    defer stale2.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), stale2.items.len);
    try std.testing.expectEqual(@as(?i32, @intCast(tax_mix_stale_days + 1)), stale2.items[0].age_days);
    // No "no longer displayed" counterpart: a stale mix keeps applying.
    try std.testing.expectEqualStrings("", stale2.items[0].note);
}

test "collectStaleTaxMix: an undated mix is flagged but never retires" {
    // The load-bearing difference from `harvested`: an undated or ancient
    // tax mix still feeds the By Tax Type breakdown. It gets a null age so
    // it sorts first, but never a note claiming it stopped applying.
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4
    );
    defer am.deinit();

    var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), stale.items.len);
    try std.testing.expectEqualStrings("Sample 401k", stale.items[0].account);
    try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
    try std.testing.expectEqualStrings("", stale.items[0].note);

    // Years later, still applying and still noteless.
    var ancient = try collectStaleTaxMix(allocator, am, Date.fromYmd(2030, 7, 25));
    defer ancient.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), ancient.items.len);
    try std.testing.expectEqualStrings("", ancient.items[0].note);
    try std.testing.expectApproxEqAbs(
        @as(f64, 0.224),
        am.entries[0].taxMix().weightOf(.roth),
        1e-12,
    );
}

test "collectStaleTaxMix: future-dated mix is left to doctor" {
    // Mirrors `collectStaleHarvested`: a future date is a config typo that
    // `zfin doctor` already reports, so ageing it here would double-report.
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2027-01-01
    );
    defer am.deinit();

    var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}

test "collectStaleTaxMix: a rejected mix is still collected" {
    // The user meant to declare a mix, so the account belongs in the
    // report even though the declaration didn't take. Dropping it would
    // hide the account from the staleness nag AND leave doctor as the
    // only mention.
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:140
    );
    defer am.deinit();

    try std.testing.expectEqual(@as(usize, 1), countTaxMixAccounts(am));
    var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), stale.items.len);
    try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
}

test "collectStaleTaxMix + sort: mixed accounts come out worst-first" {
    const allocator = std.testing.allocator;
    var am = try testAccountMap(
        \\#!srfv1
        \\account::Sample Fresh,tax_type::traditional,tax_mix_roth:num:10,tax_mix_date::2026-07-01
        \\account::Sample Mild,tax_type::traditional,tax_mix_roth:num:20,tax_mix_date::2026-01-01
        \\account::Sample Ancient,tax_type::traditional,tax_mix_roth:num:30,tax_mix_date::2024-01-01
        \\account::Sample Undated,tax_type::traditional,tax_mix_roth:num:40
        \\account::Sample None,tax_type::traditional
    );
    defer am.deinit();

    var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
    defer stale.deinit(allocator);
    std.mem.sort(StaleDeclared, stale.items, {}, staleDeclaredLessThan);

    try std.testing.expectEqual(@as(usize, 4), countTaxMixAccounts(am));
    try std.testing.expectEqual(@as(usize, 3), stale.items.len); // Fresh and None excluded
    try std.testing.expectEqualStrings("Sample Undated", stale.items[0].account);
    try std.testing.expectEqualStrings("Sample Ancient", stale.items[1].account);
    try std.testing.expectEqualStrings("Sample Mild", stale.items[2].account);
}

// ── printStaleDeclaredSection ────────────────────────────────

test "printStaleDeclaredSection: no rows emits a reassuring (none)" {
    // "You use this feature and everything is current" must be visibly
    // different from the section being absent entirely.
    var buf: [1024]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);

    try printStaleDeclaredSection(&w, false, &.{}, 90, "Stale widgets (>90 days)", "no widget_date set");

    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "Stale widgets (>90 days)") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "(none)") != null);
}

test "printStaleDeclaredSection: dated rows print an age, undated print the fallback" {
    var buf: [2048]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);

    const rows = [_]StaleDeclared{
        .{ .account = "Sample Undated", .age_days = null },
        .{ .account = "Sample Ancient", .age_days = 400, .note = " - no longer displayed" },
        .{ .account = "Sample Mild", .age_days = 120 },
    };
    try printStaleDeclaredSection(&w, false, &rows, 90, "Stale harvested figures", "no harvested_date set");

    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "Stale harvested figures") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "(none)") == null);
    // Undated rows say why instead of showing a nonsense age.
    try std.testing.expect(std.mem.indexOf(u8, out, "Sample Undated") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "no harvested_date set") != null);
    // Dated rows show the age, and carry the note when one is set.
    try std.testing.expect(std.mem.indexOf(u8, out, "last updated 400 days ago - no longer displayed") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "last updated 120 days ago\n") != null);
}

test "printStaleDeclaredSection: an empty note appends nothing" {
    // Regression guard for the tax-mix caller, which never sets a note:
    // the age must not pick up stray trailing text.
    var buf: [1024]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);

    const rows = [_]StaleDeclared{.{ .account = "Sample 401k", .age_days = 577 }};
    try printStaleDeclaredSection(&w, false, &rows, tax_mix_stale_days, "Stale tax-mix figures", "no tax_mix_date set (mix still applies)");

    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "last updated 577 days ago\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "no longer displayed") == null);
}

// ── findPriceDateMismatches ──────────────────────────────────

test "findPriceDateMismatches: price moved but date unchanged is flagged" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var head = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    var work = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
    const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };

    var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), m.items.len);
    try std.testing.expectEqualStrings("F529A", m.items[0].symbol);
    try std.testing.expectEqual(@as(f64, 25.0), m.items[0].old_price);
    try std.testing.expectEqual(@as(f64, 27.5), m.items[0].new_price);
}

test "findPriceDateMismatches: price moved AND date moved (back-date) is not flagged" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var head = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    var work = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 29) },
    };
    const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
    const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
    var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), m.items.len);
}

test "findPriceDateMismatches: unchanged price is not flagged" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var m = try findPriceDateMismatches(allocator, pf, pf, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), m.items.len);
}

test "findPriceDateMismatches: newly-added lot is not flagged" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var head = [_]portfolio_mod.Lot{};
    var work = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
    const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
    var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), m.items.len);
}

test "findPriceDateMismatches: undated working lot is left to the stale-price section" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var head = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    var work = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = null },
    };
    const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
    const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
    var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), m.items.len);
}

test "findPriceDateMismatches: CD price change is ignored" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var head = [_]portfolio_mod.Lot{
        .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    var work = [_]portfolio_mod.Lot{
        .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10100.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
    const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
    var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), m.items.len);
}

test "UpdateCadence label" {
    try std.testing.expectEqualStrings("weekly", analysis.UpdateCadence.weekly.label());
    try std.testing.expectEqualStrings("monthly", analysis.UpdateCadence.monthly.label());
    try std.testing.expectEqualStrings("quarterly", analysis.UpdateCadence.quarterly.label());
    try std.testing.expectEqualStrings("none", analysis.UpdateCadence.none.label());
}

test "printLargeLotWarning: cash destination emits dest_lot::cash template" {
    var buf: [1024]u8 = undefined;
    var writer = std.Io.Writer.fixed(&buf);

    const lot: contributions.UnmatchedLargeLot = .{
        .account = "Acct A",
        .symbol = "",
        .security_type = .cash,
        .value = 50_000.0,
        .open_date = Date.fromYmd(2026, 5, 10),
    };

    try printLargeLotWarning(&writer, lot, false); // color=false -> no ANSI escapes
    const output = writer.buffered();

    // Header line with account + value + date.
    try std.testing.expect(std.mem.indexOf(u8, output, "Acct A: new CASH lot cash") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "+$50,000.00") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null);

    // Template line with the expected SRF shape.
    try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000.00,from::<SOURCE>,to::Acct A,dest_lot::cash") != null);
}

test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" {
    var buf: [1024]u8 = undefined;
    var writer = std.Io.Writer.fixed(&buf);

    const lot: contributions.UnmatchedLargeLot = .{
        .account = "Acct B",
        .symbol = "SYM",
        .security_type = .stock,
        .value = 25_000.0,
        .open_date = Date.fromYmd(2026, 5, 3),
    };

    try printLargeLotWarning(&writer, lot, false);
    const output = writer.buffered();

    try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000.00,from::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
}

test "printLargeLotWarning: cents are preserved in template" {
    // Regression: previously the template rounded to whole dollars,
    // so a $73,158.33 lot suggested `amount:num:73158`. Pasting that
    // verbatim into transaction_log.srf records a fictitious amount
    // and (with $1 matcher tolerance) only barely pairs. The fix
    // prints two-decimal precision so the suggested record exactly
    // describes the lot it's offering to attribute.
    var buf: [1024]u8 = undefined;
    var writer = std.Io.Writer.fixed(&buf);

    const lot: contributions.UnmatchedLargeLot = .{
        .account = "Sample Trust",
        .symbol = "",
        .security_type = .cash,
        .value = 73_158.33,
        .open_date = Date.fromYmd(2026, 5, 20),
    };

    try printLargeLotWarning(&writer, lot, false);
    const output = writer.buffered();

    try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158.33") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158,") == null);
}

test "strLessThan: orders strings lexicographically" {
    try std.testing.expect(strLessThan({}, "AAPL", "MSFT"));
    try std.testing.expect(!strLessThan({}, "MSFT", "AAPL"));
    try std.testing.expect(!strLessThan({}, "AAPL", "AAPL"));
    try std.testing.expect(strLessThan({}, "AAPL", "AAPLE"));
}

test "lotToString: stock lot includes symbol, shares, date" {
    const allocator = std.testing.allocator;
    const lot = portfolio_mod.Lot{
        .symbol = "AAPL",
        .shares = 100,
        .open_date = Date.fromYmd(2024, 3, 15),
        .open_price = 150.50,
    };
    const s = try lotToString(allocator, lot);
    defer allocator.free(s);
    try std.testing.expect(std.mem.indexOf(u8, s, "AAPL") != null);
    try std.testing.expect(std.mem.indexOf(u8, s, "100") != null);
    try std.testing.expect(std.mem.indexOf(u8, s, "2024-03-15") != null);
}

test "staleLessThan: orders by account, then symbol" {
    const a = StaleManualPrice{ .account = "Sample IRA", .symbol = "AAPL", .note = null, .price = 1, .price_date = null, .age_days = null };
    const b = StaleManualPrice{ .account = "Sample IRA", .symbol = "MSFT", .note = null, .price = 1, .price_date = null, .age_days = null };
    const c = StaleManualPrice{ .account = "Sample Roth", .symbol = "AAA", .note = null, .price = 1, .price_date = null, .age_days = null };

    // Same account -> symbol breaks the tie.
    try std.testing.expect(staleLessThan({}, a, b));
    try std.testing.expect(!staleLessThan({}, b, a));
    // Different account -> account wins regardless of symbol.
    try std.testing.expect(staleLessThan({}, b, c));
    try std.testing.expect(!staleLessThan({}, c, b));
}

test "mismatchLessThan: orders by account, then symbol" {
    const d = Date.fromYmd(2026, 1, 1);
    const a = PriceDateMismatch{ .account = "Sample IRA", .symbol = "AAPL", .old_price = 1, .new_price = 2, .price_date = d };
    const b = PriceDateMismatch{ .account = "Sample IRA", .symbol = "MSFT", .old_price = 1, .new_price = 2, .price_date = d };
    const c = PriceDateMismatch{ .account = "Sample Roth", .symbol = "AAA", .old_price = 1, .new_price = 2, .price_date = d };

    try std.testing.expect(mismatchLessThan({}, a, b));
    try std.testing.expect(!mismatchLessThan({}, b, a));
    try std.testing.expect(mismatchLessThan({}, b, c));
    try std.testing.expect(!mismatchLessThan({}, c, b));
}

test "findPriceDateMismatches: duplicate HEAD identity collapses to one entry" {
    const allocator = std.testing.allocator;

    // Two committed lots share an identity key (same symbol/account/
    // open_date/open_price) -> the second is ambiguous and skipped, but
    // the first still anchors the comparison.
    var committed_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) },
        .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) },
    };
    const committed = portfolio_mod.Portfolio{ .lots = &committed_lots, .allocator = allocator };

    // Working tree bumps the price but leaves price_date untouched.
    var working_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 160, .price_date = Date.fromYmd(2026, 1, 1) },
    };
    const working = portfolio_mod.Portfolio{ .lots = &working_lots, .allocator = allocator };

    var mismatches = try findPriceDateMismatches(allocator, committed, working, Date.fromYmd(2026, 6, 1));
    defer mismatches.deinit(allocator);

    // Dup HEAD identity collapsed to one; the price-without-date bump still flags.
    try std.testing.expectEqual(@as(usize, 1), mismatches.items.len);
    try std.testing.expectEqualStrings("AAPL", mismatches.items[0].symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 150), mismatches.items[0].old_price, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 160), mismatches.items[0].new_price, 0.01);
}

test "runHygieneCheck: Section 7 flags an un-opted-in symbol's split, not an opted-in one" {
    const allocator = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // NVDA: held across its 2024 split, NOT opted in -> should flag.
    // AMZN: held across its 2022 split, opted in via metadata -> should not.
    try tmp.dir.writeFile(io, .{
        .sub_path = "portfolio.srf",
        .data =
        \\#!srfv1
        \\symbol::NVDA,shares:num:100,open_date::2020-01-01,open_price:num:40.00,account::Sample Brokerage
        \\symbol::AMZN,shares:num:30,open_date::2019-01-01,open_price:num:90.00,account::Sample Brokerage
        \\
        ,
    });
    // accounts.srf must exist and parse (an empty map is fine).
    try tmp.dir.writeFile(io, .{ .sub_path = "accounts.srf", .data = "#!srfv1\n" });
    // Per-symbol opt-in: AMZN yes, NVDA absent (not opted in).
    try tmp.dir.writeFile(io, .{
        .sub_path = "metadata.srf",
        .data = "#!srfv1\nsymbol::AMZN,splits_current_through::2024-01-01\n",
    });

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const dir = path_buf[0..dir_len];

    // Seed the split cache: NVDA 10:1 (2024-06-10), AMZN 20:1 (2022-06-06).
    var store = zfin.cache.Store.init(io, allocator, dir);
    var nvda = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }};
    store.write(zfin.Split, "NVDA", nvda[0..], .{ .seconds = zfin.cache.Ttl.splits });
    var amzn = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2022, 6, 6), .numerator = 20, .denominator = 1 }};
    store.write(zfin.Split, "AMZN", amzn[0..], .{ .seconds = zfin.cache.Ttl.splits });

    // No API keys / no server -> hermetic (fetches can't reach a network).
    var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
    defer svc.deinit();

    var env = try std.testing.environ.createMap(allocator);
    defer env.deinit();

    const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
    defer allocator.free(pf_path);

    var aw: std.Io.Writer.Allocating = .init(allocator);
    defer aw.deinit();

    try runHygieneCheck(io, allocator, &env, &svc, pf_path, 3, false, zfin.Date.fromYmd(2026, 1, 1), 1_767_225_600, false, .never, &aw.writer);

    const output = aw.written();
    try std.testing.expect(std.mem.indexOf(u8, output, "Portfolio hygiene") != null);

    // Assert against Section 7 specifically (it's the last section, so
    // slice from its header to end) - robust even if NVDA/AMZN surface
    // in an earlier section. The un-opted-in NVDA is listed; the
    // opted-in AMZN is not.
    const sec6_start = std.mem.indexOf(u8, output, "Unhandled stock splits") orelse return error.Section6Missing;
    const sec6 = output[sec6_start..];
    try std.testing.expect(std.mem.indexOf(u8, sec6, "NVDA") != null);
    try std.testing.expect(std.mem.indexOf(u8, sec6, "AMZN") == null);
}

/// Format a two-account portfolio.srf for the git-history test. Only
/// `shares` varies between revisions, which is enough for
/// findModifiedAccounts to flag the account.
fn testPortfolioSrf(buf: []u8, ira_shares: []const u8, roth_shares: []const u8) ![]const u8 {
    return std.fmt.bufPrint(buf, "#!srfv1\n" ++
        "symbol::VOO,shares:num:{s},open_date::2026-01-01,open_price:num:1.00,account::Sample IRA\n" ++
        "symbol::BND,shares:num:{s},open_date::2026-01-01,open_price:num:1.00,account::Sample Roth\n", .{ ira_shares, roth_shares });
}

test "findLastUpdateTimestamps: resolves an account changed in a non-newest commit" {
    const allocator = std.testing.allocator;
    const io = std.testing.io;

    // Skip if `git` isn't on PATH (CI sandbox without git).
    if (!test_git.available(allocator)) return;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const dir = path_buf[0..dir_len];

    var buf: [512]u8 = undefined;

    // Commit A (oldest): IRA=100, Roth=50.
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = try testPortfolioSrf(&buf, "100", "50") });
    try test_git.run(allocator, dir, null, &.{ "init", "-q" });
    try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" });
    try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" });
    try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
    try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" });
    try test_git.run(allocator, dir, "2026-01-10T12:00:00", &.{ "commit", "-q", "-m", "A" });

    // Commit B: IRA changes to 200 (Roth unchanged). This is the change
    // the old 2x-cadence lookback window could exclude, leaving IRA as
    // "no update history found".
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = try testPortfolioSrf(&buf, "200", "50") });
    try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" });
    try test_git.run(allocator, dir, "2026-02-15T12:00:00", &.{ "commit", "-q", "-m", "B" });

    // Commit C (newest): Roth changes to 60 (IRA unchanged).
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = try testPortfolioSrf(&buf, "200", "60") });
    try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" });
    try test_git.run(allocator, dir, "2026-07-01T12:00:00", &.{ "commit", "-q", "-m", "C" });

    var env = try std.testing.environ.createMap(allocator);
    defer env.deinit();

    const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
    defer allocator.free(pf_path);

    const ri = try git.findRepo(io, allocator, &env, pf_path);
    defer {
        allocator.free(ri.root);
        allocator.free(ri.rel_path);
    }

    // Authoritative committer timestamps, newest-first: [0]=C, [1]=B, [2]=A.
    const commits = try git.listCommitsTouching(io, allocator, &env, ri.root, ri.rel_path, null);
    defer git.freeCommitTouches(allocator, commits);
    try std.testing.expectEqual(@as(usize, 3), commits.len);

    var all_accounts = std.StringHashMap(void).init(allocator);
    defer all_accounts.deinit();
    try all_accounts.put("Sample IRA", {});
    try all_accounts.put("Sample Roth", {});

    var out = std.StringHashMap(i64).init(allocator);
    defer out.deinit();
    try findLastUpdateTimestamps(io, allocator, &env, ri, &all_accounts, &out);

    // Both accounts resolve. Critically "Sample IRA" - last changed in
    // commit B, NOT the newest commit - is found rather than absent.
    const ira_ts = out.get("Sample IRA") orelse return error.IraUnresolved;
    const roth_ts = out.get("Sample Roth") orelse return error.RothUnresolved;

    // Each change attributed to the commit that actually made it.
    try std.testing.expectEqual(commits[1].timestamp, ira_ts); // B
    try std.testing.expectEqual(commits[0].timestamp, roth_ts); // C
    try std.testing.expect(ira_ts < roth_ts);
}
