//! `zfin contributions` — show money added to the portfolio since the
//! last recorded state in git.
//!
//! Four modes:
//!   - No flags (default):
//!       - dirty working tree:  HEAD  vs  working copy
//!       - clean working tree:  HEAD~1 vs HEAD           (review last commit)
//!   - `--since <DATE>`:        commit-at-or-before(DATE) vs HEAD (or working copy if dirty)
//!   - `--since <D1> --until <D2>`: commit-at-or-before(D1) vs commit-at-or-before(D2)
//!   - `--until <DATE>` alone:  rejected; window is ambiguous
//!
//! The `--since` / `--until` flags use `commitAtOrBeforeDate` in
//! `src/git.zig`, which runs `git log --until=<DATE> -1 -- portfolio.srf`
//! to pick the most recent commit at or before the requested date.
//! Relative forms (1M, 3Q, 1Y) are also accepted — parsed by
//! `cli.parseAsOfDate` and resolved to an absolute date before being
//! passed in.
//!
//! ## Classification matrix
//!
//! Every lot-level change gets exactly one `ChangeKind` assigned at
//! diff time in `computeReport`. Downstream consumers (section printer,
//! per-account summary, `computeAttributionSpec` used by `compare`) all
//! read the pre-classified kinds — there is no post-hoc reclassification
//! in any consumer. Single point of truth so the grand total in
//! `zfin contributions` and the attribution line in `zfin compare`
//! always agree over the same window.
//!
//! ### Base classifications (same for every account)
//!
//!   - `new_stock`        — stock lot appeared (drip::false)
//!   - `new_drip_lot`     — stock lot appeared (drip::true → confirmed DRIP)
//!   - `new_cash`         — cash lot appeared (fresh line, not a balance bump)
//!   - `new_cd`           — CD opened
//!   - `new_option`       — option opened
//!   - `drip_confirmed`   — same-key drip::true stock lot, Δshares > 0
//!   - `rollup_delta`     — same-key drip::false stock lot, Δshares > 0
//!                          (ambiguous: DRIP or contribution)
//!   - `drip_negative`    — same-key stock lot, Δshares < 0 (unusual)
//!   - `cash_delta`       — same-key cash lot, Δshares, default noise
//!   - `cd_matured`       — CD disappeared, maturity_date ≤ today
//!   - `cd_removed_early` — CD disappeared, maturity_date > today
//!   - `lot_removed`      — stock/cash/option lot disappeared
//!   - `lot_edited`       — secondary-key match across broken strict keys
//!                          (open_date/open_price/symbol-alias rewrite)
//!   - `price_only`       — same-key, only the `price::` field changed
//!   - `flagged`          — any other edit shape (maturity_date change, etc.)
//!
//! ### Cash-account opt-in (`cash_is_contribution::true` in accounts.srf)
//!
//! Most cash-account activity is internal flow — DRIP cash legs,
//! dividend credits, CD coupons, settlement sweeps — which is why
//! `cash_delta` is noise by default. But for payroll-adjacent
//! accounts (ESPP accrual, direct 401k cash deposits, HSA employer
//! contributions), a positive cash movement IS the contribution.
//! The `cash_is_contribution::true` flag in `accounts.srf` opts an
//! account into "positive cash_delta = real contribution" semantics.
//!
//! Opted-IN accounts:
//!
//! | Scenario                      | Kind               | Section              | In Grand Total | In Attribution |
//! |-------------------------------|--------------------|----------------------|:--------------:|:--------------:|
//! | Brand-new cash lot appears    | `new_cash`         | New contributions    |      yes       |      yes       |
//! | Existing cash, balance up     | `cash_contribution`| New contributions    |      yes       |      yes       |
//! | Existing cash, balance down   | `cash_delta`       | Cash deltas (raw)    |      no        |      no        |
//! | Cash lot fully removed        | `lot_removed`      | Flagged for review   |      no        |      no        |
//!
//! Opted-OUT accounts (default):
//!
//! | Scenario                      | Kind               | Section              | In Grand Total | In Attribution |
//! |-------------------------------|--------------------|----------------------|:--------------:|:--------------:|
//! | Brand-new cash lot appears    | `new_cash`         | New contributions    |      yes       |      yes       |
//! | Existing cash, balance moves  | `cash_delta`       | Cash deltas (raw)    |      no        |      no        |
//! | Cash lot fully removed        | `lot_removed`      | Flagged for review   |      no        |      no        |
//!
//! The asymmetry on negative Δ for opted-in accounts is intentional:
//! a withdrawal from ESPP/HSA is rare and semantically different
//! from "a contribution of negative money." The branch for that
//! (withdraw-as-negative-contribution) is one `if (delta < 0)` in
//! `computeReport` if/when the need arises.
//!
//! ### Direct-indexing accounts (`direct_indexing::true` in accounts.srf)
//!
//! Direct-indexing proxies hold a basket of underlying stocks
//! tracked as a single benchmark via `ticker::`. The basket
//! naturally drifts against the benchmark week-to-week (tracking
//! error) and the user rebalances periodically — producing small
//! share-count adjustments that aren't real money flow.
//!
//! When a lot in a flagged account goes through `detectEdits`
//! (strict-key broken but secondary key matches), the residual
//! share-delta tolerance is loosened from 0.01% to 1%. The identity
//! match still collapses to `lot_edited`; residuals under 1% are
//! suppressed entirely instead of surfacing as `rollup_delta` /
//! `drip_negative`. Real contributions to direct-indexing accounts
//! (e.g. a $100k buy-in on a multi-million basket = ~1.2%) still
//! surface because they're above the tolerance.
//!
//! The `zfin audit` command uses the same flag for its companion
//! behavior: emit a `price_ratio` adjustment suggestion for these
//! lots even though their ratio is 1.0, bridging the brokerage-vs-
//! portfolio value gap that accumulates from tracking error.
//!
//! ### Transfer reclassification (`transaction_log.srf`)
//!
//! Records in `transaction_log.srf` flag internal money movement
//! between accounts the user owns. When present, the matcher runs
//! after Pass 1/Pass 2 and flips destination/source Changes to
//! dedicated transfer kinds that contribute $0 to attribution —
//! fixing the double-count where a transfer's destination lot would
//! otherwise read as a fresh external contribution.
//!
//! Four reclassification kinds emerge from the matcher:
//!
//!   - `transfer_in`         — destination lot (or cash-dest pool)
//!                              fully attributed to a transfer.
//!                              Replaces the base `new_*` kind.
//!   - `partial_transfer_in` — destination lot partially attributed.
//!                              Residual (`value() − transfer_attributed`)
//!                              still counts toward attribution as
//!                              "pre-existing cash that funded the
//!                              rest of the lot."
//!   - `transfer_out`        — sending-side match (negative
//!                              `cash_delta` or `lot_removed`). Best-
//!                              effort: a missing `from` side is
//!                              silent, not an error (the sending
//!                              account may not be in portfolio.srf).
//!   - `unmatched_transfer`  — record that couldn't be matched.
//!                              Surfaces in the Flagged section with
//!                              a reason string; the transfer amount
//!                              stays out of attribution either way.
//!
//! | Scenario                                      | Kind                   | Section          | In Grand Total | In Attribution |
//! |-----------------------------------------------|------------------------|------------------|:--------------:|:--------------:|
//! | Lot fully funded by transfer                  | `transfer_in`          | Transfers        |      no        |      no        |
//! | Lot partially funded by transfer              | `partial_transfer_in`  | New contributions (residual) + Transfers | residual |   residual   |
//! | Sending-side `lot_removed` / `cash_delta`     | `transfer_out`         | Transfers        |      no        |      no        |
//! | Record with no match (bad dest, in_kind, …)   | `unmatched_transfer`   | Flagged          |      no        |      no        |
//!
//! Cash-destination records don't flip the original `cash_delta` /
//! `new_cash` Change (a single cash delta can be drained by
//! multiple records, which `kind` can't represent). Instead the
//! matcher appends a synthetic `transfer_in` Change for the
//! Transfers section and records the attributed amount in
//! `Report.cash_attributed_by_account`, which the per-account totals
//! and attribution summary subtract from cash-side contributions.
//!
//! ### Which records does the matcher consider?
//!
//! The matcher runs against the records that are NEW in the after-side
//! `transaction_log.srf` relative to the before-side. Concretely:
//! `prepareReport` loads the file at both `before_rev` and the
//! after-side (working copy or `after_rev`), parses each, and passes
//! the set difference (`after - before`, by `TransferRecord.eql`) to
//! the matcher. See `diffTransferLogs`.
//!
//! Why not filter by `transfer::DATE` against the diff's git
//! timestamp window? Because the user's natural workflow is to
//! record a transfer days, weeks, or months after the actual
//! transaction date. A back-dated record is the rule, not the
//! exception. The original date-window filter rejected those
//! back-dated records and produced "unmatched contribution" noise
//! the user couldn't quiet without changing the record's date
//! to fall inside the diff window — a workaround that destroyed
//! the historical accuracy of `transaction_log.srf`.
//!
//! Editing a previously-recorded transfer (e.g. fixing a typo in
//! `from::`) produces an old-form record in `before` and a new-form
//! record in `after`. The old form silently drops out (its diff cycle
//! is over); the new form is treated as a fresh record and re-pairs
//! against the current diff. If no matching destination Change exists
//! in the current diff (because the lot was added in an earlier
//! commit), the record surfaces as `unmatched_transfer` — accept the
//! noise or undo the edit.
//!
//! The matcher also composes with `direct_indexing::true`: a transfer
//! into a direct-indexing account matches normally on the destination
//! lot; subsequent tracking-error drift on that lot is still swallowed
//! by the direct-indexing tolerance. Different problems, different
//! passes.
//!
//! ## Other architecture notes
//!
//! Relies on: portfolio.srf being tracked in a git repo, and the `git`
//! executable existing on PATH. We never rely on comments; maturity is
//! decided from the Lot.maturity_date field, not from the file's form.

const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const git = @import("../git.zig");
const framework = @import("framework.zig");
const TimeRange = @import("TimeRange.zig");
const analysis = @import("../analytics/analysis.zig");
const transaction_log = @import("../models/transaction_log.zig");
const Money = @import("../Money.zig");
const Date = zfin.Date;
const Lot = zfin.Lot;
const LotType = @import("../models/portfolio.zig").LotType;

// ── Public entry point ───────────────────────────────────────

/// Resolved endpoints for the contributions diff: the before/after
/// commit range (from `git.resolveCommitRange`) plus the
/// human-readable label for the report header.
const Endpoints = struct {
    range: git.CommitRange,
    label: []const u8,
};

pub const ParsedArgs = struct {
    before: ?git.CommitSpec = null,
    after: ?git.CommitSpec = null,
};

pub const meta: framework.Meta = .{
    .name = "contributions",
    .group = .timeseries,
    .synopsis = "Show money added since the last recorded state in git",
    .help =
    \\Usage: zfin contributions [opts]
    \\
    \\Show contributions, withdrawals, and lot-level changes between
    \\two points in the portfolio's git history. Four modes:
    \\
    \\  No flags (default):
    \\    dirty working tree:  HEAD vs working copy
    \\    clean working tree:  HEAD~1 vs HEAD (review last commit)
    \\  --since <DATE>           commit-at-or-before(DATE) vs HEAD (or
    \\                           working copy when dirty)
    \\  --since <D1> --until <D2>  commit-at-or-before(D1) vs
    \\                             commit-at-or-before(D2)
    \\  --until <DATE> alone     rejected; window is ambiguous
    \\
    \\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
    \\
    \\Options:
    \\  --since <DATE>           Earliest side (resolves to commit-at-
    \\                           or-before).
    \\  --until <DATE>           Latest side. Pair with --since.
    \\  --commit-before <SPEC>   Pin the before commit directly. Same
    \\                           grammar as --commit-after, minus
    \\                           `working`. Useful when you committed
    \\                           after your review date.
    \\  --commit-after <SPEC>    Pin the after commit. SPEC accepts
    \\                           YYYY-MM-DD, relative (1W/1M/1Q/1Y),
    \\                           HEAD, HEAD~N, hex SHA, or `working`
    \\                           for the working copy.
    \\
    \\--since and --commit-before describe the same axis; pass at most
    \\one. Same for --until and --commit-after.
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{ DuplicateEndpoint, InvalidArg, MissingOpenDate, PrepareFailed, ResolveFailed, UnexpectedArg },
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    const io = ctx.io;
    const today = ctx.today;
    const allocator = ctx.allocator;

    const tr_result = TimeRange.parse(io, allocator, today, cmd_args, .{
        .accept_since = true,
        .accept_until = true,
        .accept_commit_before = true,
        .accept_commit_after = true,
    }) catch |err| switch (err) {
        error.MissingValue,
        error.InvalidValue,
        error.WorkingCopyOnBeforeSide,
        error.LiveNotAllowed,
        => return error.InvalidArg,
        error.DuplicateEndpoint, error.RepeatedFlag => return error.DuplicateEndpoint,
        error.OutOfMemory => return error.OutOfMemory,
    };
    defer allocator.free(tr_result.consumed);

    // Reject any tokens TimeRange didn't consume — contributions has
    // no other flags or positionals.
    var consumed_set = std.AutoHashMap(usize, void).init(allocator);
    defer consumed_set.deinit();
    for (tr_result.consumed) |idx| try consumed_set.put(idx, {});
    for (cmd_args, 0..) |a, i| {
        if (consumed_set.contains(i)) continue;
        cli.stderrPrint(io, "Error: unexpected argument to 'contributions': ");
        cli.stderrPrint(io, a);
        cli.stderrPrint(io, "\n");
        return error.UnexpectedArg;
    }

    // Translate Endpoint to CommitSpec. `--since` produces a date
    // endpoint; `--commit-before` a commit_spec endpoint. Map both
    // to the same CommitSpec union the existing run() expects.
    var parsed: ParsedArgs = .{};
    if (tr_result.range.before) |ep| switch (ep) {
        .date => |d| parsed.before = .{ .date_at_or_before = d },
        .commit_spec => |s| parsed.before = s,
        .live => unreachable,
    };
    if (tr_result.range.after) |ep| switch (ep) {
        .date => |d| parsed.after = .{ .date_at_or_before = d },
        .commit_spec => |s| parsed.after = s,
        .live => unreachable,
    };

    // Validate `--since on or before --until` ordering for the
    // date-only form, matching legacy behavior. We can only check
    // when BOTH sides are date_at_or_before (the commit-spec form
    // is opaque until git resolves it).
    if (parsed.before) |b| if (parsed.after) |a| {
        if (b == .date_at_or_before and a == .date_at_or_before) {
            if (b.date_at_or_before.days > a.date_at_or_before.days) {
                cli.stderrPrint(io, "Error: --since must be on or before --until.\n");
                return error.InvalidArg;
            }
        }
    };
    return parsed;
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    const svc = ctx.svc orelse return error.MissingDataService;
    const io = ctx.io;
    const allocator = ctx.allocator;
    const out = ctx.out;
    const color = ctx.color;
    const as_of = ctx.today;
    const before = parsed.before;
    const after = parsed.after;

    const pf = ctx.resolvePortfolioPath();
    defer pf.deinit(allocator);
    const portfolio_path = pf.path;

    return runImpl(io, allocator, ctx.environ_map, svc, portfolio_path, before, after, as_of, color, ctx.globals.refresh_policy, out);
}

fn runImpl(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    svc: *zfin.DataService,
    portfolio_path: []const u8,
    before: ?git.CommitSpec,
    after: ?git.CommitSpec,
    as_of: Date,
    color: bool,
    refresh: framework.RefreshPolicy,
    out: *std.Io.Writer,
) !void {
    // Arena for all transient allocations: git subprocess buffers, duped path
    // strings, the snapshot blobs, the symbol set, price map, and the Report
    // itself. Portfolio objects use the base allocator (they own their own
    // deinit). One defer cleans everything up at once.
    var arena_state = std.heap.ArenaAllocator.init(allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // Enforce the "an `after` with no `before` is ambiguous" rule at
    // the entry point so `resolveEndpoints`/`git.resolveCommitRangeSpec`
    // can assume the invariant. The legacy no-flag path passes both
    // as null and falls through to HEAD~1..HEAD / HEAD..WC.
    if (before == null and after != null) {
        cli.stderrPrint(io, "Error: --until / --commit-after requires --since / --commit-before.\n");
        return;
    }

    var ctx = prepareReport(io, allocator, arena, env, svc, portfolio_path, before, after, as_of, color, refresh, .verbose) catch return;
    defer ctx.deinit();

    try printReport(out, &ctx.report, ctx.endpoints.label, color);
    try out.flush();
}

/// Shared pipeline context: everything `run` and `computeAttributionSpec`
/// both need from the git-backed diff.
///
/// Owned fields split across two allocators:
///   - `before_pf` / `after_pf` use the base allocator (their own
///     `deinit` frees internals).
///   - `endpoints`, `report`, and the snapshot blobs live in the
///     supplied arena; the arena's own `deinit` cleans them up.
/// `deinit` releases only the base-allocator-owned pieces.
const ReportContext = struct {
    endpoints: Endpoints,
    before_pf: zfin.Portfolio,
    after_pf: zfin.Portfolio,
    report: Report,

    fn deinit(self: *ReportContext) void {
        self.before_pf.deinit();
        self.after_pf.deinit();
    }
};

const PrepareError = error{PrepareFailed};

/// Run the common pipeline — resolve endpoints, read both blobs,
/// parse both portfolios, fetch prices, build the report.
///
/// Shared between `run` (prints the report) and
/// `computeAttributionImpl` (aggregates totals). Centralizes the git
/// plumbing and the price-loading step; callers own their output
/// decisions.
///
/// Stderr output is gated by `verbosity`: `.verbose` is the `run`
/// path (user sees why things failed); `.silent` is the attribution
/// path (failure just means "no attribution line", don't nag).
fn prepareReport(
    io: std.Io,
    allocator: std.mem.Allocator,
    arena: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    svc: *zfin.DataService,
    portfolio_path: []const u8,
    before_spec: ?git.CommitSpec,
    after_spec: ?git.CommitSpec,
    as_of: Date,
    color: bool,
    refresh: framework.RefreshPolicy,
    verbosity: Verbosity,
) PrepareError!ReportContext {
    const repo = git.findRepo(io, arena, env, portfolio_path) catch |err| {
        if (verbosity == .verbose) {
            switch (err) {
                error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a git repo.\n"),
                error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n"),
                else => cli.stderrPrint(io, "Error locating git repo.\n"),
            }
        }
        return error.PrepareFailed;
    };

    const status = git.pathStatus(io, arena, env, repo.root, repo.rel_path) catch {
        if (verbosity == .verbose) cli.stderrPrint(io, "Error: could not determine git status of portfolio.srf.\n");
        return error.PrepareFailed;
    };
    if (status == .untracked) {
        if (verbosity == .verbose) cli.stderrPrint(io, "Error: portfolio.srf is not tracked in git. Add and commit it first.\n");
        return error.PrepareFailed;
    }
    const dirty = status == .modified;

    const endpoints = resolveEndpoints(io, arena, env, repo, before_spec, after_spec, dirty, verbosity) catch return error.PrepareFailed;

    // Pull both sides: before is always from git; after is either
    // from git (at some revision) or from the working copy.
    const before = git.show(io, arena, env, repo.root, endpoints.range.before_rev, repo.rel_path) catch |err| {
        if (verbosity == .verbose) {
            var buf: [256]u8 = undefined;
            const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ endpoints.range.before_rev, @errorName(err) }) catch "Error reading before-side portfolio.\n";
            cli.stderrPrint(io, msg);
        }
        return error.PrepareFailed;
    };

    const after = if (endpoints.range.after_rev) |rev|
        git.show(io, arena, env, repo.root, rev, repo.rel_path) catch |err| {
            if (verbosity == .verbose) {
                var buf: [256]u8 = undefined;
                const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ rev, @errorName(err) }) catch "Error reading after-side portfolio.\n";
                cli.stderrPrint(io, msg);
            }
            return error.PrepareFailed;
        }
    else
        std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, arena, .limited(10 * 1024 * 1024)) catch {
            if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n");
            return error.PrepareFailed;
        };

    var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch {
        if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing before-snapshot portfolio.\n");
        return error.PrepareFailed;
    };
    errdefer before_pf.deinit();

    var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch {
        if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing after-snapshot portfolio.\n");
        return error.PrepareFailed;
    };
    errdefer after_pf.deinit();

    // Fetch current prices (cache-hit preferred) for DRIP/share-delta valuation.
    var prices = std.StringHashMap(f64).init(arena);
    var sym_set = std.StringHashMap(void).init(arena);
    for (before_pf.lots) |l| {
        if (l.security_type == .stock and !(l.price != null and l.ticker == null)) {
            sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed;
        }
    }
    for (after_pf.lots) |l| {
        if (l.security_type == .stock and !(l.price != null and l.ticker == null)) {
            sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed;
        }
    }
    var syms: std.ArrayList([]const u8) = .empty;
    var sit = sym_set.keyIterator();
    while (sit.next()) |k| syms.append(arena, k.*) catch return error.PrepareFailed;

    if (syms.items.len > 0) {
        var load_result = cli.loadPortfolioPrices(io, svc, syms.items, &.{}, refresh, color);
        defer load_result.deinit();
        var pit = load_result.prices.iterator();
        while (pit.next()) |entry| {
            prices.put(entry.key_ptr.*, entry.value_ptr.*) catch return error.PrepareFailed;
        }
    }

    // Load accounts.srf (if present) so opt-in cash-delta
    // reclassification fires at diff time. When missing or
    // unparseable, computeReport falls back to the default cash_delta
    // classification — no account gets the opt-in treatment.
    var account_map_opt = svc.loadAccountMap(allocator, portfolio_path);
    defer if (account_map_opt) |*am| am.deinit();

    // Load transaction_log.srf from BOTH sides of the diff. The
    // matcher considers only records that are NEW in the after-side
    // log (i.e. added to `transaction_log.srf` since `before_rev`).
    // This decouples record matching from the record's
    // `transfer::DATE`, allowing back-dated entries to pair with
    // the diff that introduced them.
    //
    // Path is sibling to portfolio.srf in the same git repo.
    // Missing-on-either-side is OK: file may not have existed in
    // before_rev (treat as empty) or may not exist in working copy
    // (treat as no records → matcher is a no-op).
    const tlog_rel_path = blk: {
        const dir_end = if (std.mem.lastIndexOfScalar(u8, repo.rel_path, '/')) |idx| idx + 1 else 0;
        break :blk std.fmt.allocPrint(arena, "{s}transaction_log.srf", .{repo.rel_path[0..dir_end]}) catch return error.PrepareFailed;
    };

    var before_tlog_opt: ?transaction_log.TransactionLog = blk: {
        const data = git.show(io, arena, env, repo.root, endpoints.range.before_rev, tlog_rel_path) catch break :blk null;
        break :blk transaction_log.parseTransactionLogFile(arena, data) catch null;
    };
    defer if (before_tlog_opt) |*tl| tl.deinit();

    var after_tlog_opt: ?transaction_log.TransactionLog = blk: {
        if (endpoints.range.after_rev) |rev| {
            const data = git.show(io, arena, env, repo.root, rev, tlog_rel_path) catch break :blk null;
            break :blk transaction_log.parseTransactionLogFile(arena, data) catch null;
        } else {
            break :blk svc.loadTransferLog(portfolio_path);
        }
    };
    defer if (after_tlog_opt) |*tl| tl.deinit();

    // Diff: keep only the records new in after. The slice borrows
    // from after_tlog_opt's record memory; both must outlive the
    // matcher call below (they do — same arena scope).
    const new_records: ?[]const transaction_log.TransferRecord = blk: {
        const after_tl = if (after_tlog_opt) |*tl| tl else break :blk null;
        const before_ptr: ?*const transaction_log.TransactionLog = if (before_tlog_opt) |*tl| tl else null;
        break :blk diffTransferLogs(arena, before_ptr, after_tl) catch return error.PrepareFailed;
    };

    const report = computeReport(
        arena,
        before_pf.lots,
        after_pf.lots,
        &prices,
        as_of,
        .{
            .account_map = if (account_map_opt) |*am| am else null,
            .transfer_log = new_records,
        },
    ) catch {
        if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n");
        return error.PrepareFailed;
    };

    return .{
        .endpoints = endpoints,
        .before_pf = before_pf,
        .after_pf = after_pf,
        .report = report,
    };
}

/// Whether `resolveEndpoints` / `prepareReport` should print
/// explanatory stderr messages when the window can't be resolved. The
/// main `run` command uses `.verbose` so the user sees why the command
/// failed; the internal `computeAttributionSpec` helper uses `.silent`
/// because a missing git window is an expected null-return case, not
/// a hard error.
const Verbosity = enum { verbose, silent };

/// Resolve `since` / `until` flags plus dirty-working-tree state to
/// the pair of git revisions to diff, along with a human-readable
/// label for the report header.
///
/// Pure SHA-level resolution is delegated to `git.resolveCommitRange`;
/// this wrapper adds:
///   - Label formatting (CLI-level presentation concern).
///   - Stderr messages on failure (respecting `verbosity`).
///   - A friendly "resolved to the same commit" warning when
///     `--since` and `--until` collapse.
fn resolveEndpoints(
    io: std.Io,
    arena: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    repo: git.RepoInfo,
    before: ?git.CommitSpec,
    after: ?git.CommitSpec,
    dirty: bool,
    verbosity: Verbosity,
) !Endpoints {
    const range = git.resolveCommitRangeSpec(io, arena, env, repo, before, after, dirty) catch |err| {
        if (verbosity == .verbose) {
            switch (err) {
                error.NoCommitAtOrBefore => {
                    // Report which spec triggered it. When both are
                    // date specs we can't easily tell from here;
                    // covers both by naming the before-side.
                    var before_buf: [10]u8 = undefined;
                    const before_str = specDisplayString(before, &before_buf);
                    var msg_buf: [256]u8 = undefined;
                    const msg = std.fmt.bufPrint(&msg_buf, "Error: no commit of {s} at or before {s}.\n", .{ repo.rel_path, before_str }) catch "Error: no commit at or before requested date.\n";
                    cli.stderrPrint(io, msg);
                },
                error.InvalidArg => {
                    cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
                },
                else => {
                    cli.stderrPrint(io, "Error resolving commit range: ");
                    cli.stderrPrint(io, @errorName(err));
                    cli.stderrPrint(io, "\n");
                },
            }
        }
        return error.ResolveFailed;
    };

    // Label the endpoints based on the resolution mode. Matches the
    // legacy phrasing where possible so existing test assertions still
    // pass.
    const label = try buildLabelFromSpecs(arena, range, before, after, dirty);

    // Same-commit warning for the two-date window case. Legit confusion
    // trigger — the user asked for a diff between two dates that both
    // snap to the same commit (e.g., no activity in the window).
    if (before != null and after != null and verbosity == .verbose) {
        if (range.after_rev) |after_rev| {
            if (std.mem.eql(u8, range.before_rev, after_rev)) {
                cli.stderrPrint(io, "Warning: before and after resolve to the same commit; no changes to report.\n");
            }
        }
    }

    // Snap-note warning: when the user provided a date-form spec and
    // the resolved commit's committer-date is >1 day before the
    // requested date, emit a muted hint so the user can verify the
    // selected commit matches their intent. See
    // `docs/notes/commit-window-edge-case.md` (aka TODO.md) for the
    // motivating scenario.
    if (verbosity == .verbose) {
        try maybeSnapNote(io, arena, env, repo, before, range.before_rev, "before");
    }

    return .{ .range = range, .label = label };
}

/// Render a `CommitSpec` for user-facing error messages. Dates and
/// working-copy sentinels get formatted; refs are passed through.
/// When `spec` is null, returns "(unset)".
fn specDisplayString(spec: ?git.CommitSpec, date_buf: *[10]u8) []const u8 {
    const s = spec orelse return "(unset)";
    return switch (s) {
        .git_ref => |r| r,
        .date_at_or_before => |d| std.fmt.bufPrint(date_buf, "{f}", .{d}) catch "????-??-??",
        .working_copy => "working",
    };
}

/// If the user's before spec was a date form and the resolved commit
/// sits more than 1 day earlier than the requested date, print a
/// muted hint. Catches the "I committed after my review date" case
/// where `--since 1W` picks up a commit 7+ days before the cutoff.
fn maybeSnapNote(
    io: std.Io,
    arena: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    repo: git.RepoInfo,
    spec: ?git.CommitSpec,
    resolved_ref: []const u8,
    label: []const u8,
) !void {
    const s = spec orelse return;
    const requested_date = switch (s) {
        .date_at_or_before => |d| d,
        else => return,
    };

    // Get the committer-date of the resolved commit. `%ct` gives a
    // Unix timestamp.
    const ts = git.commitTimestamp(io, arena, env, repo.root, resolved_ref) catch return;
    const commit_date = zfin.Date.fromEpoch(ts);
    if (!commit_date.lessThan(requested_date)) return;

    const gap_days = requested_date.days - commit_date.days;
    if (gap_days <= 1) return;

    var msg_buf: [320]u8 = undefined;
    const msg = std.fmt.bufPrint(
        &msg_buf,
        "(git {s} uses commit {s} from {f}, {d} day{s} before requested {f} — " ++
            "use --commit-{s} HEAD or a later date to pin to your latest reconciliation commit)\n",
        .{
            label,
            shortSha(resolved_ref),
            commit_date,
            gap_days,
            if (gap_days == 1) "" else "s",
            requested_date,
            label,
        },
    ) catch return;
    cli.stderrPrint(io, msg);
}

/// Abbreviate a commit ref for display. SHAs get shortened to 7
/// chars; symbolic refs (HEAD, HEAD~1) stay as-is.
fn shortSha(ref: []const u8) []const u8 {
    if (std.mem.startsWith(u8, ref, "HEAD")) return ref;
    if (ref.len > 7) return ref[0..7];
    return ref;
}

/// Build a report-header label from the resolved range + the
/// user-provided specs. Date specs render their requested form;
/// refs render verbatim; null falls back to the legacy HEAD~/HEAD
/// naming.
fn buildLabelFromSpecs(
    arena: std.mem.Allocator,
    range: git.CommitRange,
    before: ?git.CommitSpec,
    after: ?git.CommitSpec,
    dirty: bool,
) ![]const u8 {
    // Preserve the original date-based label when both specs are
    // date-form (most common user-facing flow). Fall back to a
    // spec-agnostic rendering otherwise.
    const before_date: ?Date = if (before) |b| switch (b) {
        .date_at_or_before => |d| d,
        else => null,
    } else null;
    const after_date: ?Date = if (after) |a| switch (a) {
        .date_at_or_before => |d| d,
        else => null,
    } else null;

    // If either spec is a non-date form, emit a label describing
    // the resolved range. Otherwise use the legacy date/label path
    // for back-compat with snapshot tests.
    const has_non_date = (before != null and before_date == null) or
        (after != null and after_date == null);
    if (has_non_date) {
        const before_str = try specLabel(arena, before, range.before_rev);
        const after_str = try specLabelAfter(arena, after, range.after_rev);
        return std.fmt.allocPrint(arena, "{s} vs {s}", .{ before_str, after_str });
    }

    return buildLabel(arena, range, before_date, after_date, dirty);
}

fn specLabel(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: []const u8) ![]const u8 {
    const s = spec orelse return arena.dupe(u8, resolved_ref);
    return switch (s) {
        .git_ref => |r| arena.dupe(u8, r),
        .date_at_or_before => |d| std.fmt.allocPrint(arena, "commit at-or-before {f}", .{d}),
        .working_copy => arena.dupe(u8, "working copy"),
    };
}

fn specLabelAfter(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: ?[]const u8) ![]const u8 {
    if (spec) |s| return specLabel(arena, s, resolved_ref orelse "working");
    if (resolved_ref) |r| return arena.dupe(u8, r);
    return arena.dupe(u8, "working copy");
}

/// Build the human-readable header label for a resolved range.
fn buildLabel(
    arena: std.mem.Allocator,
    range: git.CommitRange,
    since: ?Date,
    until: ?Date,
    dirty: bool,
) ![]const u8 {
    // No date window → legacy labels, matches pre-since/--until wording.
    if (since == null) {
        return if (dirty)
            "Comparing working copy against HEAD"
        else
            "Working tree clean — comparing HEAD~1 against HEAD";
    }

    var since_buf: [10]u8 = undefined;
    const since_str = std.fmt.bufPrint(&since_buf, "{f}", .{since.?}) catch "????-??-??";

    if (until) |until_date| {
        var until_buf: [10]u8 = undefined;
        const until_str = std.fmt.bufPrint(&until_buf, "{f}", .{until_date}) catch "????-??-??";
        return std.fmt.allocPrint(arena, "Comparing {s} ({s}) against {s} ({s})", .{
            short(range.before_rev),
            since_str,
            short(range.after_rev.?),
            until_str,
        });
    }

    // --since only: after side is HEAD or working copy.
    return if (dirty)
        std.fmt.allocPrint(arena, "Comparing {s} ({s}) against working copy", .{ short(range.before_rev), since_str })
    else
        std.fmt.allocPrint(arena, "Comparing {s} ({s}) against HEAD", .{ short(range.before_rev), since_str });
}

/// 7-char short SHA for display. Runtime behavior is already
/// length-agnostic (accepts any `>= 7`), so this works for both SHA-1
/// (40-char) and SHA-256 (64-char) repos without modification.
/// Slices rather than reallocs.
fn short(sha: []const u8) []const u8 {
    return if (sha.len >= 7) sha[0..7] else sha;
}

// ── Attribution helper for compare ──────────────────────────

/// Aggregated "money in" totals over a date window, produced by the
/// contributions pipeline but distilled to the numbers needed for
/// the compare-command attribution line.
///
/// "Contributions" in the plain-English sense (what the user wrote a
/// check for or what got DRIP'd back in) = `new_contributions` +
/// `drip`. CD face-value rollovers are *not* here — moving a maturing
/// CD's face value back into cash isn't new money, and `new_cash`
/// records during that transaction don't double-count because the
/// pipeline separates cd_matured from cash_delta.
pub const AttributionSummary = struct {
    /// Fresh lots that represent outside money: 401k contributions,
    /// DRIP-false stock purchases, CD openings, option opens, cash
    /// top-ups. Matches the "New contributions / purchases" section
    /// in the full report.
    new_contributions: f64,
    /// Dividend reinvestments: new `drip::true` lots + share increases
    /// on same-key drip lots + rollup share deltas (ambiguous
    /// contribution-vs-DRIP cases, treated as DRIP here to avoid
    /// double-counting with cash contributions).
    drip: f64,

    pub fn total(self: AttributionSummary) f64 {
        return self.new_contributions + self.drip;
    }
};

/// Run the contributions pipeline over a commit window and return the
/// aggregated "money in" totals. Returns null on any failure —
/// intended callers (e.g. `compare`) surface the attribution line
/// opportunistically; a missing git repo or no resolvable commits
/// shouldn't break the primary command.
///
/// Parameters mirror `run` but without the writer/color: no output
/// is produced. Failures swallow silently via the shared
/// `prepareReport` helper's `.silent` verbosity.
///
/// Classification logic is SOLELY in `computeReport` — this function
/// just buckets pre-classified change kinds into their totals. In
/// particular, opt-in cash-delta handling is resolved at diff time
/// (cash_delta → cash_contribution on accounts marked
/// `cash_is_contribution::true`), so the attribution line here and
/// the grand total in the full `zfin contributions` report come out
/// of the same classifier and always agree over the same window.
pub fn computeAttributionSpec(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    svc: *zfin.DataService,
    portfolio_path: []const u8,
    before: ?git.CommitSpec,
    after: ?git.CommitSpec,
    as_of: Date,
    color: bool,
    refresh: framework.RefreshPolicy,
) ?AttributionSummary {
    if (before == null and after != null) return null;

    var arena_state = std.heap.ArenaAllocator.init(allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var ctx = prepareReport(io, allocator, arena, env, svc, portfolio_path, before, after, as_of, color, refresh, .silent) catch return null;
    defer ctx.deinit();

    return summarizeAttribution(ctx);
}

// ── Public audit hook ────────────────────────────────────────

/// Descriptor of a "large new lot" the audit command may want to
/// surface. Emitted by `findUnmatchedLargeLots` for any new-side
/// Change whose `value()` meets the caller's threshold and which
/// was NOT reclassified by the transfer-log matcher. All string
/// fields are caller-arena-owned through the `UnmatchedLargeLotSet`
/// wrapper; the caller frees everything at once via `deinit`.
pub const UnmatchedLargeLot = struct {
    /// The account the lot landed on (arena-owned copy).
    account: []const u8,
    /// Security ticker, or empty string for cash-kind lots.
    symbol: []const u8,
    /// `.stock`, `.cash`, `.cd`, etc. Drives the dest_lot shape in
    /// the suggested template.
    security_type: LotType,
    /// Dollar value of the lot (`Change.value()`).
    value: f64,
    /// Lot open_date. Used in the `dest_lot::SYMBOL@OPEN_DATE`
    /// template for stock / CD destinations.
    open_date: Date,
};

/// Result wrapper that owns both the slice and the arena the string
/// fields live in. Caller must `deinit`.
pub const UnmatchedLargeLotSet = struct {
    lots: []UnmatchedLargeLot,
    arena: std.heap.ArenaAllocator,

    pub fn deinit(self: *UnmatchedLargeLotSet) void {
        self.arena.deinit();
    }
};

/// Find new-side lots (new_stock / new_drip_lot / new_cash / new_cd
/// / cash_contribution) with `value() >= threshold` that weren't
/// matched to a record in `transaction_log.srf` over the HEAD →
/// working-copy window. Mirrors the `zfin contributions` zero-flag
/// path — uses `prepareReport`'s shared git + portfolio + transfer
/// plumbing so the classification is identical. Returns null if the
/// pipeline can't resolve a window (not in a git repo, etc.).
///
/// Consumed by `zfin audit` to prompt the user to either confirm
/// the lot as an external contribution or add a transfer record
/// when it was really an internal movement. Works whether or not
/// `transaction_log.srf` exists — when absent, every large lot
/// surfaces since nothing gets reclassified.
pub fn findUnmatchedLargeLots(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    svc: *zfin.DataService,
    portfolio_path: []const u8,
    threshold: f64,
    as_of: Date,
    color: bool,
    refresh: framework.RefreshPolicy,
) ?UnmatchedLargeLotSet {
    var arena_state = std.heap.ArenaAllocator.init(allocator);
    errdefer arena_state.deinit();
    const arena = arena_state.allocator();

    // Explicit null/null selects the legacy zero-flag path:
    // before=HEAD~1..HEAD if clean, HEAD..working-copy if dirty.
    // Audit cares about the dirty-working-copy case in practice
    // (that's where "unconfirmed large lots" live), but both
    // branches are valid consumers.
    //
    // Separate allocator here so we can tear the whole thing down
    // via `arena_state.deinit` once we've copied out the descriptors.
    var ctx = prepareReport(io, allocator, arena, env, svc, portfolio_path, null, null, as_of, color, refresh, .silent) catch {
        arena_state.deinit();
        return null;
    };
    defer ctx.deinit();

    const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, threshold) catch {
        arena_state.deinit();
        return null;
    };
    return .{ .lots = lots, .arena = arena_state };
}

/// Pure filter: pick out new-side Changes with `value() >= threshold`
/// and dupe their string fields into `arena`. Split out so tests can
/// feed a synthetic `[]Change` without running the git/IO pipeline.
///
/// Cash-destination transfers don't flip their original `new_cash` /
/// `cash_contribution` Change (a single cash delta can be drained by
/// multiple records, which the kind field can't represent). Instead
/// the matcher records the attributed amount in
/// `cash_attributed_by_account`. Subtract that here so a fully-
/// attributed cash lot doesn't re-surface as "unmatched large lot."
/// A partially-attributed cash lot surfaces only on the residual,
/// matching the user's mental model: "this much of the lot is
/// already documented, check the rest."
///
/// Deliberately excludes `partial_transfer_in`. A partial lot already
/// has an explicit transfer record acknowledging the large movement;
/// the unmatched residual is typically small (pre-existing cash that
/// topped the lot off) and surfacing it again would nag on something
/// the user has already documented. If a residual is large enough to
/// care about independently, the user can review the lot's full value
/// via `zfin contributions` — this filter's job is to catch
/// *unrecorded* large movements, not to re-flag partial ones.
/// Pure filter: pick out new-side Changes whose unattributed value
/// (`attributedValue()`) is at or above `threshold`, and dupe their
/// string fields into `arena`. Split out so tests can feed a
/// synthetic `[]Change` without running the git/IO pipeline.
///
/// Cash-destination transfers don't flip their original `new_cash` /
/// `cash_contribution` Change's kind (a single cash delta can be
/// drained by multiple records, which the kind field can't
/// represent). Instead the matcher accumulates the per-record
/// attribution onto each consumed Change's `transfer_attributed`
/// field, in iteration order. `attributedValue()` then returns the
/// residual: 0 for fully-attributed lots, the unattributed
/// remainder for partials.
///
/// Deliberately excludes `partial_transfer_in`. A partial lot already
/// has an explicit transfer record acknowledging the large movement;
/// the unmatched residual is typically small (pre-existing cash that
/// topped the lot off) and surfacing it again would nag on something
/// the user has already documented. If a residual is large enough to
/// care about independently, the user can review the lot's full value
/// via `zfin contributions` — this filter's job is to catch
/// *unrecorded* large movements, not to re-flag partial ones.
fn collectUnmatchedLargeLots(
    arena: std.mem.Allocator,
    changes: []const Change,
    threshold: f64,
) ![]UnmatchedLargeLot {
    var out: std.ArrayList(UnmatchedLargeLot) = .empty;
    for (changes) |c| {
        const is_new_side = switch (c.kind) {
            .new_stock, .new_drip_lot, .new_cash, .new_cd, .cash_contribution => true,
            else => false,
        };
        if (!is_new_side) continue;

        // Use attributedValue() so a fully-attributed cash lot
        // (whose `transfer_attributed` covers the whole `value()`)
        // drops out, and a partially-attributed cash lot surfaces
        // only on the unattributed remainder.
        const residual = c.attributedValue();
        if (residual < threshold) continue;

        // open_date is populated in Pass 1's new-lot branch; absence
        // here would be a pipeline bug.
        const od = c.open_date orelse return error.MissingOpenDate;
        const account_copy = try arena.dupe(u8, c.account);
        const symbol_copy = try arena.dupe(u8, c.symbol);
        try out.append(arena, .{
            .account = account_copy,
            .symbol = symbol_copy,
            .security_type = c.security_type,
            .value = residual,
            .open_date = od,
        });
    }
    return out.toOwnedSlice(arena);
}

fn summarizeAttribution(ctx: ReportContext) AttributionSummary {

    // Aggregate. Classification logic matches the full-report sections:
    //   - New contributions: new_stock + new_cash + new_cd + new_option
    //                        + cash_contribution (opt-in cash_delta)
    //                        + partial_transfer_in residual
    //                          (`value()` − `transfer_attributed`)
    //                        − cash-dest transfer totals
    //                          (from `cash_attributed_by_account`)
    //   - DRIP: new_drip_lot + drip_confirmed + rollup_delta
    // `rollup_delta` is the ambiguous "share increased on a drip::false
    // lot" case. Lumping it with DRIP here matches the report's own
    // visual grouping (both shown as positive, both under DRIP-ish
    // headings) and prevents double-counting against cash_delta.
    // `cash_delta` (non-opt-in) is excluded — it's noisy cash movement
    // (DRIP legs, interest, CD coupons, settlement sweeps) that the
    // user hasn't explicitly flagged as a contribution source.
    // `lot_edited` is excluded — it's a reconciliation noise bucket.
    // `transfer_in` / `transfer_out` / `unmatched_transfer` → $0.
    // `partial_transfer_in` → residual only (attributedValue()).
    var new_contributions: f64 = 0;
    var drip: f64 = 0;
    for (ctx.report.changes) |c| switch (c.kind) {
        .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(),
        .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
        .partial_transfer_in => new_contributions += c.attributedValue(),
        else => {},
    };
    // Note: cash-dest transfer attribution is already removed by
    // `attributedValue()` on the per-Change side (matchCashDestination
    // accumulates into `transfer_attributed`). No second subtraction
    // off `cash_attributed_by_account` needed here.

    return .{ .new_contributions = new_contributions, .drip = drip };
}

// ── Git discovery / invocation ───────────────────────────────
//
// Git plumbing lives in `src/git.zig` (shared with future snapshot
// features). This module only classifies which revisions to diff and
// how to interpret the result.

// ── Diff algorithm ───────────────────────────────────────────

/// Categorized change on a single lot-key.
const ChangeKind = enum {
    new_stock, // lot appeared: stock purchase (drip::false)
    new_drip_lot, // lot appeared: stock with drip::true (confirmed DRIP reinvestment)
    new_cash, // lot appeared: cash added
    new_cd, // lot appeared: CD opened
    new_option, // lot appeared: option opened
    drip_confirmed, // same key on a drip::true stock lot, Δshares > 0
    rollup_delta, // same key on a drip::false stock lot, Δshares > 0 (DRIP or contribution; can't distinguish)
    drip_negative, // same key, stock, Δshares < 0 (share sale on the same lot — unusual)
    cash_delta, // same key, cash, Δshares (treated as noise — interest, DRIP legs)
    /// Positive cash_delta on an account marked
    /// `cash_is_contribution::true` in accounts.srf. Reclassified at
    /// diff time (inside `computeReport`) so every downstream
    /// consumer — the full report, per-account summary, attribution
    /// totals in `compare` — sees the same classification. Without
    /// this single-point-of-truth, compare's attribution line and the
    /// contributions report's grand total could disagree on the same
    /// window.
    cash_contribution,
    cd_matured, // lot disappeared: CD with maturity_date <= today
    cd_removed_early, // lot disappeared: CD with maturity_date > today
    lot_removed, // lot disappeared: stock/cash/option
    /// Strict lot key broke (open_date / open_price / account rewritten)
    /// but the same (security_type, symbol, account) reappears on the
    /// other side with approximately the same share total. Classified
    /// as a lot edit — not counted as contribution, not counted as
    /// disposal. Fixes the phantom-contribution bug from reconciliation
    /// tweaks, CD auto-renewals rewriting `open_date`, and account
    /// renames that shift every lot in the account under a new name.
    lot_edited,
    price_only, // same key, price:: field changed, no share change
    flagged, // any other shape of edit

    // ── Transfer reclassifications (see `matchTransfers`) ────
    /// Destination lot or cash_delta fully attributed to a transfer
    /// record in `transaction_log.srf`. Contributes $0 to attribution —
    /// it's internal money movement, not new contribution. Replaces
    /// the `new_stock` / `new_drip_lot` / `new_cash` / `new_cd` /
    /// `cash_contribution` classification the lot would otherwise
    /// receive.
    transfer_in,
    /// `lot_removed` or negative `cash_delta` on the sending account,
    /// credited against a transfer record. Contributes $0 to
    /// attribution. Also replaces the base classification.
    transfer_out,
    /// Destination lot or cash partially attributed to a transfer.
    /// The remaining value (`c.value() - c.transfer_attributed`) is
    /// still real new money and flows through attribution under the
    /// base classification's bucket (new_contributions or drip).
    partial_transfer_in,
    /// Transfer record in the window that couldn't be matched to any
    /// portfolio-diff Change. Emitted as a standalone Change (not
    /// attached to an existing lot) so it surfaces in the Flagged
    /// section. `detail` carries the human-readable reason.
    /// Contributes $0 to attribution.
    unmatched_transfer,
};

const Change = struct {
    kind: ChangeKind,
    symbol: []const u8,
    account: []const u8,
    security_type: LotType,
    /// Δshares (after - before). Zero for price_only and lot_removed.
    delta_shares: f64 = 0,
    /// Price used to value delta_shares (open_price, current price, or manual price).
    unit_value: f64 = 0,
    /// For cd_matured / cd_removed_early / lot_removed: the before-lot's shares.
    face_value: f64 = 0,
    /// For cd_matured / cd_removed_early: maturity_date.
    maturity_date: ?Date = null,
    /// For price_only: old and new values.
    old_price: f64 = 0,
    new_price: f64 = 0,
    /// Free-form detail for flagged changes.
    detail: ?[]const u8 = null,
    /// Lot open_date for new_* kinds — carried here so downstream
    /// consumers (the audit large-lot warning, the Transfers section
    /// printer) can generate `transfer_log.srf` templates without
    /// re-reading the after-portfolio. Null for non-new kinds.
    open_date: ?Date = null,

    // ── Transfer reclassification (see `matchTransfers`) ────
    /// Dollar amount of this Change's `value()` that's attributable
    /// to a matched transfer record. Set only for `transfer_in`
    /// (equals `value()`), `partial_transfer_in` (less than `value()`),
    /// and `unmatched_transfer` (equals the record's amount, carried
    /// on a synthetic Change). Zero otherwise.
    transfer_attributed: f64 = 0,
    /// Free-form text carried from the transfer record (its `note::`
    /// field) or from the matcher (for `unmatched_transfer`, a
    /// reason string).
    transfer_note: ?[]const u8 = null,
    /// Sending account for transfer_in / partial_transfer_in /
    /// unmatched_transfer. Null for non-transfer kinds.
    transfer_from: ?[]const u8 = null,
    /// Date of the matched transfer record. Null for non-transfer
    /// kinds.
    transfer_date: ?Date = null,

    pub fn value(self: Change) f64 {
        return self.delta_shares * self.unit_value;
    }

    /// Portion of `value()` that counts toward attribution (after
    /// transfer reclassification). For `transfer_in` / `transfer_out`
    /// / `unmatched_transfer`, the contribution is $0. For
    /// `partial_transfer_in`, only the residual counts. For
    /// `new_cash`, `cash_contribution`, and `cash_delta`, the
    /// matcher may have credited some of the value to a cash-
    /// destination transfer record (see `matchCashDestination`);
    /// `transfer_attributed` tracks how much. The residual is
    /// `value() − transfer_attributed`, which is what shows up in
    /// "New contributions / purchases" and the audit large-lot
    /// filter. Everyone else sees `value()` unchanged.
    pub fn attributedValue(self: Change) f64 {
        return switch (self.kind) {
            .transfer_in, .transfer_out, .unmatched_transfer => 0,
            .partial_transfer_in => self.value() - self.transfer_attributed,
            .new_cash, .cash_contribution, .cash_delta => self.value() - self.transfer_attributed,
            else => self.value(),
        };
    }
};

/// Summary aggregated for the report. All string fields and backing memory
/// live in the caller-supplied arena; there is no explicit deinit.
const Report = struct {
    changes: []Change,
    /// Per-account rollups for the summary section.
    account_totals: std.StringHashMap(AccountTotal),
    /// Per-account cash amounts matched to transfer records. Subtracted
    /// from cash-side totals in the per-account summary so transferred
    /// cash doesn't double-count. Keys borrow from Change.account
    /// strings (arena-owned, same lifetime as the Report).
    cash_attributed_by_account: std.StringHashMap(f64),

    const AccountTotal = struct {
        new_money: f64 = 0, // stock+cd+cash new lots (drip::false)
        drip_confirmed: f64 = 0, // confirmed DRIP (drip::true lots: new or shares increased)
        rollup: f64 = 0, // share deltas on drip::false aggregate lots (DRIP or contribution; ambiguous)
        cd_interest: f64 = 0, // implied interest from matured CDs
        cash_delta: f64 = 0, // unclassified cash balance changes
    };
};

/// Build a canonical lookup key for matching lots between snapshots.
/// Key: (security_type, symbol, account, open_date, open_price).
fn lotKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
    return std.fmt.allocPrint(allocator, "{s}|{s}|{s}|{f}|{d:.6}", .{
        @tagName(lot.security_type),
        lot.symbol,
        lot.account orelse "",
        lot.open_date,
        lot.open_price,
    });
}

/// Aggregate duplicate-key lots by summing shares. (Rare in practice but
/// possible.) Returns map key -> (shares, representative Lot).
const LotAgg = struct {
    shares: f64,
    lot: Lot,
};

fn aggregateByKey(
    allocator: std.mem.Allocator,
    lots: []const Lot,
) !std.StringHashMap(LotAgg) {
    var map = std.StringHashMap(LotAgg).init(allocator);

    for (lots) |lot| {
        const k = try lotKey(allocator, lot);
        const gop = try map.getOrPut(k);
        if (gop.found_existing) {
            gop.value_ptr.shares += lot.shares;
        } else {
            gop.value_ptr.* = .{ .shares = lot.shares, .lot = lot };
        }
    }
    return map;
}

/// Secondary key for edit detection: (security_type, priceSymbol, account).
/// Lots with the same secondary key but different strict `lotKey`s are
/// candidates for reclassification as `lot_edited` — the strict key
/// broke because `open_date`, `open_price`, or the underlying symbol
/// string got rewritten, but the position itself continued.
///
/// Uses `priceSymbol()` (ticker-alias-aware) rather than raw `symbol`
/// so that edits like `symbol::SPY` → `symbol::DI-SPX, ticker::SPY`
/// collapse correctly. Both sides resolve to the same effective
/// ticker (`SPY`) and represent the same underlying exposure; the
/// raw `symbol` changed but the position did not.
fn secondaryKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
    return std.fmt.allocPrint(allocator, "{s}|{s}|{s}", .{
        @tagName(lot.security_type),
        lot.priceSymbol(),
        lot.account orelse "",
    });
}

/// Tolerance for "did the share total stay the same" check when
/// deciding whether to emit a rollup_delta for any residual share
/// difference alongside a `lot_edited` classification. Anything within
/// this tolerance is treated as rounding/reconciliation noise and
/// suppressed; anything beyond it surfaces as a normal share-delta
/// change (rollup_delta for positive, drip_negative for negative).
///
/// This is NOT a gate on whether the pair collapses — secondary-key
/// matches ALWAYS collapse the identity change into a lot_edited
/// record. The tolerance only decides whether to additionally emit
/// the residual share movement as a distinct change.
const edit_residual_tolerance_rel: f64 = 0.0001; // 0.01%

/// Looser tolerance applied to accounts flagged
/// `direct_indexing::true` in `accounts.srf`. Direct-indexing
/// proxies (a basket of underlying stocks tracked as a single
/// benchmark via `ticker::`) have tracking-error drift that legitimately
/// moves the basket's equivalent share count without any real money
/// flowing. A tighter tolerance flags that drift as contribution-
/// adjacent noise; this looser one treats it as edit-only, so the
/// attribution line stays clean of tracking-error reconciliation.
///
/// 1% is chosen to sit well above typical weekly tracking-error
/// magnitudes (< 0.5% in normal markets) while still catching
/// out-of-band moves — e.g. an actual $100k contribution into a
/// multi-million direct-indexing basket (~1.2% of the account) will
/// fall just over this threshold and surface as a rollup_delta for
/// review. Not configurable per-account today; revisit if anyone's
/// direct-indexing account generates > 1% drift regularly.
const direct_indexing_residual_tolerance_rel: f64 = 0.01; // 1%

/// Identify strict-key pairs that should be reclassified as edits.
///
/// Walks the "only in after" and "only in before" strict keys, groups
/// each by secondary key (security_type, priceSymbol, account), and
/// returns a set of strict keys that should be skipped by the primary
/// classification passes (both `new_*` and `lot_removed` / `cd_*`).
/// Populates `changes` with a single `lot_edited` entry per matched
/// secondary-key group, plus a residual `rollup_delta` / `drip_negative`
/// for any share difference beyond noise tolerance.
///
/// Matching rules:
///   - A secondary key must have at least one unmatched lot on each
///     side (otherwise it's a pure add or a pure remove, which keeps
///     the existing semantics).
///   - All unmatched strict keys for that secondary key group collapse
///     together regardless of share-total magnitude. The thinking:
///     secondary-key agreement (same `(security_type, priceSymbol,
///     account)`) means the user is editing the same underlying
///     position. Any share difference is a separate question —
///     handled by emitting a residual rollup_delta (if positive) or
///     drip_negative (if negative). This matches how share deltas on
///     intact strict keys are classified in Pass 1.
fn detectEdits(
    allocator: std.mem.Allocator,
    before_map: *const std.StringHashMap(LotAgg),
    after_map: *const std.StringHashMap(LotAgg),
    prices: *const std.StringHashMap(f64),
    account_map: ?*const analysis.AccountMap,
    changes: *std.ArrayList(Change),
) !std.StringHashMap(void) {
    var skip = std.StringHashMap(void).init(allocator);
    errdefer skip.deinit();

    // Group unmatched strict keys by secondary key, tagging each entry
    // with the side it came from. `strict_keys` alongside the value
    // lets us seed the skip set only when a group actually matches.
    const SidedEntry = struct {
        strict_key: []const u8,
        agg: LotAgg,
        from_after: bool,
    };
    var groups = std.StringHashMap(std.ArrayList(SidedEntry)).init(allocator);
    defer {
        var vit = groups.valueIterator();
        while (vit.next()) |list| list.deinit(allocator);
        groups.deinit();
    }

    var ait = after_map.iterator();
    while (ait.next()) |entry| {
        if (before_map.contains(entry.key_ptr.*)) continue;
        const sk = try secondaryKey(allocator, entry.value_ptr.*.lot);
        const gop = try groups.getOrPut(sk);
        if (!gop.found_existing) {
            gop.value_ptr.* = std.ArrayList(SidedEntry).empty;
        } else {
            allocator.free(sk);
        }
        try gop.value_ptr.append(allocator, .{
            .strict_key = entry.key_ptr.*,
            .agg = entry.value_ptr.*,
            .from_after = true,
        });
    }

    var bit = before_map.iterator();
    while (bit.next()) |entry| {
        if (after_map.contains(entry.key_ptr.*)) continue;
        const sk = try secondaryKey(allocator, entry.value_ptr.*.lot);
        const gop = try groups.getOrPut(sk);
        if (!gop.found_existing) {
            gop.value_ptr.* = std.ArrayList(SidedEntry).empty;
        } else {
            allocator.free(sk);
        }
        try gop.value_ptr.append(allocator, .{
            .strict_key = entry.key_ptr.*,
            .agg = entry.value_ptr.*,
            .from_after = false,
        });
    }

    // Dup helper for string arena storage into `changes`.
    const Dup = struct {
        a: std.mem.Allocator,
        fn of(self: @This(), s: []const u8) ![]const u8 {
            return self.a.dupe(u8, s);
        }
    };
    const sdup = Dup{ .a = allocator };

    var git_it = groups.iterator();
    while (git_it.next()) |entry| {
        const list = entry.value_ptr.*;

        // Need at least one on each side to be an edit.
        var after_shares: f64 = 0;
        var before_shares: f64 = 0;
        var after_rep: ?LotAgg = null;
        var before_rep: ?LotAgg = null;
        for (list.items) |e| {
            if (e.from_after) {
                after_shares += e.agg.shares;
                if (after_rep == null) after_rep = e.agg;
            } else {
                before_shares += e.agg.shares;
                if (before_rep == null) before_rep = e.agg;
            }
        }
        if (after_shares == 0 or before_shares == 0) continue;

        // Qualified edit: add all strict keys to the skip set. Emit a
        // lot_edited record representing the identity continuity.
        for (list.items) |e| {
            try skip.put(e.strict_key, {});
        }

        const rep_lot = after_rep.?.lot;
        const acct = try sdup.of(rep_lot.account orelse "");
        const sym = try sdup.of(rep_lot.symbol);
        try changes.append(allocator, .{
            .kind = .lot_edited,
            .symbol = sym,
            .account = acct,
            .security_type = rep_lot.security_type,
            .delta_shares = 0,
            .unit_value = 0,
        });

        // Emit a residual share-delta change if the totals diverge
        // beyond noise. Mirrors Pass 1's same-key share-delta handling
        // so the user sees the same classification whether the strict
        // key was preserved or rewritten.
        //
        // Direct-indexing accounts (flagged `direct_indexing::true`
        // in accounts.srf) use a looser tolerance — tracking-error
        // share reconciliation on a proxy basket isn't real money
        // flow and shouldn't land in rollup_delta / drip_negative.
        const delta = after_shares - before_shares;
        const denom = @max(@abs(after_shares), @abs(before_shares));
        const rel = if (denom == 0) 0.0 else @abs(delta) / denom;
        const tolerance = if (account_map) |am|
            (if (am.isDirectIndexing(rep_lot.account orelse ""))
                direct_indexing_residual_tolerance_rel
            else
                edit_residual_tolerance_rel)
        else
            edit_residual_tolerance_rel;
        if (rel <= tolerance) continue;

        const unit_value: f64 = blk: {
            if (rep_lot.security_type == .stock) {
                // `prices.get` is the raw retail-class API price → ratio applies.
                // `lot.price` (manual override) and `lot.open_price` are both
                // in the lot's own share-class terms (preadjusted) → ratio
                // must NOT be applied. See the "Pricing model" doc-block in
                // models/portfolio.zig.
                if (prices.get(rep_lot.priceSymbol())) |p| break :blk rep_lot.effectivePrice(p, false);
                if (rep_lot.price) |p| break :blk rep_lot.effectivePrice(p, true);
                break :blk rep_lot.effectivePrice(rep_lot.open_price, true);
            }
            break :blk 1.0;
        };

        const is_drip = rep_lot.drip or (before_rep.?.lot.drip);
        const kind: ChangeKind = switch (rep_lot.security_type) {
            .stock => if (delta > 0)
                (if (is_drip) ChangeKind.drip_confirmed else ChangeKind.rollup_delta)
            else
                ChangeKind.drip_negative,
            .cash => .cash_delta,
            else => .flagged,
        };
        try changes.append(allocator, .{
            .kind = kind,
            .symbol = sym,
            .account = acct,
            .security_type = rep_lot.security_type,
            .delta_shares = delta,
            .unit_value = unit_value,
        });
    }

    return skip;
}

/// Optional knobs for `computeReport`. An explicit struct keeps the
/// call-site noise low at the many in-file test call sites (all pass
/// `.{}`) while still letting production callers thread through the
/// account map for opted-in cash-delta reclassification.
const ReportOptions = struct {
    /// Account metadata from `accounts.srf`. When present, positive
    /// `cash_delta` entries for accounts with
    /// `cash_is_contribution::true` are reclassified as
    /// `cash_contribution` so every downstream consumer (full
    /// report, per-account summary, `compare` attribution) agrees.
    account_map: ?*const analysis.AccountMap = null,
    /// Transfer records to match against this diff. Typically the
    /// records that are NEW in the after-side `transaction_log.srf`
    /// relative to the before-side (computed by
    /// `diffTransferLogs`). The matcher reclassifies
    /// destination/source Changes as `transfer_in` /
    /// `partial_transfer_in` / `transfer_out` and emits
    /// `unmatched_transfer` entries for records that can't be
    /// matched. When null or empty, `matchTransfers` is a no-op.
    ///
    /// Records are not date-window filtered: the caller is
    /// responsible for passing only records that should be
    /// considered for THIS diff. The expected source is "records
    /// added to `transaction_log.srf` since `before_rev`," not
    /// "records dated within the diff's git timestamp window."
    /// See `diffTransferLogs` and the prepareReport pipeline for
    /// how the production caller assembles the slice.
    transfer_log: ?[]const transaction_log.TransferRecord = null,
};

fn computeReport(
    allocator: std.mem.Allocator,
    before: []const Lot,
    after: []const Lot,
    prices: *const std.StringHashMap(f64),
    as_of: Date,
    opts: ReportOptions,
) !Report {
    var changes: std.ArrayList(Change) = .empty;

    var before_map = try aggregateByKey(allocator, before);
    var after_map = try aggregateByKey(allocator, after);

    // Edit detection: identify strict-key pairs that look like edits
    // (reconciliation tweak, CD auto-renewal rewriting `open_date`,
    // symbol alias rewrite like SPY→DI-SPX,ticker::SPY) rather than
    // real new/removed lots. The returned `skip` set is the list of
    // strict keys passes 1 and 2 should ignore — each matched group
    // emits its own `lot_edited` change plus a residual rollup for
    // any share delta beyond noise.
    var skip = try detectEdits(allocator, &before_map, &after_map, prices, opts.account_map, &changes);
    defer skip.deinit();

    // Helper for duping strings into the arena so Change fields have
    // predictable lifetimes even if caller-supplied lot strings go away.
    const Dup = struct {
        a: std.mem.Allocator,
        fn of(self: @This(), s: []const u8) ![]const u8 {
            return self.a.dupe(u8, s);
        }
    };
    const sdup = Dup{ .a = allocator };

    // Pass 1: keys in after. Classify as new, shares-changed, or (matched
    // with price-only or other-metadata changes) edited.
    var ait = after_map.iterator();
    while (ait.next()) |entry| {
        if (skip.contains(entry.key_ptr.*)) continue;
        const after_agg = entry.value_ptr.*;
        if (before_map.get(entry.key_ptr.*)) |before_agg| {
            // Key present in both. Compare shares and other fields.
            const delta = after_agg.shares - before_agg.shares;
            const lot = after_agg.lot;
            const acct = try sdup.of(lot.account orelse "");
            const sym = try sdup.of(lot.symbol);
            if (@abs(delta) > 0.000001) {
                // Direct-indexing suppression: for stock lots in
                // flagged accounts, sub-1% share drift is tracking-
                // error reconciliation, not real money flow. Skip
                // emitting a rollup_delta / drip_negative so the
                // attribution stays clean. Must apply the same
                // logic here as in `detectEdits` or the treatment
                // becomes inconsistent depending on whether the
                // strict key broke this week.
                if (lot.security_type == .stock) {
                    const denom = @max(@abs(after_agg.shares), @abs(before_agg.shares));
                    const rel = if (denom == 0) 0.0 else @abs(delta) / denom;
                    const is_di = if (opts.account_map) |am| am.isDirectIndexing(lot.account orelse "") else false;
                    if (is_di and rel <= direct_indexing_residual_tolerance_rel) continue;
                }

                const before_lot = before_agg.lot;
                const is_drip = lot.drip or before_lot.drip;
                const base_kind: ChangeKind = switch (lot.security_type) {
                    .stock => if (delta > 0)
                        (if (is_drip) ChangeKind.drip_confirmed else ChangeKind.rollup_delta)
                    else
                        ChangeKind.drip_negative,
                    .cash => .cash_delta,
                    .cd => .flagged, // CD face value shouldn't change on the same key
                    .option => .flagged,
                    else => .flagged,
                };
                // Opt-in reclassification: on accounts marked
                // `cash_is_contribution::true` in accounts.srf, a
                // positive cash_delta is actually new money arriving
                // (payroll ESPP accrual, direct 401k cash deposit,
                // etc.). Reclassify at this single point so every
                // downstream consumer — full report, per-account
                // summary, `compare` attribution — sees the same
                // classification. Negative cash_delta stays as noise
                // (a real withdrawal would need different semantics).
                const kind: ChangeKind = if (base_kind == .cash_delta and delta > 0) blk: {
                    if (opts.account_map) |am| {
                        if (am.cashIsContribution(lot.account orelse "")) break :blk .cash_contribution;
                    }
                    break :blk .cash_delta;
                } else base_kind;
                // Determine unit_value for stocks: prefer current cached price;
                // fall back to manual price::; fall back to open_price.
                //
                // `prices.get` is the raw retail-class API price → ratio applies.
                // `lot.price` (manual override) and `lot.open_price` are both
                // in the lot's own share-class terms (preadjusted) → ratio
                // must NOT be applied. See the "Pricing model" doc-block in
                // models/portfolio.zig.
                const unit_value: f64 = blk: {
                    if (lot.security_type == .stock) {
                        if (prices.get(lot.priceSymbol())) |p| break :blk lot.effectivePrice(p, false);
                        if (lot.price) |p| break :blk lot.effectivePrice(p, true);
                        break :blk lot.effectivePrice(lot.open_price, true);
                    }
                    // cash/cd: 1:1 with shares
                    break :blk 1.0;
                };
                try changes.append(allocator, .{
                    .kind = kind,
                    .symbol = sym,
                    .account = acct,
                    .security_type = lot.security_type,
                    .delta_shares = delta,
                    .unit_value = unit_value,
                });
            } else {
                // Same shares. Check for price:: field or other metadata changes.
                const before_lot = before_agg.lot;
                const a_price = lot.price;
                const b_price = before_lot.price;
                if ((a_price != null) != (b_price != null) or
                    (a_price != null and b_price != null and @abs(a_price.? - b_price.?) > 0.000001))
                {
                    try changes.append(allocator, .{
                        .kind = .price_only,
                        .symbol = sym,
                        .account = acct,
                        .security_type = lot.security_type,
                        .old_price = b_price orelse 0,
                        .new_price = a_price orelse 0,
                    });
                } else if ((lot.maturity_date == null) != (before_lot.maturity_date == null) or
                    (lot.maturity_date != null and before_lot.maturity_date != null and
                        !lot.maturity_date.?.eql(before_lot.maturity_date.?)))
                {
                    var old_buf: [10]u8 = undefined;
                    var new_buf: [10]u8 = undefined;
                    const old_str = if (before_lot.maturity_date) |d| (std.fmt.bufPrint(&old_buf, "{f}", .{d}) catch "????-??-??") else "(none)";
                    const new_str = if (lot.maturity_date) |d| (std.fmt.bufPrint(&new_buf, "{f}", .{d}) catch "????-??-??") else "(none)";
                    const detail = try std.fmt.allocPrint(allocator, "maturity_date {s} -> {s}", .{ old_str, new_str });
                    try changes.append(allocator, .{
                        .kind = .flagged,
                        .symbol = sym,
                        .account = acct,
                        .security_type = lot.security_type,
                        .detail = detail,
                    });
                }
                // Other edits (note, rate, etc.) are intentionally ignored to
                // keep noise low.
            }
        } else {
            // Key only in after → new lot.
            const lot = after_agg.lot;
            const acct = try sdup.of(lot.account orelse "");
            const sym = try sdup.of(lot.symbol);
            const kind: ChangeKind = switch (lot.security_type) {
                .stock => if (lot.drip) ChangeKind.new_drip_lot else ChangeKind.new_stock,
                .cash => .new_cash,
                .cd => .new_cd,
                .option => .new_option,
                else => .flagged,
            };
            // For fresh stock lots: value at open_price (that's literally the
            // money that went in). For cash: shares == dollars. For CDs:
            // face = shares × open_price.
            //
            // open_price is in the lot's own share-class terms (preadjusted),
            // so route it through effectivePrice with is_preadjusted=true to
            // avoid double-applying price_ratio. See the "Pricing model"
            // doc-block in models/portfolio.zig.
            const unit_value: f64 = switch (lot.security_type) {
                .stock => lot.effectivePrice(lot.open_price, true),
                .cash => 1.0,
                .cd => lot.open_price,
                .option => lot.open_price * lot.multiplier,
                else => lot.open_price,
            };
            try changes.append(allocator, .{
                .kind = kind,
                .symbol = sym,
                .account = acct,
                .security_type = lot.security_type,
                .delta_shares = after_agg.shares,
                .unit_value = unit_value,
                .open_date = lot.open_date,
            });
        }
    }

    // Pass 2: keys in before but not in after → lot disappeared.
    var bit = before_map.iterator();
    while (bit.next()) |entry| {
        if (after_map.contains(entry.key_ptr.*)) continue;
        if (skip.contains(entry.key_ptr.*)) continue;
        const before_agg = entry.value_ptr.*;
        const lot = before_agg.lot;
        const acct = try sdup.of(lot.account orelse "");
        const sym = try sdup.of(lot.symbol);

        var kind: ChangeKind = .lot_removed;
        if (lot.security_type == .cd) {
            if (lot.maturity_date) |mat| {
                // "matured" if maturity_date <= as_of (i.e. NOT as_of.lessThan(mat))
                if (!as_of.lessThan(mat)) {
                    kind = .cd_matured;
                } else {
                    kind = .cd_removed_early;
                }
            } else {
                kind = .cd_removed_early; // no maturity — treat as flagged-ish
            }
        }

        try changes.append(allocator, .{
            .kind = kind,
            .symbol = sym,
            .account = acct,
            .security_type = lot.security_type,
            .face_value = before_agg.shares * (if (lot.security_type == .cd) lot.open_price else 1.0),
            .maturity_date = lot.maturity_date,
            .delta_shares = -before_agg.shares,
        });
    }

    // Transfer reclassification pass: rewrite destination/source
    // Change kinds for records the caller passed in (typically the
    // diff between before-side and after-side
    // `transaction_log.srf`), and accumulate per-account cash
    // attribution so transferred cash doesn't double-count in
    // per-account totals. No-op when no records are supplied. See
    // `matchTransfers` docstring for the matching algorithm.
    var cash_attributed_by_account: std.StringHashMap(f64) = .init(allocator);
    if (opts.transfer_log) |records| {
        try matchTransfers(
            allocator,
            &changes,
            &cash_attributed_by_account,
            records,
        );
    }

    // Build per-account totals.
    var acct_totals = std.StringHashMap(Report.AccountTotal).init(allocator);

    for (changes.items) |c| {
        const gop = try acct_totals.getOrPut(c.account);
        if (!gop.found_existing) gop.value_ptr.* = .{};
        switch (c.kind) {
            .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => {
                // attributedValue() returns the unattributed residual
                // for cash-kind Changes (matchCashDestination drained
                // `transfer_attributed`); for non-cash kinds it equals
                // value(). Either way, this is "real new money on the
                // account."
                gop.value_ptr.new_money += c.attributedValue();
            },
            .new_drip_lot, .drip_confirmed => {
                gop.value_ptr.drip_confirmed += c.value();
            },
            .rollup_delta => {
                gop.value_ptr.rollup += c.value();
            },
            .cash_delta => {
                gop.value_ptr.cash_delta += c.attributedValue();
            },
            .cd_matured => {
                // Interest is computed lazily against cash_delta in print();
                // we don't add face value to new_money (that's not new money).
            },
            .partial_transfer_in => {
                // Residual (value() − transfer_attributed) flows into
                // new_money. The lot-destination matcher is the only
                // producer of partial_transfer_in (cash-dest uses the
                // per-account attribution bucket), so the residual
                // represents pre-existing cash that funded part of
                // the lot — a real contribution from the user.
                gop.value_ptr.new_money += c.attributedValue();
            },
            .transfer_in, .transfer_out, .unmatched_transfer => {
                // $0 contribution. Deliberately no-op.
            },
            else => {},
        }
    }

    // Note: cash-dest transfer attribution is already removed by
    // `attributedValue()` on each cash-side Change (the matcher
    // accumulates into `transfer_attributed`). No second subtraction
    // off `cash_attributed_by_account` needed here. The bucket is
    // still populated for downstream consumers (e.g. callers that
    // want a per-account view of attributed transfers) but isn't
    // used in the totals math.

    return .{
        .changes = try changes.toOwnedSlice(allocator),
        .account_totals = acct_totals,
        .cash_attributed_by_account = cash_attributed_by_account,
    };
}

// ── Transfer reclassification ────────────────────────────────

/// Absolute-dollar tolerance for matching a transfer record's `amount`
/// against a Change's `value()`. Within ±tolerance is a full match
/// (`transfer_in`); strictly below is a partial (`partial_transfer_in`);
/// strictly above is unmatched (with "amount exceeds …" detail).
///
/// $1 is chosen to absorb typical reconciliation rounding (broker
/// statements often show cents, portfolio.srf may round to whole
/// dollars for cash lots) without masking real discrepancies.
const transfer_amount_tolerance: f64 = 1.0;

/// Compute the slice of transfer records that are NEW in `after`
/// relative to `before` — i.e. records the matcher should consider
/// for the current diff. Records present in both logs are skipped
/// (they paired in their own diff cycle and shouldn't re-match).
///
/// `before` may be null (e.g. `transaction_log.srf` did not exist in
/// the before-side revision); in that case every record in `after`
/// is treated as new.
///
/// The returned slice is allocated in `arena` and its records
/// borrow from `after`'s record memory. Treat the slice as read-only
/// and tied to `after`'s lifetime.
///
/// Equality is `TransferRecord.eql` (total-field equality including
/// optional `note`). User edits to an existing record's any field
/// produce a "new" record from this function's POV; the matcher
/// then re-attempts pairing. See the `eql` doc comment for rationale.
fn diffTransferLogs(
    arena: std.mem.Allocator,
    before: ?*const transaction_log.TransactionLog,
    after: *const transaction_log.TransactionLog,
) ![]const transaction_log.TransferRecord {
    var out: std.ArrayList(transaction_log.TransferRecord) = .empty;
    errdefer out.deinit(arena);
    for (after.transfers) |a| {
        var found = false;
        if (before) |b| for (b.transfers) |bef| {
            if (a.eql(bef)) {
                found = true;
                break;
            }
        };
        if (!found) try out.append(arena, a);
    }
    return try out.toOwnedSlice(arena);
}

/// Reclassify Changes whose lot (or cash_delta) corresponds to a
/// recorded transfer. Walks `report.changes` and the in-window
/// transfer records together:
///
///   - For each record with `type::cash` (v1 only; `in_kind` always
///     emits `unmatched_transfer`):
///     - If `dest_lot` is a specific lot (`SYMBOL@DATE`): find the
///       matching `new_stock` / `new_drip_lot` / `new_cash` /
///       `new_cd` / `cash_contribution` Change with the same
///       (account, symbol) and flip its kind to `transfer_in` (full)
///       or `partial_transfer_in` (partial), recording
///       `transfer_attributed` / `transfer_note`. Emit
///       `unmatched_transfer` if no such Change exists, if another
///       record already consumed it, or if `amount` exceeds the
///       lot's value by more than tolerance.
///     - If `dest_lot::cash`: verify the `to` account's pooled
///       positive cash activity (new_cash + cash_delta +
///       cash_contribution summed) can cover the record's amount
///       (minus any prior cash-dest records on the same account).
///       Success appends a synthetic `transfer_in` Change for
///       display AND accumulates into
///       `cash_attributed_by_account[to]`, which the caller
///       subtracts from cash-side per-account totals. Failure
///       (budget underflow) emits `unmatched_transfer`.
///
///   - For the `from` side: try to find a matching negative
///     `cash_delta` or `lot_removed` on the sending account and
///     credit the transfer amount against it, flipping to
///     `transfer_out`. A missing `from` side is NOT unmatched — the
///     sending account might not appear in portfolio.srf at all
///     (external account) or its outflow may be masked by unrelated
///     activity (dividend posting offsetting the withdrawal). Only
///     destination mismatches surface as unmatched_transfer.
///
/// Records outside the window (`transfer < window_start` or
/// `transfer > window_end`) are NOT filtered here. The caller is
/// responsible for narrowing the slice to records that should be
/// considered for THIS diff. The production path uses
/// `diffTransferLogs` to pass only the records added to
/// `transaction_log.srf` since `before_rev`, regardless of their
/// `transfer::DATE`. This allows a user to back-date a record
/// (e.g. add a `transfer::2026-05-20` entry on 2026-05-23) and
/// have it pair against the working-copy diff that introduced it.
///
/// Populates `cash_attributed_by_account` (caller-owned) with the
/// per-account total of amounts matched to cash-destination records;
/// these amounts are subtracted from the cash bucket in the
/// per-account totals pass so transferred cash doesn't double-count.
fn matchTransfers(
    allocator: std.mem.Allocator,
    changes: *std.ArrayList(Change),
    cash_attributed_by_account: *std.StringHashMap(f64),
    records: []const transaction_log.TransferRecord,
) !void {
    // Bookkeeping: track which Change indices have already been
    // claimed by a transfer record to catch duplicates on the
    // lot-destination path.
    var consumed_lot_idx: std.AutoHashMap(usize, void) = .init(allocator);
    defer consumed_lot_idx.deinit();

    // Per-account cash budget: sum of positive cash activity
    // (new_cash + positive cash_delta + cash_contribution) on each
    // account. Cash-dest records draw from this pool in record
    // order. Underflow past tolerance → unmatched_transfer.
    var cash_budget: std.StringHashMap(f64) = .init(allocator);
    defer cash_budget.deinit();
    for (changes.items) |c| {
        const v = c.value();
        switch (c.kind) {
            .new_cash => {
                const gop = try cash_budget.getOrPut(c.account);
                if (!gop.found_existing) gop.value_ptr.* = 0;
                gop.value_ptr.* += v;
            },
            .cash_delta, .cash_contribution => if (v > 0) {
                const gop = try cash_budget.getOrPut(c.account);
                if (!gop.found_existing) gop.value_ptr.* = 0;
                gop.value_ptr.* += v;
            },
            else => {},
        }
    }

    // Walk transfer records in caller-supplied order. Caller is
    // responsible for filtering to only those records that should
    // be considered for THIS diff (typically: records new in the
    // after-side `transaction_log.srf` vs. the before-side).
    for (records) |rec| {
        if (rec.type == .in_kind) {
            try appendUnmatched(allocator, changes, rec, "in-kind transfers not yet supported in v1");
            continue;
        }

        switch (rec.dest_lot) {
            .lot => |dl| {
                try matchLotDestination(allocator, changes, &consumed_lot_idx, rec, dl);
            },
            .cash => {
                try matchCashDestination(allocator, changes, &cash_budget, cash_attributed_by_account, rec);
            },
        }

        tryMatchFromSide(changes, rec);
    }
}

/// Append a synthetic `unmatched_transfer` Change carrying the record's
/// raw amount + from/to + date + a reason string. Detail lives on
/// `transfer_note`; `amount` encodes into `delta_shares * unit_value`
/// as `amount * 1.0` so `value()` returns the transfer amount.
fn appendUnmatched(
    allocator: std.mem.Allocator,
    changes: *std.ArrayList(Change),
    rec: transaction_log.TransferRecord,
    reason: []const u8,
) !void {
    const from = try allocator.dupe(u8, rec.from);
    const to = try allocator.dupe(u8, rec.to);
    const note = try allocator.dupe(u8, reason);
    try changes.append(allocator, .{
        .kind = .unmatched_transfer,
        .symbol = "",
        .account = to, // "landed on" side for the Flagged display
        .security_type = .cash, // irrelevant — no lot attached
        .delta_shares = rec.amount,
        .unit_value = 1.0,
        .transfer_attributed = rec.amount,
        .transfer_note = note,
        .transfer_from = from,
        .transfer_date = rec.transfer,
    });
}

/// Find the Change matching `dest_lot` and flip its kind. Produces
/// an `unmatched_transfer` on any mismatch (not found, already
/// consumed, amount too big).
fn matchLotDestination(
    allocator: std.mem.Allocator,
    changes: *std.ArrayList(Change),
    consumed: *std.AutoHashMap(usize, void),
    rec: transaction_log.TransferRecord,
    dl: transaction_log.DestLot.LotRef,
) !void {
    // Find the first matching Change by (account, symbol, open_date)
    // that's:
    //   - A destination-side kind (new_* or cash_contribution on the
    //     `to` account); transfer_in / partial already consumed
    //     counts as "taken".
    //   - Not yet claimed by another transfer record.
    //
    // We don't have direct access to the underlying Lot's open_date
    // on the Change struct — it's encoded implicitly in the diff
    // (a `new_stock` Change has one lot on the `after` side with a
    // unique open_date). Since the matcher can't disambiguate
    // multiple lots of the same (account, symbol) opened on
    // different dates, we leave the matching keyed just on
    // (account, symbol) and accept the rare ambiguity. When two
    // lots of the same (account, symbol) appear in the same diff,
    // the user would need separate transfer records per lot — the
    // first record matches the first Change, the second matches the
    // second, etc.
    //
    // TODO: thread open_date through Change. For now, accept the
    // (account, symbol) key and first-match-wins.
    _ = dl.open_date; // consulted only for display (see report printer)

    var found_idx: ?usize = null;
    var saw_consumed = false;
    for (changes.items, 0..) |c, i| {
        if (!std.mem.eql(u8, c.account, rec.to)) continue;
        if (!std.mem.eql(u8, c.symbol, dl.symbol)) continue;
        const is_dest_kind = switch (c.kind) {
            .new_stock, .new_drip_lot, .new_cash, .new_cd, .cash_contribution => true,
            else => false,
        };
        if (!is_dest_kind) continue;
        if (consumed.contains(i)) {
            saw_consumed = true;
            continue;
        }
        found_idx = i;
        break;
    }

    if (found_idx) |i| {
        const c = &changes.items[i];
        const lot_value = c.value();
        if (rec.amount > lot_value + transfer_amount_tolerance) {
            // Amount exceeds lot by more than tolerance: emit
            // unmatched, leave the Change untouched.
            const buf = try std.fmt.allocPrint(
                allocator,
                "amount ${d:.2} exceeds destination lot {s}@ value ${d:.2}",
                .{ rec.amount, dl.symbol, lot_value },
            );
            try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
            return;
        }

        try consumed.put(i, {});
        // Copy the note from the record (if any) onto the Change.
        const note_copy: ?[]const u8 = if (rec.note) |n| try allocator.dupe(u8, n) else null;
        const from_copy = try allocator.dupe(u8, rec.from);

        if (@abs(rec.amount - lot_value) <= transfer_amount_tolerance) {
            c.kind = .transfer_in;
            c.transfer_attributed = lot_value; // full
        } else {
            c.kind = .partial_transfer_in;
            c.transfer_attributed = rec.amount; // residual = value() − amount
        }
        c.transfer_note = note_copy;
        c.transfer_from = from_copy;
        c.transfer_date = rec.transfer;
    } else if (saw_consumed) {
        const buf = try std.fmt.allocPrint(
            allocator,
            "destination lot {s}@ on account {s} already claimed by an earlier transfer record",
            .{ dl.symbol, rec.to },
        );
        try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
    } else {
        const buf = try std.fmt.allocPrint(
            allocator,
            "destination lot {s}@ not found on account {s}",
            .{ dl.symbol, rec.to },
        );
        try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
    }
}

/// Same as `appendUnmatched` but takes a pre-formatted note the
/// caller already owns (produced via `std.fmt.allocPrint`).
fn appendUnmatchedWithOwnedNote(
    allocator: std.mem.Allocator,
    changes: *std.ArrayList(Change),
    rec: transaction_log.TransferRecord,
    owned_note: []const u8,
) !void {
    const from = try allocator.dupe(u8, rec.from);
    const to = try allocator.dupe(u8, rec.to);
    try changes.append(allocator, .{
        .kind = .unmatched_transfer,
        .symbol = "",
        .account = to,
        .security_type = .cash,
        .delta_shares = rec.amount,
        .unit_value = 1.0,
        .transfer_attributed = rec.amount,
        .transfer_note = owned_note,
        .transfer_from = from,
        .transfer_date = rec.transfer,
    });
}

/// Verify the `to` account's cash budget has capacity for this
/// record, draw from it, and either attach to an existing cash
/// Change or append a synthetic one. The per-account attribution
/// bucket (`cash_attributed_by_account`) is what actually drives
/// totals math — the Change-level reclassification is for display.
fn matchCashDestination(
    allocator: std.mem.Allocator,
    changes: *std.ArrayList(Change),
    cash_budget: *std.StringHashMap(f64),
    cash_attributed_by_account: *std.StringHashMap(f64),
    rec: transaction_log.TransferRecord,
) !void {
    const budget_entry = cash_budget.getPtr(rec.to);
    const available = if (budget_entry) |p| p.* else 0.0;
    if (available < rec.amount - transfer_amount_tolerance) {
        const buf = try std.fmt.allocPrint(
            allocator,
            "destination cash increase ${d:.2} insufficient for transfer ${d:.2}",
            .{ available, rec.amount },
        );
        try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf);
        return;
    }

    // Draw from the budget. Running remainder stays on the budget
    // so later records on the same account see the correct
    // capacity.
    if (budget_entry) |p| p.* -= rec.amount;

    // Accumulate into per-account attribution bucket. The per-
    // account totals pass subtracts this from cash-side totals so
    // transferred cash doesn't double-count.
    const gop = try cash_attributed_by_account.getOrPut(rec.to);
    if (!gop.found_existing) gop.value_ptr.* = 0;
    gop.value_ptr.* += rec.amount;

    // Distribute the record amount across the destination account's
    // cash-side Changes by accumulating into each Change's
    // `transfer_attributed`. We deliberately do NOT flip the
    // Change's `kind`: a single cash Change can be drained by
    // multiple records (e.g. two transfers landing on the same
    // cash lot), and a single record can drain across multiple
    // cash Changes (when the user split the inflow into several
    // lots). The kind field can only hold one classification.
    //
    // Bumping `transfer_attributed` makes `attributedValue()`
    // return the unattributed residual — fully-attributed Changes
    // drop out of the "New contributions" section, audit's
    // "Large new lots" filter, and any other consumer that asks
    // "how much of this Change is real new money?". The summary
    // pass continues to use `cash_attributed_by_account` for its
    // per-account math — the two views agree because the same
    // amount is subtracted on both sides.
    var remaining = rec.amount;
    for (changes.items) |*c| {
        if (remaining <= 0) break;
        if (!std.mem.eql(u8, c.account, rec.to)) continue;
        const is_cash_kind = switch (c.kind) {
            .new_cash, .cash_contribution => true,
            .cash_delta => c.value() > 0,
            else => false,
        };
        if (!is_cash_kind) continue;
        const unattributed = c.value() - c.transfer_attributed;
        if (unattributed <= 0) continue;
        const draw = @min(unattributed, remaining);
        c.transfer_attributed += draw;
        remaining -= draw;
    }
    // `remaining > 0` here would indicate the budget pre-check
    // accepted a record we couldn't actually distribute. Possible
    // if the cash-side Changes' `value()` totals don't agree with
    // the `cash_budget` we built (they should — both are derived
    // from the same Changes). Leave it as silent over-credit on
    // the per-account bucket; the user-visible symptom would be
    // a small Joint-trust-style residual showing up in audit.

    // Append a synthetic transfer_in Change for Transfers-section
    // display.
    const from = try allocator.dupe(u8, rec.from);
    const to = try allocator.dupe(u8, rec.to);
    const note_copy: ?[]const u8 = if (rec.note) |n| try allocator.dupe(u8, n) else null;
    try changes.append(allocator, .{
        .kind = .transfer_in,
        .symbol = "",
        .account = to,
        .security_type = .cash,
        .delta_shares = rec.amount,
        .unit_value = 1.0,
        .transfer_attributed = rec.amount,
        .transfer_note = note_copy,
        .transfer_from = from,
        .transfer_date = rec.transfer,
    });
}

/// Best-effort: find a negative cash_delta or lot_removed on the
/// `from` account with |value| >= amount − tolerance; reclassify to
/// transfer_out. No-op (silent) if no such Change exists — the
/// sending side may not be in portfolio.srf.
fn tryMatchFromSide(
    changes: *std.ArrayList(Change),
    rec: transaction_log.TransferRecord,
) void {
    for (changes.items) |*c| switch (c.kind) {
        .cash_delta, .lot_removed => {
            if (!std.mem.eql(u8, c.account, rec.from)) continue;
            const abs_val = @abs(c.value());
            if (abs_val < rec.amount - transfer_amount_tolerance) continue;
            // Match: flip to transfer_out. We don't track
            // transfer_attributed on the from side (it's
            // decorative) but we record the counterpart date so
            // the Transfers section can cross-reference.
            c.kind = .transfer_out;
            c.transfer_attributed = rec.amount;
            c.transfer_date = rec.transfer;
            return;
        },
        else => {},
    };
}

// ── Output ───────────────────────────────────────────────────

fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void {
    const h_color = cli.CLR_HEADER;
    const pos_color = cli.CLR_POSITIVE;
    const mut_color = cli.CLR_MUTED;
    const warn_color = cli.CLR_WARNING;

    // Header
    try cli.setBold(out, color);
    try cli.printFg(out, color, h_color, "Portfolio contributions report\n", .{});
    try cli.setFg(out, color, mut_color);
    try out.writeAll("  ");
    try out.writeAll(label);
    try out.writeAll("\n\n");
    try cli.reset(out, color);

    // If nothing changed at all, say so explicitly and return.
    if (report.changes.len == 0) {
        try cli.printFg(out, color, mut_color, "  No changes detected.\n", .{});
        return;
    }

    // ── Section: New contributions / purchases ──
    try printSection(out, "New contributions / purchases", color, h_color);
    var any = false;
    var new_total: f64 = 0;
    for (report.changes) |c| switch (c.kind) {
        .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => {
            // Use attributedValue so a cash lot fully covered by a
            // transfer record (whose `transfer_attributed` equals
            // its `value()`) drops out, and a partially-covered
            // lot shows only the unattributed remainder.
            const residual = c.attributedValue();
            if (residual <= 0) continue;
            any = true;
            new_total += residual;
            try printChangeLine(out, c, color, pos_color);
        },
        .partial_transfer_in => {
            // Lot partially funded by a transfer: show the residual
            // (value() − transfer_attributed) in this section, with
            // the full lot value annotated so the user can see where
            // the transferred portion went.
            const residual = c.attributedValue();
            if (residual > 0) {
                any = true;
                new_total += residual;
                try printPartialTransferLine(out, c, color, pos_color, mut_color);
            }
        },
        else => {},
    };
    if (!any) try printNone(out, color, mut_color);
    if (any) try printTotalLine(out, "Total", new_total, color, h_color);
    try out.writeAll("\n");

    // ── Section: DRIP (confirmed) ──
    try printSection(out, "DRIP (confirmed — lots tagged drip::true)", color, h_color);
    any = false;
    var drip_total: f64 = 0;
    for (report.changes) |c| switch (c.kind) {
        .new_drip_lot, .drip_confirmed => {
            any = true;
            drip_total += c.value();
            try printChangeLine(out, c, color, pos_color);
        },
        else => {},
    };
    if (!any) try printNone(out, color, mut_color);
    if (any) try printTotalLine(out, "Total", drip_total, color, h_color);
    try out.writeAll("\n");

    // ── Section: Rollup share deltas (ambiguous) ──
    try printSection(out, "Rollup share deltas (DRIP or contribution)", color, h_color);
    any = false;
    var rollup_total: f64 = 0;
    for (report.changes) |c| switch (c.kind) {
        .rollup_delta => {
            any = true;
            rollup_total += c.value();
            try printChangeLine(out, c, color, pos_color);
        },
        else => {},
    };
    if (!any) try printNone(out, color, mut_color);
    if (any) try printTotalLine(out, "Total", rollup_total, color, h_color);
    try out.writeAll("\n");

    // ── Section: CD events ──
    try printSection(out, "CD events", color, h_color);
    any = false;
    var cd_interest_total: f64 = 0;
    for (report.changes) |c| switch (c.kind) {
        .cd_matured, .cd_removed_early => {
            any = true;
            // Find same-account cash_delta to compute implied interest.
            var matched_cash_delta: ?f64 = null;
            for (report.changes) |c2| {
                if (c2.kind == .cash_delta and std.mem.eql(u8, c2.account, c.account)) {
                    matched_cash_delta = c2.value();
                    break;
                }
            }
            const interest: ?f64 = if (c.kind == .cd_matured and matched_cash_delta != null)
                matched_cash_delta.? - c.face_value
            else
                null;
            if (interest) |i| if (i > 0) {
                cd_interest_total += i;
            };
            try printCdLine(out, c, interest, color);
        },
        else => {},
    };
    if (!any) try printNone(out, color, mut_color);
    if (any and cd_interest_total > 0) try printTotalLine(out, "Implied interest captured", cd_interest_total, color, h_color);
    try out.writeAll("\n");

    // ── Section: Cash deltas ──
    try printSection(out, "Cash deltas (raw balance changes)", color, h_color);
    any = false;
    for (report.changes) |c| switch (c.kind) {
        .cash_delta => {
            any = true;
            try printCashDeltaLine(out, c, report, color);
        },
        else => {},
    };
    if (!any) try printNone(out, color, mut_color);
    try out.writeAll("\n");

    // ── Section: Transfers (matched — not counted) ──
    //
    // Any record from `transaction_log.srf` that matched a
    // destination-side Change (or pooled cash on the receiving
    // account). These entries contribute $0 to attribution; the
    // destination's lot/cash value is reclassified as internal
    // money movement, not new contribution. Shown between Cash
    // deltas and Lot edits so the user can cross-reference them
    // against the raw cash movement above.
    var any_xfer = false;
    for (report.changes) |c| switch (c.kind) {
        .transfer_in, .partial_transfer_in, .transfer_out => {
            any_xfer = true;
            break;
        },
        else => {},
    };
    if (any_xfer) {
        try printSection(out, "Transfers (matched — not counted)", color, h_color);
        for (report.changes) |c| switch (c.kind) {
            .transfer_in, .partial_transfer_in, .transfer_out => {
                try printTransferLine(out, c, color, mut_color);
            },
            else => {},
        };
        try out.writeAll("\n");
    }

    // ── Section: Price-only updates ──
    var any_price = false;
    for (report.changes) |c| if (c.kind == .price_only) {
        any_price = true;
        break;
    };
    if (any_price) {
        try printSection(out, "Price-only updates (informational)", color, h_color);
        for (report.changes) |c| switch (c.kind) {
            .price_only => {
                try printPriceOnlyLine(out, c, color, mut_color);
            },
            else => {},
        };
        try out.writeAll("\n");
    }

    // ── Section: Lot edits (reclassified, not counted) ──
    //
    // Broken strict lot keys that matched a secondary key with
    // approximately-equal share totals. Shown as a muted section so
    // the user can verify the reclassification was correct (e.g. a CD
    // auto-renewed `open_date`, an account got renamed, or the
    // tax-loss account had shares tweaked during reconciliation).
    // Not counted as contributions, DRIP, or removals.
    var any_edit = false;
    for (report.changes) |c| if (c.kind == .lot_edited) {
        any_edit = true;
        break;
    };
    if (any_edit) {
        try printSection(out, "Lot edits (same position, key rewritten — not counted)", color, h_color);
        for (report.changes) |c| switch (c.kind) {
            .lot_edited => {
                var buf: [256]u8 = undefined;
                const msg = std.fmt.bufPrint(
                    &buf,
                    "  {s: <12} {s: <24} (strict key broke, shares unchanged)\n",
                    .{ c.symbol, c.account },
                ) catch "  (lot edit)\n";
                try cli.printFg(out, color, mut_color, "{s}", .{msg});
            },
            else => {},
        };
        try out.writeAll("\n");
    }

    // ── Section: Flagged ──
    var any_flag = false;
    for (report.changes) |c| switch (c.kind) {
        .flagged, .lot_removed, .drip_negative, .unmatched_transfer => any_flag = true,
        else => {},
    };
    if (any_flag) {
        try printSection(out, "Flagged for review", color, h_color);
        for (report.changes) |c| switch (c.kind) {
            .flagged, .lot_removed, .drip_negative => {
                try printFlaggedLine(out, c, color, warn_color);
            },
            .unmatched_transfer => {
                try printUnmatchedTransferLine(out, c, color, warn_color);
            },
            else => {},
        };
        try out.writeAll("\n");
    }

    // ── Summary ──
    try printSection(out, "Summary by account", color, h_color);
    var ait = report.account_totals.iterator();
    var total_new: f64 = 0;
    var total_drip: f64 = 0;
    var total_rollup: f64 = 0;
    var total_cd_int: f64 = 0;
    // Print per-account rows, recomputing CD interest as we go.
    while (ait.next()) |entry| {
        const acct = entry.key_ptr.*;
        const t = entry.value_ptr.*;
        // Recompute this account's CD interest from change list.
        var cd_int: f64 = 0;
        var face: f64 = 0;
        for (report.changes) |c| {
            if (!std.mem.eql(u8, c.account, acct)) continue;
            if (c.kind == .cd_matured) face += c.face_value;
        }
        if (face > 0 and t.cash_delta > 0) {
            const i = t.cash_delta - face;
            if (i > 0) cd_int = i;
        }
        total_new += t.new_money;
        total_drip += t.drip_confirmed;
        total_rollup += t.rollup;
        total_cd_int += cd_int;

        const acct_label = if (acct.len == 0) "(no account)" else acct;
        try out.print("  {s:<28}", .{acct_label});
        try printSummaryCell(out, " new", t.new_money, color);
        try printSummaryCell(out, " drip", t.drip_confirmed, color);
        try printSummaryCell(out, " rollup", t.rollup, color);
        try printSummaryCell(out, " cd-int", cd_int, color);
        try out.writeAll("\n");
    }
    try out.writeAll("\n");

    // Grand totals
    try cli.setBold(out, color);
    try cli.printFg(out, color, h_color, "Totals\n", .{});
    try out.print("  New contributions / purchases: {f}\n", .{Money.from(total_new)});
    try out.print("  DRIP (confirmed):              {f}\n", .{Money.from(total_drip)});
    try out.print("  Rollup share deltas:           {f}  (DRIP or contribution; can't distinguish)\n", .{Money.from(total_rollup)});
    if (total_cd_int > 0) {
        try out.print("  CD interest captured:          {f}\n", .{Money.from(total_cd_int)});
    }
    // Grand total across everything "money in"-ish. CD interest is
    // included because it's real return realized during the window,
    // even though it originated inside the portfolio.
    const grand = total_new + total_drip + total_rollup + total_cd_int;
    try cli.printFg(out, color, h_color, "  Grand total:                   {f}\n", .{Money.from(grand)});
}

fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void {
    try cli.setBold(out, color);
    try cli.printFg(out, color, hdr, "== {s} ==\n", .{title});
}

fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void {
    try cli.printFg(out, color, muted, "  (none)\n", .{});
}

fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void {
    try cli.printFg(out, color, hdr, "  {s}: {f}\n", .{ label, Money.from(v) });
}

fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !void {
    var share_buf: [32]u8 = undefined;
    var price_buf: [32]u8 = undefined;
    var val_buf: [32]u8 = undefined;
    const share_str = std.fmt.bufPrint(&share_buf, "{d:.4}", .{c.delta_shares}) catch "?";
    const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(c.unit_value)}) catch "$?";
    const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.value())}) catch "$?";

    const acct = if (c.account.len == 0) "(no account)" else c.account;
    try out.print("  {s:<14}{s:<24}", .{ c.symbol, acct });
    if (c.security_type == .cash) {
        try cli.printFg(out, color, pos, " {s}", .{val_str});
    } else {
        try out.print(" {s} shares × {s} = ", .{ share_str, price_str });
        try cli.printFg(out, color, pos, "{s}", .{val_str});
    }
    try out.writeAll("\n");
}

fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bool) !void {
    var mat_buf: [10]u8 = undefined;
    const mat_str = if (c.maturity_date) |d| (std.fmt.bufPrint(&mat_buf, "{f}", .{d}) catch "????-??-??") else "(no maturity)";
    const acct = if (c.account.len == 0) "(no account)" else c.account;
    const verb = switch (c.kind) {
        .cd_matured => "matured",
        .cd_removed_early => "removed EARLY",
        else => "removed",
    };
    try out.print("  {s:<14}{s:<24} {s:<16} face {f}  maturity {s}\n", .{
        c.symbol,
        acct,
        verb,
        Money.from(c.face_value),
        mat_str,
    });
    if (implied_interest) |i| {
        try cli.printFg(out, color, cli.CLR_POSITIVE, "  {s:<14}{s:<24} implied interest: {f}\n", .{ "", "", Money.from(i) });
    }
}

fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, color: bool) !void {
    const v = c.value();
    const acct = if (c.account.len == 0) "(no account)" else c.account;
    const sign = if (v >= 0) "+" else "-";
    try out.print("  {s:<14}{s:<24} cash ", .{ c.symbol, acct });
    try cli.printGainLoss(out, color, v, "{s}{f}", .{ sign, Money.from(@abs(v)) });

    // Hint if a CD matured in the same account.
    for (report.changes) |o| {
        if (o.kind == .cd_matured and std.mem.eql(u8, o.account, c.account)) {
            try cli.printFg(out, color, cli.CLR_MUTED, "   (may include CD maturity of {f})", .{Money.from(o.face_value)});
            break;
        }
    }
    try out.writeAll("\n");
}

fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
    const acct = if (c.account.len == 0) "(no account)" else c.account;
    try cli.printFg(out, color, muted, "  {s:<14}{s:<24} price {f} → {f}\n", .{
        c.symbol,
        acct,
        Money.from(c.old_price),
        Money.from(c.new_price),
    });
}

fn printFlaggedLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void {
    const acct = if (c.account.len == 0) "(no account)" else c.account;
    try cli.setFg(out, color, warn);
    switch (c.kind) {
        .flagged => {
            try out.print("  {s:<14}{s:<24} {s}", .{ c.symbol, acct, c.detail orelse "edited" });
        },
        .lot_removed => {
            try out.print("  {s:<14}{s:<24} {s} lot removed (face {f})", .{
                c.symbol, acct, @tagName(c.security_type), Money.from(c.face_value),
            });
        },
        .drip_negative => {
            try out.print("  {s:<14}{s:<24} shares decreased on existing lot ({f})", .{
                c.symbol, acct, Money.from(@abs(c.value())),
            });
        },
        else => {},
    }
    try cli.reset(out, color);
    try out.writeAll("\n");
}

fn printSummaryCell(out: *std.Io.Writer, label: []const u8, v: f64, color: bool) !void {
    try out.print("{s} ", .{label});
    if (v == 0) {
        try cli.printFg(out, color, cli.CLR_MUTED, "{s:>12}", .{"-"});
    } else {
        try cli.printFg(out, color, cli.CLR_POSITIVE, "{f}", .{Money.from(v).padRight(12)});
    }
}

// ── Transfer-related line printers ───────────────────────────

/// Two-line rendering for a matched transfer (either a destination
/// lot/cash match or a from-side match). Muted throughout — these
/// don't count toward attribution so visually step them back.
///
/// ```
///   2026-05-02  $145,300.00  Acct A → Acct B   (full attribution)
///                            → SYM@2026-05-03
/// ```
fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
    var date_buf: [10]u8 = undefined;
    const date_str = if (c.transfer_date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??";
    var val_buf: [32]u8 = undefined;
    const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?";

    const from_str = c.transfer_from orelse "?";
    // For transfer_in / partial on the destination side, c.account
    // is the `to` account. For transfer_out, c.account is the `from`
    // account (the sending Change). Label accordingly.
    const arrow_from = if (c.kind == .transfer_out) c.account else from_str;
    const arrow_to = if (c.kind == .transfer_out)
        "?" // from-side match has no explicit `to` carried through
    else
        c.account;

    const tag: []const u8 = switch (c.kind) {
        .transfer_in => "(full attribution)",
        .partial_transfer_in => "(partial attribution)",
        .transfer_out => "(from side)",
        else => "",
    };

    try cli.printFg(
        out,
        color,
        muted,
        "  {s}  {s}  {s} → {s}   {s}\n",
        .{ date_str, val_str, arrow_from, arrow_to, tag },
    );

    // Second line: destination detail. For lot destinations, show
    // the SYM@DATE. For cash, show "→ cash". For partial, show the
    // lot_value / attributed breakdown.
    if (c.kind == .partial_transfer_in) {
        const lot_value = c.value();
        const residual = lot_value - c.transfer_attributed;
        try cli.printFg(
            out,
            color,
            muted,
            "                           → {s} ({f} of {f} lot — {f} from pre-existing cash)\n",
            .{
                if (c.symbol.len > 0) c.symbol else "cash",
                Money.from(c.transfer_attributed),
                Money.from(lot_value),
                Money.from(residual),
            },
        );
    } else if (c.symbol.len > 0) {
        try cli.printFg(out, color, muted, "                           → {s}\n", .{c.symbol});
    } else {
        try cli.printFg(out, color, muted, "                           → cash\n", .{});
    }

    // Optional note from the record.
    if (c.transfer_note) |n| {
        if (n.len > 0) {
            try cli.printFg(out, color, muted, "                             ({s})\n", .{n});
        }
    }
}

/// Single-line rendering for an unmatched transfer record, shown
/// in the Flagged section. Keeps the layout similar to
/// `printFlaggedLine` for visual consistency.
fn printUnmatchedTransferLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void {
    var date_buf: [10]u8 = undefined;
    const date_str = if (c.transfer_date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??";
    var val_buf: [32]u8 = undefined;
    const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?";
    const from_str = c.transfer_from orelse "?";

    try cli.setFg(out, color, warn);
    try out.print("  ? Transfer {s}  {s}  {s} → {s}\n", .{ date_str, val_str, from_str, c.account });
    if (c.transfer_note) |n| {
        try out.print("    {s}\n", .{n});
    }
    try cli.reset(out, color);
}

/// Render a `partial_transfer_in` row in the "New contributions"
/// section. Shows the residual value (the portion that wasn't
/// attributable to the transfer) plus an annotation explaining the
/// split so the user can tell at a glance why the number is smaller
/// than the lot's face value.
fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8, muted: [3]u8) !void {
    const acct = if (c.account.len == 0) "(no account)" else c.account;
    const residual = c.attributedValue();
    const lot_value = c.value();
    const sym = if (c.symbol.len > 0) c.symbol else "cash";

    try out.print("  {s:<14}{s:<24}", .{ sym, acct });
    try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)});
    try cli.printFg(
        out,
        color,
        muted,
        "   (of {f} total — rest from transfer)\n",
        .{Money.from(lot_value)},
    );
}

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

const testing = std.testing;

fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs {
    var ctx: framework.RunCtx = .{
        .io = std.testing.io,
        .allocator = std.testing.allocator,
        .gpa = std.testing.allocator,
        // SAFETY: parseArgs doesn't touch environ_map.
        .environ_map = undefined,
        .config = .{ .cache_dir = "" },
        .svc = null,
        .globals = .{},
        .today = today,
        .now_s = 0,
        .color = false,
        // SAFETY: parseArgs doesn't write to out.
        .out = undefined,
    };
    return parseArgs(&ctx, args);
}

test "parseArgs: empty args produces both null" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    const parsed = try parseArgsForTest(today, &.{});
    try testing.expect(parsed.before == null);
    try testing.expect(parsed.after == null);
}

test "parseArgs: --since populates before as date_at_or_before" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--since", "2026-04-01" };
    const parsed = try parseArgsForTest(today, &args);
    switch (parsed.before.?) {
        .date_at_or_before => |d| try testing.expect(d.eql(zfin.Date.fromYmd(2026, 4, 1))),
        else => try testing.expect(false),
    }
}

test "parseArgs: --since + --until populates both" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--since", "2026-04-01", "--until", "2026-05-01" };
    const parsed = try parseArgsForTest(today, &args);
    try testing.expect(parsed.before != null);
    try testing.expect(parsed.after != null);
}

test "parseArgs: --commit-after working" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--commit-after", "working" };
    const parsed = try parseArgsForTest(today, &args);
    try testing.expect(parsed.after.? == .working_copy);
}

test "parseArgs: --since + --commit-before is duplicate axis" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--since", "1W", "--commit-before", "HEAD" };
    try testing.expectError(error.DuplicateEndpoint, parseArgsForTest(today, &args));
}

test "parseArgs: --since after --until errors" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--since", "2026-05-01", "--until", "2026-04-01" };
    try testing.expectError(error.InvalidArg, parseArgsForTest(today, &args));
}

test "parseArgs: unknown flag errors" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--bogus", "value" };
    try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args));
}

test "computeReport: fresh stock purchase counts as new contribution" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("AAPL", 200.0);

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 180, .account = "Brokerage" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
    try std.testing.expectApproxEqAbs(@as(f64, 1800.0), report.changes[0].value(), 0.01);
}

test "computeReport: rollup_delta when shares increase on untagged lot" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("VBTLX", 9.79);

    const before = [_]Lot{
        .{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 9.79, .account = "DCP" },
    };
    const after = [_]Lot{
        .{ .symbol = "VBTLX", .shares = 1010, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 9.79, .account = "DCP" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    // drip::false on both sides → rollup_delta (ambiguous: DRIP or contribution)
    try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
    try std.testing.expectApproxEqAbs(@as(f64, 10.0), report.changes[0].delta_shares, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 97.9), report.changes[0].value(), 0.01);
}

test "computeReport: matured CD with maturity_date <= today" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{
        .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Sample IRA", .maturity_date = Date.fromYmd(2026, 4, 17) },
    };
    const after = [_]Lot{};

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.cd_matured, report.changes[0].kind);
    try std.testing.expectApproxEqAbs(@as(f64, 58000.0), report.changes[0].face_value, 0.01);
}

test "computeReport: CD removed before maturity flagged as early" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{
        .{ .symbol = "CD2", .shares = 50000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Brokerage", .maturity_date = Date.fromYmd(2027, 1, 1) },
    };
    const after = [_]Lot{};

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.cd_removed_early, report.changes[0].kind);
}

test "computeReport: price-only update not classified as cash flow" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{
        .{ .symbol = "NON40OR52", .shares = 5000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9), .account = "401k" },
    };
    const after = [_]Lot{
        .{ .symbol = "NON40OR52", .shares = 5000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18), .account = "401k" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.price_only, report.changes[0].kind);
    try std.testing.expectApproxEqAbs(@as(f64, 161.71), report.changes[0].old_price, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 169.07), report.changes[0].new_price, 0.01);
}

test "computeReport: CD matured + cash increase -> implied interest available" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    // Before: CD $58k + cash $3k. After: no CD, cash $62.5k.
    const before = [_]Lot{
        .{ .symbol = "CDX", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2026, 4, 17) },
        .{ .symbol = "CASH", .shares = 3024.66, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
    };
    const after = [_]Lot{
        .{ .symbol = "CASH", .shares = 62510.95, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    // Expect: 1 cd_matured, 1 cash_delta
    var n_matured: usize = 0;
    var n_cash: usize = 0;
    var cash_delta: f64 = 0;
    var face: f64 = 0;
    for (report.changes) |c| switch (c.kind) {
        .cd_matured => {
            n_matured += 1;
            face += c.face_value;
        },
        .cash_delta => {
            n_cash += 1;
            cash_delta += c.value();
        },
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_matured);
    try std.testing.expectEqual(@as(usize, 1), n_cash);
    try std.testing.expectApproxEqAbs(@as(f64, 58000.0), face, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 62510.95 - 3024.66), cash_delta, 0.01);
    // Implied interest: cash_delta - face = 59486.29 - 58000 = 1486.29
    try std.testing.expectApproxEqAbs(@as(f64, 1486.29), cash_delta - face, 0.01);
}

test "computeReport: unit_value prefers current price over open_price for rollup delta" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SPY", 461.24);

    const before = [_]Lot{
        .{ .symbol = "SPY", .shares = 717.34, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Tax Loss" },
    };
    const after = [_]Lot{
        .{ .symbol = "SPY", .shares = 718.4848, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Tax Loss" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
    // 1.1448 * 461.24 ≈ 528.07
    try std.testing.expectApproxEqAbs(@as(f64, 528.07), report.changes[0].value(), 0.1);
}

test "computeReport: manual-priced lot (price:: no ticker) uses manual price" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    // No price in the map — should fall back to manual price::.

    const before = [_]Lot{
        .{ .symbol = "NON40OR52", .shares = 5070.866, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" },
    };
    const after = [_]Lot{
        .{ .symbol = "NON40OR52", .shares = 5075.077, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
    // (5075.077 - 5070.866) = 4.211 shares × 169.07 = 711.96
    try std.testing.expectApproxEqAbs(@as(f64, 711.96), report.changes[0].value(), 0.5);
}

// ── price_ratio regression tests ─────────────────────────────
//
// `lot.open_price` and `lot.price` (manual override) are both in the
// LOT's own share-class terms — i.e. preadjusted. Multiplying either
// by `lot.price_ratio` would double-apply the ratio. Only API-fetched
// prices from `prices.get(...)` (retail share class) need the ratio.
// See the "Pricing model" doc-block at the top of `models/portfolio.zig`.
//
// These four tests cover each of the three unit_value branches in
// `computeReport`'s new-lot and same-key-share-delta paths. Without
// the fix, the new-lot, manual-price, and open-price fallback tests
// inflate value by `price_ratio`× while the live-price test stays
// correct.

test "computeReport: new institutional stock lot uses preadjusted open_price (no double-ratio)" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    // No live price — exercises the open_price fallback branch in
    // the new-lot path. open_price is in the LOT's institutional
    // share class, so the ratio must NOT be applied.

    const before = [_]Lot{};
    const after = [_]Lot{
        .{
            .symbol = "02315N402",
            .ticker = "VTTVX",
            .shares = 39.249,
            .open_date = Date.fromYmd(2026, 2, 26),
            .open_price = 140.92,
            .price_ratio = 6.6139,
            .account = "Sample 401(k)",
        },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
    // 39.249 × 140.92 = 5,531.18 (institutional value).
    // Buggy version produces 39.249 × 140.92 × 6.6139 ≈ 36,581.
    try std.testing.expectApproxEqAbs(@as(f64, 5531.18), report.changes[0].value(), 0.5);
}

test "computeReport: same-key share delta with no live price uses preadjusted open_price" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    // No price in the map — open_price fallback should fire.

    const before = [_]Lot{
        .{
            .symbol = "02315N402",
            .ticker = "VTTVX",
            .shares = 100,
            .open_date = Date.fromYmd(2026, 2, 26),
            .open_price = 140.92,
            .price_ratio = 6.6139,
            .account = "Sample 401(k)",
        },
    };
    const after = [_]Lot{
        .{
            .symbol = "02315N402",
            .ticker = "VTTVX",
            .shares = 110,
            .open_date = Date.fromYmd(2026, 2, 26),
            .open_price = 140.92,
            .price_ratio = 6.6139,
            .account = "Sample 401(k)",
        },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
    // 10 × 140.92 = 1,409.20 (institutional value of the share delta).
    // Buggy version produces 10 × 140.92 × 6.6139 ≈ 9,322.
    try std.testing.expectApproxEqAbs(@as(f64, 1409.20), report.changes[0].value(), 0.5);
}

test "computeReport: same-key share delta with manual price:: uses preadjusted manual price" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    // No price in the map — manual `price::` override should fire.
    // Manual prices are entered in the LOT's share class (what the
    // user sees on their statement), so they're preadjusted.

    const before = [_]Lot{
        .{
            .symbol = "02315N402",
            .ticker = "VTTVX",
            .shares = 100,
            .open_date = Date.fromYmd(2026, 2, 26),
            .open_price = 140.92,
            .price = 145.00,
            .price_ratio = 6.6139,
            .account = "Sample 401(k)",
        },
    };
    const after = [_]Lot{
        .{
            .symbol = "02315N402",
            .ticker = "VTTVX",
            .shares = 110,
            .open_date = Date.fromYmd(2026, 2, 26),
            .open_price = 140.92,
            .price = 145.00,
            .price_ratio = 6.6139,
            .account = "Sample 401(k)",
        },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
    // 10 × 145.00 = 1,450.00. Buggy version: 10 × 145.00 × 6.6139 ≈ 9,590.
    try std.testing.expectApproxEqAbs(@as(f64, 1450.00), report.changes[0].value(), 0.5);
}

test "computeReport: same-key share delta with live price applies ratio" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    // Live retail-class price — ratio MUST be applied to convert
    // to institutional NAV. This is the only branch that's correct
    // in the buggy code; lock it in so the fix doesn't regress.
    try prices.put("VTTVX", 21.30);

    const before = [_]Lot{
        .{
            .symbol = "02315N402",
            .ticker = "VTTVX",
            .shares = 100,
            .open_date = Date.fromYmd(2026, 2, 26),
            .open_price = 140.92,
            .price_ratio = 6.6139,
            .account = "Sample 401(k)",
        },
    };
    const after = [_]Lot{
        .{
            .symbol = "02315N402",
            .ticker = "VTTVX",
            .shares = 110,
            .open_date = Date.fromYmd(2026, 2, 26),
            .open_price = 140.92,
            .price_ratio = 6.6139,
            .account = "Sample 401(k)",
        },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind);
    // 10 × 21.30 × 6.6139 = 1,408.76 (institutional value).
    try std.testing.expectApproxEqAbs(@as(f64, 1408.76), report.changes[0].value(), 0.5);
}

test "computeReport: maturity_date change on same CD is flagged" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{
        .{ .symbol = "CDY", .shares = 87000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2026, 7, 15) },
    };
    const after = [_]Lot{
        .{ .symbol = "CDY", .shares = 87000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 7, 15) },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.flagged, report.changes[0].kind);
}

test "computeReport: CD open_date rewrite reclassified as edit, not new+removed" {
    // The classic CD auto-renewal scenario: shares and account stay
    // the same, but the `open_date` gets rewritten to the renewal
    // date. Prior behavior was a phantom $58k new_cd contribution
    // plus a $58k cd_removed_early. After edit detection: one
    // lot_edited, no contribution.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{
        .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 2, 25) },
    };
    const after = [_]Lot{
        // Same CD, rewritten open_date (e.g. renewal) — key broken.
        .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 4, 20), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 4, 20) },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);

    // Attribution must see zero contribution for this CD.
    var new_contrib: f64 = 0;
    var drip: f64 = 0;
    for (report.changes) |c| switch (c.kind) {
        .new_stock, .new_cash, .new_cd, .new_option => new_contrib += c.value(),
        .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
        else => {},
    };
    try std.testing.expectApproxEqAbs(@as(f64, 0), new_contrib, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0), drip, 0.01);
}

test "computeReport: stock open_price renormalized reclassified as edit" {
    // Reconciliation tweak: user updates `open_price` to match the
    // institutional-share-class NAV, leaving everything else alone.
    // Prior behavior: phantom new_stock for the full lot value.
    // After edit detection: lot_edited.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("TAXLOSS", 50.0);

    const before = [_]Lot{
        .{ .symbol = "TAXLOSS", .shares = 100, .open_date = Date.fromYmd(2026, 1, 15), .open_price = 45.0, .account = "Tax Loss" },
    };
    const after = [_]Lot{
        .{ .symbol = "TAXLOSS", .shares = 100, .open_date = Date.fromYmd(2026, 1, 15), .open_price = 48.0, .account = "Tax Loss" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
}

test "computeReport: symbol rename with ticker alias collapses to lot_edited" {
    // 2026-05-02 regression: user changed a tax-loss lot from
    // `symbol::SPY` to `symbol::DI-SPX, ticker::SPY` (direct-indexing
    // proxy) and tweaked shares by ~1% during reconciliation. Same
    // underlying SPY exposure, same account, same open_date /
    // open_price — should collapse to an edit, NOT a ~$327k phantom
    // contribution.
    //
    // Before the `priceSymbol()`-based secondary key, the raw-symbol
    // comparison treated "SPY" and "DI-SPX" as different positions
    // and emitted a `new_stock` + `lot_removed` pair worth the full
    // lot value (~715 × $461 = $330k).
    //
    // After the fix: one `lot_edited` for the identity continuity,
    // plus a `rollup_delta` for the ~6-share residual worth ~$3k.
    // The attribution total sees only the residual, not the full lot.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SPY", 461.24);

    const before = [_]Lot{
        .{
            .symbol = "SPY",
            .shares = 715.912037,
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 461.240208,
            .account = "Tax Loss",
        },
    };
    const after = [_]Lot{
        .{
            .symbol = "DI-SPX",
            .ticker = "SPY",
            .shares = 709.235272, // ~1% tweak during reconciliation
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 461.240208,
            .account = "Tax Loss",
            .price_ratio = 1.0,
        },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});

    // Expect one lot_edited + one residual share-delta change
    // (rollup_delta is used here because delta < 0 would be
    // drip_negative — but 709 < 715 means after < before, so
    // drip_negative). Actually delta = 709 - 715 = -6, so
    // drip_negative.
    var n_edit: usize = 0;
    var n_rollup: usize = 0;
    var n_drip_neg: usize = 0;
    var n_new_stock: usize = 0;
    var n_removed: usize = 0;
    var residual_value: f64 = 0;
    for (report.changes) |c| switch (c.kind) {
        .lot_edited => n_edit += 1,
        .rollup_delta => {
            n_rollup += 1;
            residual_value = c.value();
        },
        .drip_negative => {
            n_drip_neg += 1;
            residual_value = c.value();
        },
        .new_stock => n_new_stock += 1,
        .lot_removed => n_removed += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_edit);
    try std.testing.expectEqual(@as(usize, 0), n_new_stock);
    try std.testing.expectEqual(@as(usize, 0), n_removed);
    // Share count went DOWN, so we get a drip_negative for the
    // residual (not rollup_delta).
    try std.testing.expectEqual(@as(usize, 1), n_drip_neg);
    try std.testing.expectEqual(@as(usize, 0), n_rollup);
    // ~6.68 shares × 461.24 ≈ $3,080 — the real reconciliation-scale
    // movement, not the ~$330k phantom.
    try std.testing.expect(residual_value < 0); // drip_negative sign
    try std.testing.expect(@abs(residual_value) < 5_000); // nowhere near $330k
}

test "computeReport: direct_indexing account suppresses sub-1% residual" {
    // Tax-loss direct-indexing proxy: same symbol/account/key but
    // small share drift (0.5%) from tracking-error reconciliation.
    // With no account_map, the default 0.01% tolerance surfaces this
    // as a rollup_delta. With account_map flagging the account as
    // direct_indexing, the looser 1% tolerance swallows it — only
    // the lot_edited marker is emitted, no residual that would land
    // in the attribution total.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SPY", 461.24);

    const before = [_]Lot{
        .{
            .symbol = "SPY",
            .shares = 1000.0,
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 461.240208,
            .account = "Tax Loss",
        },
    };
    const after = [_]Lot{
        // Broken strict key (symbol+ticker alias rewrite) plus a
        // 0.5% share decrease from tracking-error reconciliation.
        .{
            .symbol = "DI-SPX",
            .ticker = "SPY",
            .shares = 995.0,
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 461.240208,
            .account = "Tax Loss",
            .price_ratio = 1.0,
        },
    };

    // Build an account_map flagging Tax Loss as direct-indexing.
    var am_entries = [_]analysis.AccountTaxEntry{
        .{
            .account = "Tax Loss",
            .tax_type = .taxable,
            .direct_indexing = true,
        },
    };
    const account_map = analysis.AccountMap{
        .entries = &am_entries,
        .allocator = allocator,
    };

    const report = try computeReport(
        allocator,
        &before,
        &after,
        &prices,
        Date.fromYmd(2026, 4, 21),
        .{ .account_map = &account_map },
    );

    var n_edit: usize = 0;
    var n_rollup_or_drip: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .lot_edited => n_edit += 1,
        .rollup_delta, .drip_negative, .drip_confirmed => n_rollup_or_drip += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_edit);
    // Looser tolerance swallows the 0.5% residual — no rollup/drip
    // leak into the attribution total.
    try std.testing.expectEqual(@as(usize, 0), n_rollup_or_drip);
}

test "computeReport: direct_indexing same-key drift suppressed in Pass 1" {
    // Tax-loss direct-indexing proxy with SAME strict key on both
    // sides (no rename this week) but small share drift from
    // tracking-error reconciliation. Pass 1 would normally emit a
    // drip_negative; with direct_indexing the 1% tolerance suppresses
    // it the same way detectEdits does.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SPY", 461.24);

    const before = [_]Lot{
        .{
            .symbol = "DI-SPX",
            .ticker = "SPY",
            .shares = 1000.0,
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 461.240208,
            .account = "Tax Loss",
        },
    };
    const after = [_]Lot{
        // Same strict key, 0.5% share decrease from tracking-error
        // reconciliation.
        .{
            .symbol = "DI-SPX",
            .ticker = "SPY",
            .shares = 995.0,
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 461.240208,
            .account = "Tax Loss",
        },
    };

    var am_entries = [_]analysis.AccountTaxEntry{
        .{
            .account = "Tax Loss",
            .tax_type = .taxable,
            .direct_indexing = true,
        },
    };
    const account_map = analysis.AccountMap{
        .entries = &am_entries,
        .allocator = allocator,
    };

    const report = try computeReport(
        allocator,
        &before,
        &after,
        &prices,
        Date.fromYmd(2026, 4, 21),
        .{ .account_map = &account_map },
    );

    // No rollup/drip — the share drift was tracking error and the
    // direct_indexing flag suppressed it.
    try std.testing.expectEqual(@as(usize, 0), report.changes.len);
}

test "computeReport: same-key drift without direct_indexing still surfaces (sanity)" {
    // Sanity check that the flag is what's doing the suppression:
    // same setup as above but without the account_map, so Pass 1
    // emits a drip_negative for the 0.5% drift.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SPY", 461.24);

    const before = [_]Lot{
        .{
            .symbol = "DI-SPX",
            .ticker = "SPY",
            .shares = 1000.0,
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 461.240208,
            .account = "Tax Loss",
        },
    };
    const after = [_]Lot{
        .{
            .symbol = "DI-SPX",
            .ticker = "SPY",
            .shares = 995.0,
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 461.240208,
            .account = "Tax Loss",
        },
    };

    const report = try computeReport(
        allocator,
        &before,
        &after,
        &prices,
        Date.fromYmd(2026, 4, 21),
        .{},
    );

    var n_drip_neg: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .drip_negative => n_drip_neg += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_drip_neg);
}

test "computeReport: direct_indexing tolerance still surfaces real contributions" {
    // Regression: the 1% tolerance should still catch a real
    // contribution. If the user transfers $100k into a multi-million
    // direct-indexing account (~1.2% of value), the residual should
    // surface as a rollup_delta so the attribution isn't silent about
    // real money flow.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SPY", 461.24);

    // 17,361 shares × $461 ≈ $8M (same scale as the user's real tax
    // loss account). A $100k contribution = ~217 shares = ~1.25%.
    const before = [_]Lot{
        .{
            .symbol = "DI-SPX",
            .ticker = "SPY",
            .shares = 17361.0,
            .open_date = Date.fromYmd(2026, 2, 25),
            .open_price = 461.240208,
            .account = "Tax Loss",
            .price_ratio = 1.0,
        },
    };
    const after = [_]Lot{
        // Strict-key break (different open_date — e.g. user
        // redated after a large contribution) AND a 1.25% share
        // increase. Tolerance at 1% fails the "swallow" check so
        // the residual surfaces.
        .{
            .symbol = "DI-SPX",
            .ticker = "SPY",
            .shares = 17578.0, // +217 shares, ≈ +1.25%
            .open_date = Date.fromYmd(2026, 5, 2),
            .open_price = 461.240208,
            .account = "Tax Loss",
            .price_ratio = 1.0,
        },
    };

    var am_entries = [_]analysis.AccountTaxEntry{
        .{
            .account = "Tax Loss",
            .tax_type = .taxable,
            .direct_indexing = true,
        },
    };
    const account_map = analysis.AccountMap{
        .entries = &am_entries,
        .allocator = allocator,
    };

    const report = try computeReport(
        allocator,
        &before,
        &after,
        &prices,
        Date.fromYmd(2026, 5, 2),
        .{ .account_map = &account_map },
    );

    var n_edit: usize = 0;
    var n_rollup: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .lot_edited => n_edit += 1,
        .rollup_delta => n_rollup += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_edit);
    // 1.25% is over the 1% tolerance, so we get a rollup_delta.
    try std.testing.expectEqual(@as(usize, 1), n_rollup);
}

test "computeReport: ticker-alias removed (CUSIP-like -> plain ticker) also collapses" {
    // Reverse direction: before has a CUSIP-style symbol with ticker
    // alias, after has the plain ticker with no alias. Both resolve
    // to the same `priceSymbol()` → edit, not new+removed.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("VTTHX", 27.78);

    const before = [_]Lot{
        .{
            .symbol = "00766V100", // fake CUSIP-style string
            .ticker = "VTTHX",
            .shares = 5000,
            .open_date = Date.fromYmd(2023, 1, 15),
            .open_price = 25.0,
            .account = "401k",
        },
    };
    const after = [_]Lot{
        .{
            .symbol = "VTTHX",
            .shares = 5000,
            .open_date = Date.fromYmd(2023, 1, 15),
            .open_price = 25.0,
            .account = "401k",
        },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
}

test "computeReport: different tickers stay distinct (no false collapse)" {
    // Sanity: VOO → VTI in the same account with a broken strict key
    // is NOT the same underlying position. Must not collapse into an
    // edit. Should classify as new + removed.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("VOO", 450.0);
    try prices.put("VTI", 230.0);

    const before = [_]Lot{
        .{
            .symbol = "VOO",
            .shares = 100,
            .open_date = Date.fromYmd(2024, 1, 15),
            .open_price = 400.0,
            .account = "IRA",
        },
    };
    const after = [_]Lot{
        .{
            .symbol = "VTI",
            .shares = 100,
            .open_date = Date.fromYmd(2024, 1, 15),
            .open_price = 200.0,
            .account = "IRA",
        },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});

    var n_edit: usize = 0;
    var n_new: usize = 0;
    var n_removed: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .lot_edited => n_edit += 1,
        .new_stock => n_new += 1,
        .lot_removed => n_removed += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 0), n_edit);
    try std.testing.expectEqual(@as(usize, 1), n_new);
    try std.testing.expectEqual(@as(usize, 1), n_removed);
}

test "computeReport: account rename is NOT collapsed (documented limitation)" {
    // Renaming an account string in portfolio.srf (e.g. "Brokerage" →
    // "Joint Brokerage") breaks the secondary key too, since that key
    // includes the account. Edit detection DOES NOT cover this case
    // — the rename looks indistinguishable from a transfer (closing
    // one account, opening another with the same positions). Account
    // renames must therefore be handled manually: either avoid them
    // during a review window, or accept the phantom attribution for
    // that week. Tracked in TODO.md.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("AAPL", 180.0);
    try prices.put("MSFT", 400.0);

    const before = [_]Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Brokerage" },
        .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 300.0, .account = "Brokerage" },
    };
    const after = [_]Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Joint Brokerage" },
        .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 300.0, .account = "Joint Brokerage" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});

    // Expectation: account rename collapses into regular new+removed
    // classification (NOT lot_edited). If this ever flips to 2/0/0,
    // edit detection has gained account-rename awareness — great,
    // but update this test and the TODO accordingly.
    var n_edit: usize = 0;
    var n_new: usize = 0;
    var n_removed: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .lot_edited => n_edit += 1,
        .new_stock => n_new += 1,
        .lot_removed => n_removed += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 0), n_edit);
    try std.testing.expectEqual(@as(usize, 2), n_new);
    try std.testing.expectEqual(@as(usize, 2), n_removed);
}

test "computeReport: big share delta with broken key emits lot_edited + residual rollup" {
    // Secondary key matches (same security_type + priceSymbol +
    // account) but the share total diverges significantly. This is
    // the "user broke the lot key AND had a real contribution in
    // the same window" case. The lot identity still collapses to an
    // edit, but the share delta surfaces as a rollup_delta so the
    // attribution total sees the real inflow — not the full lot
    // value as a phantom contribution.
    //
    // Prior behavior (1% share tolerance): fell through to
    // new_stock + lot_removed, over-counting by the full lot value.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("AAPL", 180.0);

    const before = [_]Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Brokerage" },
    };
    const after = [_]Lot{
        // Same symbol/account but BIG share delta and different
        // open_date. The 400-share delta is real new money; the
        // 100-share continuation is an edit.
        .{ .symbol = "AAPL", .shares = 500, .open_date = Date.fromYmd(2026, 4, 15), .open_price = 175.0, .account = "Brokerage" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});

    var n_edit: usize = 0;
    var n_rollup: usize = 0;
    var n_new: usize = 0;
    var n_removed: usize = 0;
    var rollup_value: f64 = 0;
    for (report.changes) |c| switch (c.kind) {
        .lot_edited => n_edit += 1,
        .rollup_delta => {
            n_rollup += 1;
            rollup_value += c.value();
        },
        .new_stock => n_new += 1,
        .lot_removed => n_removed += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_edit);
    try std.testing.expectEqual(@as(usize, 1), n_rollup);
    try std.testing.expectEqual(@as(usize, 0), n_new);
    try std.testing.expectEqual(@as(usize, 0), n_removed);
    // 400-share delta × $180 current price = $72k
    try std.testing.expectApproxEqAbs(@as(f64, 72_000.0), rollup_value, 1.0);
}

test "computeReport: tiny share drift emits lot_edited + residual rollup" {
    // Fractional DRIP share-count, e.g. 10.0 → 10.05 with a
    // reconciliation tweak and a key rewrite. Under the always-collapse
    // design, this produces lot_edited + a small rollup_delta for
    // the 0.05-share residual. The residual survives the 0.01% noise
    // tolerance because 0.5% > 0.01%.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SPY", 500.0);

    const before = [_]Lot{
        .{ .symbol = "SPY", .shares = 10.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 420.0, .account = "IRA" },
    };
    const after = [_]Lot{
        .{ .symbol = "SPY", .shares = 10.05, .open_date = Date.fromYmd(2023, 8, 1), .open_price = 430.0, .account = "IRA" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});

    var n_edit: usize = 0;
    var n_rollup: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .lot_edited => n_edit += 1,
        .rollup_delta => n_rollup += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_edit);
    try std.testing.expectEqual(@as(usize, 1), n_rollup);
}

test "computeReport: sub-noise share drift emits only lot_edited" {
    // If the share delta is below the residual-tolerance threshold
    // (0.01%), we emit only lot_edited — no spurious rollup_delta
    // from floating-point noise.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SPY", 500.0);

    const before = [_]Lot{
        .{ .symbol = "SPY", .shares = 1000.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 420.0, .account = "IRA" },
    };
    const after = [_]Lot{
        // 0.00005% drift (well under 0.01% tolerance)
        .{ .symbol = "SPY", .shares = 1000.0000005, .open_date = Date.fromYmd(2023, 8, 1), .open_price = 430.0, .account = "IRA" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind);
}

test "computeReport: new lot with drip::true classified as new_drip_lot" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "FAGIX", .shares = 10.0, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 11.00, .account = "Riley IRA", .drip = true },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.new_drip_lot, report.changes[0].kind);
    try std.testing.expectApproxEqAbs(@as(f64, 110.0), report.changes[0].value(), 0.01);
}

test "computeReport: new stock lot without drip flag is new_stock" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 180, .account = "Brokerage" },
    };
    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
}

test "computeReport: drip::true existing lot with shares increase is drip_confirmed" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("FAGIX", 11.00);

    const before = [_]Lot{
        .{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Riley IRA", .drip = true },
    };
    const after = [_]Lot{
        .{ .symbol = "FAGIX", .shares = 105, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Riley IRA", .drip = true },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.drip_confirmed, report.changes[0].kind);
    try std.testing.expectApproxEqAbs(@as(f64, 55.0), report.changes[0].value(), 0.01);
}

test "computeReport: per-account totals separate drip_confirmed from rollup" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("FAGIX", 11.00);
    try prices.put("VBTLX", 9.79);

    const before = [_]Lot{
        .{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true },
        .{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" },
    };
    const after = [_]Lot{
        .{ .symbol = "FAGIX", .shares = 110, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true },
        .{ .symbol = "VBTLX", .shares = 1020, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{});

    const t = report.account_totals.get("AcctA") orelse {
        try std.testing.expect(false);
        return;
    };
    // drip_confirmed: 10 * 11 = 110
    try std.testing.expectApproxEqAbs(@as(f64, 110.0), t.drip_confirmed, 0.01);
    // rollup: 20 * 9.79 = 195.8
    try std.testing.expectApproxEqAbs(@as(f64, 195.8), t.rollup, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0), t.new_money, 0.01);
}

// ── resolveEndpoints tests ───────────────────────────────────
//
// Only the legacy (no-flags) and --since-only branches that don't
// shell out to git can be unit-tested cheaply. The full flag paths
// (`--since`, `--since`+`--until`) depend on `git log --until=<DATE>`,
// which requires a real repo and is covered by `src/git.zig` tests
// plus manual smoke-testing.

test "resolveEndpoints: legacy dirty → HEAD vs working copy" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    var env = try std.testing.environ.createMap(std.testing.allocator);
    defer env.deinit();
    const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };

    const eps = try resolveEndpoints(std.testing.io, arena_state.allocator(), &env, repo, null, null, true, .verbose);
    try std.testing.expectEqualStrings("HEAD", eps.range.before_rev);
    try std.testing.expect(eps.range.after_rev == null);
    try std.testing.expect(std.mem.indexOf(u8, eps.label, "working copy against HEAD") != null);
}

test "resolveEndpoints: legacy clean → HEAD~1 vs HEAD" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    var env = try std.testing.environ.createMap(std.testing.allocator);
    defer env.deinit();
    const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };

    const eps = try resolveEndpoints(std.testing.io, arena_state.allocator(), &env, repo, null, null, false, .verbose);
    try std.testing.expectEqualStrings("HEAD~1", eps.range.before_rev);
    try std.testing.expectEqualStrings("HEAD", eps.range.after_rev.?);
    try std.testing.expect(std.mem.indexOf(u8, eps.label, "HEAD~1 against HEAD") != null);
}

test "short: long SHA truncates to 7 chars" {
    // Works for both SHA-1 (40) and SHA-256 (64). Use a 40-char
    // input as the common case; the function only cares that input
    // is >= 7 chars.
    const sha = "0123456789abcdef0123456789abcdef01234567";
    try std.testing.expectEqualStrings("0123456", short(sha));
}

test "short: SHA-256 length also truncates to 7" {
    // Forward-compat: same behavior regardless of hash algorithm.
    const sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
    try std.testing.expectEqualStrings("0123456", short(sha));
}

test "short: short input returned as-is" {
    try std.testing.expectEqualStrings("abc", short("abc"));
}

// ── matchTransfers tests ─────────────────────────────────────
//
// These exercise the transfer reclassification pipeline end-to-end
// via `computeReport(... .{ .transfer_log = &log, ... })`. Each test
// parses a small SRF fragment into a `TransactionLog`, wires it into
// `ReportOptions`, and asserts the resulting `Report.changes` kinds
// and per-account attribution.

test "diffTransferLogs: empty before, all records new" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const after = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
        \\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
        \\
    );

    const new_records = try diffTransferLogs(arena, null, &after);
    try std.testing.expectEqual(@as(usize, 2), new_records.len);
}

test "diffTransferLogs: identical before and after returns empty" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const before = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
        \\
    );
    const after = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
        \\
    );

    const new_records = try diffTransferLogs(arena, &before, &after);
    try std.testing.expectEqual(@as(usize, 0), new_records.len);
}

test "diffTransferLogs: only the new record returned" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const before = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
        \\
    );
    const after = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
        \\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
        \\
    );

    const new_records = try diffTransferLogs(arena, &before, &after);
    try std.testing.expectEqual(@as(usize, 1), new_records.len);
    try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, new_records[0].transfer.days);
}

test "diffTransferLogs: edited record treated as new" {
    // User changed the `from` field on a previously-recorded transfer.
    // Old form is in `before`, new form is in `after`. The new form
    // doesn't equal anything in before → returned as new. The old
    // form is in before but not after → silently dropped (its diff
    // cycle is over).
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const before = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Wrong Acct,to::Acct B,dest_lot::cash
        \\
    );
    const after = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
        \\
    );

    const new_records = try diffTransferLogs(arena, &before, &after);
    try std.testing.expectEqual(@as(usize, 1), new_records.len);
    try std.testing.expectEqualStrings("Acct A", new_records[0].from);
}

test "diffTransferLogs: out-of-order records still match correctly" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const before = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
        \\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
        \\
    );
    // After has the same two records but in reverse order.
    const after = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
        \\
    );

    const new_records = try diffTransferLogs(arena, &before, &after);
    try std.testing.expectEqual(@as(usize, 0), new_records.len);
}

test "diffTransferLogs: back-dated record added on top of existing log" {
    // Regression test for the original bug: user records a transfer
    // months after it actually happened. The before-side log has
    // some prior records; the after-side adds one with an old
    // transfer::DATE. That new record must be returned even though
    // its date predates everything in before.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const before = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-15,type::cash,amount:num:1000,from::Acct A,to::Acct B,dest_lot::cash
        \\
    );
    const after = try transaction_log.parseTransactionLogFile(arena,
        \\#!srfv1
        \\transfer::2026-05-15,type::cash,amount:num:1000,from::Acct A,to::Acct B,dest_lot::cash
        \\transfer::2026-01-15,type::cash,amount:num:9999,from::Old Acct,to::New Acct,dest_lot::cash
        \\
    );

    const new_records = try diffTransferLogs(arena, &before, &after);
    try std.testing.expectEqual(@as(usize, 1), new_records.len);
    try std.testing.expectEqual(@as(f64, 9999), new_records[0].amount);
}

test "matchTransfers: cash-to-cash happy path" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    // One cash lot appearing on Acct B (simulates a $5k cash top-up).
    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "cash", .shares = 5000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 3), .{
        .transfer_log = tlog.transfers,
    });

    // The new_cash Change stays as new_cash in the display (we don't
    // flip cash-side Changes because a single cash_delta can be
    // drained by multiple records). A synthetic transfer_in Change
    // is appended for Transfers-section display, and
    // `cash_attributed_by_account` carries the $5k subtraction for
    // attribution math.
    var n_new_cash: usize = 0;
    var n_transfer_in: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .new_cash => n_new_cash += 1,
        .transfer_in => n_transfer_in += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_new_cash);
    try std.testing.expectEqual(@as(usize, 1), n_transfer_in);

    // Attribution on Acct B: $5k new_cash minus $5k attribution bucket = $0.
    const t = report.account_totals.get("Acct B").?;
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
}

test "matchTransfers: lot destination happy path" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 100.0);

    // Brand new $8k stock lot on Acct B.
    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);
    try std.testing.expectApproxEqAbs(@as(f64, 8000.0), report.changes[0].transfer_attributed, 0.01);

    // Acct B totals: $0 new_money (transfer_in contributes 0).
    const t = report.account_totals.get("Acct B").?;
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
}

test "matchTransfers: partial attribution — transfer smaller than lot" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 100.0);

    // New $8k lot, but only $7k came from the transfer.
    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind);
    try std.testing.expectApproxEqAbs(@as(f64, 7000.0), report.changes[0].transfer_attributed, 0.01);
    // Residual = $8k − $7k = $1k, contributed to Acct B new_money.
    try std.testing.expectApproxEqAbs(@as(f64, 1000.0), report.changes[0].attributedValue(), 0.01);

    const t = report.account_totals.get("Acct B").?;
    try std.testing.expectApproxEqAbs(@as(f64, 1000.0), t.new_money, 0.01);
}

test "matchTransfers: sweep — lot destination + cash residual, both match" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 1.0);

    // $145,300 stock lot + $4,700 cash residual on Acct B.
    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 145300, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 1.0, .account = "Acct B" },
        .{ .symbol = "cash", .shares = 4700, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:145300,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
        \\transfer::2026-05-02,type::cash,amount:num:4700,from::Acct A,to::Acct B,dest_lot::cash
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    // Acct B new_money: $0 (both transfers fully attributed).
    const t = report.account_totals.get("Acct B").?;
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);

    // One transfer_in on the stock lot (kind flipped from new_stock).
    var n_stock_transfer_in: usize = 0;
    var n_cash_transfer_in: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .transfer_in => {
            if (c.security_type == .stock) n_stock_transfer_in += 1;
            if (c.security_type == .cash) n_cash_transfer_in += 1;
        },
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_stock_transfer_in);
    // One synthetic cash transfer_in from the cash-dest matcher.
    try std.testing.expectEqual(@as(usize, 1), n_cash_transfer_in);
}

test "matchTransfers: duplicate dest_lot emits unmatched" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 100.0);

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
    };

    // Two records pointing at the same (account, symbol). First matches;
    // second emits unmatched_transfer with "already claimed".
    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
        \\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    var n_transfer_in: usize = 0;
    var n_unmatched: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .transfer_in => n_transfer_in += 1,
        .unmatched_transfer => n_unmatched += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_transfer_in);
    try std.testing.expectEqual(@as(usize, 1), n_unmatched);
}

test "matchTransfers: missing dest_lot emits unmatched" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    // No new lots in the diff at all.
    const before = [_]Lot{};
    const after = [_]Lot{};

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.unmatched_transfer, report.changes[0].kind);
}

test "matchTransfers: amount exceeds lot value emits unmatched" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 100.0);

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
    };

    // $10k transfer against $8k lot.
    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    // Lot stays as new_stock; unmatched record appended.
    var n_new_stock: usize = 0;
    var n_unmatched: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .new_stock => n_new_stock += 1,
        .unmatched_transfer => n_unmatched += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_new_stock);
    try std.testing.expectEqual(@as(usize, 1), n_unmatched);
}

test "matchTransfers: cash insufficient emits unmatched" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    // Only $3k cash showed up on Acct B, but the record wants $5k.
    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "cash", .shares = 3000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    var n_unmatched: usize = 0;
    for (report.changes) |c| if (c.kind == .unmatched_transfer) {
        n_unmatched += 1;
    };
    try std.testing.expectEqual(@as(usize, 1), n_unmatched);
    // new_cash stays unchanged; $3k still counts as new_money.
    const t = report.account_totals.get("Acct B").?;
    try std.testing.expectApproxEqAbs(@as(f64, 3000.0), t.new_money, 0.01);
}

test "matchTransfers: same-day multi-cash records drain a single cash_delta" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    // $5k cash showed up on Acct B.
    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "cash", .shares = 5000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" },
    };

    // Two records ($2k + $3k = $5k).
    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:2000,from::Acct A,to::Acct B,dest_lot::cash
        \\transfer::2026-05-02,type::cash,amount:num:3000,from::Acct A,to::Acct B,dest_lot::cash
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    // Both records should match (budget fully consumed); no unmatched.
    var n_unmatched: usize = 0;
    var n_transfer_in: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .unmatched_transfer => n_unmatched += 1,
        .transfer_in => n_transfer_in += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 0), n_unmatched);
    try std.testing.expectEqual(@as(usize, 2), n_transfer_in);

    // Acct B new_money: $5k new_cash − $5k attribution = $0.
    const t = report.account_totals.get("Acct B").?;
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01);
}

test "matchTransfers: type::in_kind always emits unmatched" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 100.0);

    // Even if a matching lot exists, in_kind is rejected in v1.
    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    // new_stock stays as new_stock (not flipped); unmatched appended.
    var n_new_stock: usize = 0;
    var n_unmatched: usize = 0;
    for (report.changes) |c| switch (c.kind) {
        .new_stock => n_new_stock += 1,
        .unmatched_transfer => n_unmatched += 1,
        else => {},
    };
    try std.testing.expectEqual(@as(usize, 1), n_new_stock);
    try std.testing.expectEqual(@as(usize, 1), n_unmatched);
}

test "matchTransfers: back-dated record matches regardless of date" {
    // The matcher itself is date-agnostic now — the caller (typically
    // `prepareReport` via `diffTransferLogs`) is responsible for
    // narrowing the slice to records that should be considered for
    // this diff cycle. A record dated weeks before any "diff window"
    // pairs cleanly when passed to the matcher directly.
    //
    // This is the regression test for the back-dated-record-rejected
    // bug: user adds `transfer::2026-01-15` to transaction_log.srf
    // on 2026-05-04 to record a transfer that actually happened
    // months ago. The matcher must pair it.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 100.0);

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-01-15,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    // Record paired despite the months-earlier date.
    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);
}

test "matchTransfers: null transfer_log is a no-op (backward compat)" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 100.0);

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
    };

    // No transfer_log passed — baseline behavior with no reclassification.
    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});

    try std.testing.expectEqual(@as(usize, 1), report.changes.len);
    try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind);
}

test "matchTransfers: attribution excludes transferred amount" {
    // End-to-end: run through the same computeReport path that
    // `summarizeAttribution` consumes, and verify the attribution
    // line would be $0 for a fully-transferred lot.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 100.0);

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    // Replicate summarizeAttribution's logic directly.
    var new_contributions: f64 = 0;
    var drip: f64 = 0;
    for (report.changes) |c| switch (c.kind) {
        .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.value(),
        .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
        .partial_transfer_in => new_contributions += c.attributedValue(),
        else => {},
    };
    var cait = report.cash_attributed_by_account.iterator();
    while (cait.next()) |entry| new_contributions -= entry.value_ptr.*;

    try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), drip, 0.01);
}

// ── collectUnmatchedLargeLots tests ──────────────────────────
//
// These exercise the audit large-lot filter by building a `Report`
// via `computeReport` (with or without a transfer log) and feeding
// its changes directly to `collectUnmatchedLargeLots`. That skips
// the git + IO plumbing of `findUnmatchedLargeLots` while still
// covering the classification path the production code uses.

test "collectUnmatchedLargeLots: below threshold is silent" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 100.0);

    const before = [_]Lot{};
    // $5k lot — under the $10k threshold used by audit.
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct A" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
    const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);

    try std.testing.expectEqual(@as(usize, 0), lots.len);
}

test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 500.0);

    const before = [_]Lot{};
    // $50k stock lot, no transfer log, should surface.
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
    const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);

    try std.testing.expectEqual(@as(usize, 1), lots.len);
    try std.testing.expectEqualStrings("Acct A", lots[0].account);
    try std.testing.expectEqualStrings("SYM", lots[0].symbol);
    try std.testing.expectEqual(LotType.stock, lots[0].security_type);
    try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01);
    try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, lots[0].open_date.days);
}

test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "cash", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 1.0, .security_type = .cash, .account = "Acct A" },
    };

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
    const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);

    try std.testing.expectEqual(@as(usize, 1), lots.len);
    try std.testing.expectEqual(LotType.cash, lots[0].security_type);
    try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01);
}

test "collectUnmatchedLargeLots: matched via transfer log is silent" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 500.0);

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
    };

    // Transfer log fully covers the lot → kind flips to transfer_in.
    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:50000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    // Sanity: the lot should have been reclassified.
    try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);

    const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
    try std.testing.expectEqual(@as(usize, 0), lots.len);
}

test "collectUnmatchedLargeLots: cash-destination matched is silent" {
    // Regression for the user-visible bug: a $73,158.33 cash lot on
    // Sample Trust funded by a transfer record dated 2026-05-20 was
    // surfacing in audit's "Large new lots — confirm source" because
    // the cash matcher doesn't flip the original `new_cash` Change's
    // kind (it draws from `cash_attributed_by_account` instead).
    // Without subtracting that attribution, the audit filter
    // re-flagged a lot that's already explained.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .security_type = .cash, .shares = 73158.33, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-20,type::cash,amount:num:73158.33,from::Sample Source,to::Sample Trust,dest_lot::cash
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{
        .transfer_log = tlog.transfers,
    });

    // Cash-dest matching does NOT flip the original new_cash Change
    // (a single delta can be drained by multiple records). The
    // matcher records the attributed amount in
    // `cash_attributed_by_account` instead.
    var saw_new_cash = false;
    var saw_synthetic_transfer = false;
    for (report.changes) |c| switch (c.kind) {
        .new_cash => saw_new_cash = true,
        .transfer_in => saw_synthetic_transfer = true,
        else => {},
    };
    try std.testing.expect(saw_new_cash);
    try std.testing.expect(saw_synthetic_transfer);
    const attributed = report.cash_attributed_by_account.get("Sample Trust") orelse 0;
    try std.testing.expectEqual(@as(f64, 73158.33), attributed);

    // The audit filter must subtract the attribution and stay quiet.
    const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
    try std.testing.expectEqual(@as(usize, 0), lots.len);
}

test "collectUnmatchedLargeLots: cash-destination partial match surfaces residual only" {
    // A $50K cash lot with a $30K transfer attributed against it —
    // the residual $20K is "new contribution" and SHOULD surface
    // (above the $10K threshold). The filter reports the residual,
    // not the gross.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .security_type = .cash, .shares = 50000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-20,type::cash,amount:num:30000,from::Sample Source,to::Sample Trust,dest_lot::cash
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{
        .transfer_log = tlog.transfers,
    });

    const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
    try std.testing.expectEqual(@as(usize, 1), lots.len);
    try std.testing.expectEqual(@as(f64, 20000.0), lots[0].value);
}

test "collectUnmatchedLargeLots: cash-destination partial below threshold is silent" {
    // A $15K cash lot with a $10K transfer attributed → residual
    // $5K, below the $10K threshold → silent.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{};
    const after = [_]Lot{
        .{ .security_type = .cash, .shares = 15000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-20,type::cash,amount:num:10000,from::Sample Source,to::Sample Trust,dest_lot::cash
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{
        .transfer_log = tlog.transfers,
    });

    const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
    try std.testing.expectEqual(@as(usize, 0), lots.len);
}

test "collectUnmatchedLargeLots: no new lots → empty result" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const before = [_]Lot{};
    const after = [_]Lot{};

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
    const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
    try std.testing.expectEqual(@as(usize, 0), lots.len);
}

test "collectUnmatchedLargeLots: partial transfer still flags residual? No — full lot value counts" {
    // Partial transfers leave the Change as `partial_transfer_in`,
    // which the audit filter IGNORES (only new_* kinds pass the
    // `is_new_side` check). That's the correct behavior: the
    // unrecorded portion on a partial is typically small (pre-
    // existing cash) and already has an explicit transfer record
    // acknowledging the large movement. Surfacing it again would
    // double-nag.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const allocator = arena_state.allocator();
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SYM", 500.0);

    const before = [_]Lot{};
    // $50k lot, $45k from a transfer → partial_transfer_in with
    // $5k residual. Residual is below $10k threshold anyway, but
    // even if it weren't, the filter skips partial_transfer_in.
    const after = [_]Lot{
        .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" },
    };

    const tlog = try transaction_log.parseTransactionLogFile(allocator,
        \\#!srfv1
        \\transfer::2026-05-02,type::cash,amount:num:45000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03
        \\
    );

    const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{
        .transfer_log = tlog.transfers,
    });

    try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind);

    const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
    try std.testing.expectEqual(@as(usize, 0), lots.len);
}

test "shortSha: HEAD passes through unchanged" {
    try std.testing.expectEqualStrings("HEAD", shortSha("HEAD"));
    try std.testing.expectEqualStrings("HEAD~", shortSha("HEAD~"));
    try std.testing.expectEqualStrings("HEAD~3", shortSha("HEAD~3"));
}

test "shortSha: long SHA truncates to 7 chars" {
    try std.testing.expectEqualStrings("abcdef0", shortSha("abcdef0123456789"));
    try std.testing.expectEqualStrings("a1b2c3d", shortSha("a1b2c3d4e5f6789012345"));
}

test "shortSha: short input returned as-is" {
    try std.testing.expectEqualStrings("abc", shortSha("abc"));
    try std.testing.expectEqualStrings("abcdefg", shortSha("abcdefg")); // exactly 7
    try std.testing.expectEqualStrings("", shortSha(""));
}

test "specDisplayString: null yields '(unset)'" {
    var buf: [10]u8 = undefined;
    try std.testing.expectEqualStrings("(unset)", specDisplayString(null, &buf));
}

test "specDisplayString: working_copy yields 'working'" {
    var buf: [10]u8 = undefined;
    try std.testing.expectEqualStrings("working", specDisplayString(.{ .working_copy = {} }, &buf));
}

test "specDisplayString: git_ref returns ref verbatim" {
    var buf: [10]u8 = undefined;
    try std.testing.expectEqualStrings("HEAD", specDisplayString(.{ .git_ref = "HEAD" }, &buf));
    try std.testing.expectEqualStrings("main", specDisplayString(.{ .git_ref = "main" }, &buf));
}

test "specDisplayString: date_at_or_before formats date YYYY-MM-DD" {
    var buf: [10]u8 = undefined;
    const d = Date.fromYmd(2024, 3, 15);
    try std.testing.expectEqualStrings("2024-03-15", specDisplayString(.{ .date_at_or_before = d }, &buf));
}

test "specLabel: null spec returns resolved ref dup'd" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const result = try specLabel(arena, null, "abc1234");
    try std.testing.expectEqualStrings("abc1234", result);
}

test "specLabel: git_ref returns ref dup'd" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const result = try specLabel(arena, .{ .git_ref = "main" }, "ignored");
    try std.testing.expectEqualStrings("main", result);
}

test "specLabel: date renders 'commit at-or-before YYYY-MM-DD'" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const d = Date.fromYmd(2024, 3, 15);
    const result = try specLabel(arena, .{ .date_at_or_before = d }, "ignored");
    try std.testing.expectEqualStrings("commit at-or-before 2024-03-15", result);
}

test "specLabel: working_copy literal" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const result = try specLabel(arena, .{ .working_copy = {} }, "ignored");
    try std.testing.expectEqualStrings("working copy", result);
}

test "specLabelAfter: null spec + non-null resolved_ref returns resolved_ref" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const result = try specLabelAfter(arena, null, "HEAD");
    try std.testing.expectEqualStrings("HEAD", result);
}

test "specLabelAfter: null spec + null resolved_ref returns 'working copy'" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const result = try specLabelAfter(arena, null, null);
    try std.testing.expectEqualStrings("working copy", result);
}

test "specLabelAfter: spec set + null resolved_ref defaults to 'working'" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const result = try specLabelAfter(arena, .{ .working_copy = {} }, null);
    try std.testing.expectEqualStrings("working copy", result);
}

test "buildLabel: no date window, dirty -> 'Comparing working copy against HEAD'" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
    const result = try buildLabel(arena, range, null, null, true);
    try std.testing.expectEqualStrings("Comparing working copy against HEAD", result);
}

test "buildLabel: no date window, clean -> HEAD~1 against HEAD" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
    const result = try buildLabel(arena, range, null, null, false);
    try std.testing.expectEqualStrings("Working tree clean — comparing HEAD~1 against HEAD", result);
}

test "buildLabel: --since only, dirty -> against working copy" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
    const since = Date.fromYmd(2024, 3, 15);
    const result = try buildLabel(arena, range, since, null, true);
    try std.testing.expect(std.mem.indexOf(u8, result, "abc1234") != null);
    try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
    try std.testing.expect(std.mem.indexOf(u8, result, "working copy") != null);
}

test "buildLabel: --since only, clean -> against HEAD" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
    const since = Date.fromYmd(2024, 3, 15);
    const result = try buildLabel(arena, range, since, null, false);
    try std.testing.expect(std.mem.indexOf(u8, result, "against HEAD") != null);
    try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
}

test "buildLabel: --since + --until renders both dates and short SHAs" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = "def4567890123" };
    const since = Date.fromYmd(2024, 1, 15);
    const until = Date.fromYmd(2024, 3, 15);
    const result = try buildLabel(arena, range, since, until, false);
    try std.testing.expect(std.mem.indexOf(u8, result, "2024-01-15") != null);
    try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
    try std.testing.expect(std.mem.indexOf(u8, result, "abc1234") != null);
    try std.testing.expect(std.mem.indexOf(u8, result, "def4567") != null);
}

test "buildLabelFromSpecs: both date specs -> falls through to buildLabel" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const range = git.CommitRange{ .before_rev = "aaaaaaa1234567", .after_rev = "bbbbbbb1234567" };
    const before_d = Date.fromYmd(2024, 1, 15);
    const after_d = Date.fromYmd(2024, 3, 15);
    const result = try buildLabelFromSpecs(
        arena,
        range,
        .{ .date_at_or_before = before_d },
        .{ .date_at_or_before = after_d },
        false,
    );
    // Date-form path: uses buildLabel formatting
    try std.testing.expect(std.mem.indexOf(u8, result, "2024-01-15") != null);
    try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null);
}

test "buildLabelFromSpecs: non-date spec -> '<before> vs <after>' format" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const range = git.CommitRange{ .before_rev = "main", .after_rev = "feature" };
    const result = try buildLabelFromSpecs(
        arena,
        range,
        .{ .git_ref = "main" },
        .{ .git_ref = "feature" },
        false,
    );
    try std.testing.expectEqualStrings("main vs feature", result);
}

test "buildLabelFromSpecs: working_copy after -> 'working copy' literal" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null };
    const result = try buildLabelFromSpecs(
        arena,
        range,
        .{ .git_ref = "HEAD~1" },
        .{ .working_copy = {} },
        true,
    );
    try std.testing.expectEqualStrings("HEAD~1 vs working copy", result);
}

test "printChangeLine: stock change shows shares × price = value" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const c = Change{
        .kind = .new_stock,
        .symbol = "AAPL",
        .account = "Roth",
        .security_type = .stock,
        .delta_shares = 10,
        .unit_value = 150.0,
    };
    try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "Roth") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "shares") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "$150.00") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "$1,500.00") != null);
}

test "printChangeLine: cash change shows value only (no shares × price)" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const c = Change{
        .kind = .new_cash,
        .symbol = "CASH",
        .account = "Brokerage",
        .security_type = .cash,
        .delta_shares = 1000,
        .unit_value = 1.0,
    };
    try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "CASH") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "Brokerage") != null);
    // cash shouldn't show "shares ×"
    try std.testing.expect(std.mem.indexOf(u8, out, "shares ×") == null);
    try std.testing.expect(std.mem.indexOf(u8, out, "$1,000.00") != null);
}

test "printChangeLine: empty account shown as '(no account)'" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const c = Change{
        .kind = .new_stock,
        .symbol = "VTI",
        .account = "",
        .security_type = .stock,
        .delta_shares = 5,
        .unit_value = 200.0,
    };
    try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
    try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "(no account)") != null);
}

test "printSummaryCell: zero value renders muted dash" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try printSummaryCell(&w, "Drip", 0, false);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "Drip") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "-") != null);
}

test "printSummaryCell: nonzero value renders dollar amount" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try printSummaryCell(&w, "Drip", 250.50, false);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "Drip") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "$250.50") != null);
}

test "printSection: emits title with header style" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try printSection(&w, "Contributions", false, cli.CLR_POSITIVE);
    try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "Contributions") != null);
}

test "printNone: emits muted '(none)' line" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try printNone(&w, false, cli.CLR_MUTED);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "none") != null or std.mem.indexOf(u8, out, "None") != null);
}

test "printTotalLine: emits label and dollar amount" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try printTotalLine(&w, "Total:", 12_345.67, false, cli.CLR_POSITIVE);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "Total") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "$12,345.67") != null);
}

test "printPriceOnlyLine: shows old → new price" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const c = Change{
        .kind = .price_only,
        .symbol = "VTI",
        .account = "Roth",
        .security_type = .stock,
        .old_price = 100.0,
        .new_price = 110.0,
    };
    try printPriceOnlyLine(&w, c, false, cli.CLR_MUTED);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "$100.00") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "$110.00") != null);
}

test "printChangeLine: no ANSI when color=false" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const c = Change{
        .kind = .new_stock,
        .symbol = "AAPL",
        .account = "Roth",
        .security_type = .stock,
        .delta_shares = 10,
        .unit_value = 150.0,
    };
    try printChangeLine(&w, c, false, cli.CLR_POSITIVE);
    try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null);
}
