const std = @import("std");
const builtin = @import("builtin");
const zfin = @import("../root.zig");
const srf = @import("srf");
const history = @import("../history.zig");
const git = @import("../git.zig");
const framework = @import("framework.zig");
const stderr = @import("../stderr.zig");
pub const fmt = @import("../format.zig");

// ── Default CLI colors (match TUI default Monokai theme) ─────
pub const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive)
pub const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative)
pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted)
pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent)
pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill)
pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning)
pub const CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan — secondary legend items (TUI .info)

// ── ANSI color helpers ───────────────────────────────────────

pub fn setFg(out: *std.Io.Writer, c: bool, rgb: [3]u8) !void {
    if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]);
}

pub fn setBold(out: *std.Io.Writer, c: bool) !void {
    if (c) try fmt.ansiBold(out);
}

pub fn reset(out: *std.Io.Writer, c: bool) !void {
    if (c) try fmt.ansiReset(out);
}

pub fn setGainLoss(out: *std.Io.Writer, c: bool, value: f64) !void {
    if (c) {
        if (value >= 0)
            try fmt.ansiSetFg(out, CLR_POSITIVE[0], CLR_POSITIVE[1], CLR_POSITIVE[2])
        else
            try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]);
    }
}

/// Map a semantic StyleIntent to CLI ANSI color.
pub fn setStyleIntent(out: *std.Io.Writer, c: bool, intent: fmt.StyleIntent) !void {
    if (!c) return;
    switch (intent) {
        .normal => try reset(out, c),
        .muted => try setFg(out, c, CLR_MUTED),
        .positive => try setFg(out, c, CLR_POSITIVE),
        .negative => try setFg(out, c, CLR_NEGATIVE),
        .warning => try setFg(out, c, CLR_WARNING),
        .accent => try setFg(out, c, CLR_HEADER),
        .info => try setFg(out, c, CLR_INFO),
    }
}

// ── Styled print helpers ─────────────────────────────────────
//
// Collapse the common `setX; print(...); reset` triple into a single
// call. Every renderer used to spell out all three steps; these
// helpers keep the "set → write → reset" boundary intact while
// cutting line count roughly in half at the call site.

/// Set a foreground color, print a formatted string, reset.
pub fn printFg(
    out: *std.Io.Writer,
    c: bool,
    rgb: [3]u8,
    comptime fmt_str: []const u8,
    args: anytype,
) !void {
    try setFg(out, c, rgb);
    try out.print(fmt_str, args);
    try reset(out, c);
}

/// Set a bold attribute, print a formatted string, reset.
pub fn printBold(
    out: *std.Io.Writer,
    c: bool,
    comptime fmt_str: []const u8,
    args: anytype,
) !void {
    try setBold(out, c);
    try out.print(fmt_str, args);
    try reset(out, c);
}

/// Set a semantic-intent color, print a formatted string, reset.
pub fn printIntent(
    out: *std.Io.Writer,
    c: bool,
    intent: fmt.StyleIntent,
    comptime fmt_str: []const u8,
    args: anytype,
) !void {
    try setStyleIntent(out, c, intent);
    try out.print(fmt_str, args);
    try reset(out, c);
}

/// Set a sign-aware gain/loss color, print a formatted string, reset.
pub fn printGainLoss(
    out: *std.Io.Writer,
    c: bool,
    value: f64,
    comptime fmt_str: []const u8,
    args: anytype,
) !void {
    try setGainLoss(out, c, value);
    try out.print(fmt_str, args);
    try reset(out, c);
}

// ── Stderr helpers ───────────────────────────────────────────

// ── stderr writers (re-exports of `src/stderr.zig`) ─────────
//
// Best-effort, non-throwing writers. The implementations live in
// `src/stderr.zig` so the portfolio loader and TUI can use them
// without an "X calls into commands/" import smell. Re-exported
// here under the original names so the ~239 existing
// `cli.stderrPrint(...)` callers don't have to be touched.

pub const stderrPrint = stderr.print;
pub const stderrProgress = stderr.progress;
pub const stderrRateLimitWait = stderr.rateLimitWait;

/// Progress callback for loadAllPrices that prints to stderr.
/// Shared between the CLI portfolio command and TUI pre-fetch.
pub const LoadProgress = struct {
    io: std.Io,
    svc: *zfin.DataService,
    color: bool,
    /// Offset added to index for display (e.g. stock count when loading watch symbols).
    index_offset: usize,
    /// Grand total across all loadAllPrices calls (stocks + watch).
    grand_total: usize,

    fn onProgress(ctx: *anyopaque, index: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void {
        const self: *LoadProgress = @ptrCast(@alignCast(ctx));
        const display_idx = self.index_offset + index + 1;
        switch (status) {
            .fetching => {
                // Show rate-limit wait before the fetch.
                // Prices come from the candle pipeline; ask about
                // candles_daily so the wait reflects whichever
                // provider serves candles (currently Tiingo, no
                // rate limiter -- so this is effectively a no-op,
                // but stays correct if the provider gains a limiter
                // or the type's primary changes).
                if (self.svc.estimateWaitSeconds(.candles_daily)) |w| {
                    if (w > 0) stderrRateLimitWait(self.io, w, self.color);
                }
                stderrProgress(self.io, symbol, " (fetching)", display_idx, self.grand_total, self.color);
            },
            .cached => {
                stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color);
            },
            .fetched => {
                // Already showed "(fetching)" — no extra line needed
            },
            .failed_used_stale => {
                stderrProgress(self.io, symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color);
            },
            .failed => {
                stderrProgress(self.io, symbol, " FAILED", display_idx, self.grand_total, self.color);
            },
        }
    }

    pub fn callback(self: *LoadProgress) zfin.DataService.ProgressCallback {
        return .{
            .context = @ptrCast(self),
            .on_progress = onProgress,
        };
    }
};

/// Aggregate progress callback for parallel loading operations.
/// Displays a single updating line with progress bar.
pub const AggregateProgress = struct {
    io: std.Io,
    color: bool,
    last_phase: ?zfin.DataService.AggregateProgressCallback.Phase = null,
    last_completed: usize = 0,

    fn onProgress(ctx: *anyopaque, completed: usize, total: usize, phase: zfin.DataService.AggregateProgressCallback.Phase) void {
        const self: *AggregateProgress = @ptrCast(@alignCast(ctx));

        const phase_changed = self.last_phase == null or self.last_phase.? != phase;
        self.last_phase = phase;

        // Best-effort: stderr-write failures here would only mean
        // the user doesn't see a progress line. The download
        // itself proceeds. Catch + log at the boundary so the
        // vtable's `void` return is honored without 8 inline
        // `catch {}` patterns.
        draw(self, completed, total, phase, phase_changed) catch |err| {
            std.log.debug("AggregateProgress draw failed: {t}", .{err});
        };
    }

    fn draw(self: *AggregateProgress, completed: usize, total: usize, phase: zfin.DataService.AggregateProgressCallback.Phase, phase_changed: bool) !void {
        var buf: [256]u8 = undefined;
        var writer = std.Io.File.stderr().writer(self.io, &buf);
        const w = &writer.interface;

        switch (phase) {
            .cache_check => {},
            .server_sync => {
                if (completed != self.last_completed) {
                    if (self.color) try fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
                    try w.print("  Syncing from server... [{d}/{d}]\n", .{ completed, total });
                    if (self.color) try fmt.ansiReset(w);
                    try w.flush();
                    self.last_completed = completed;
                }
            },
            .provider_fetch => {
                if (phase_changed) {
                    if (self.color) try fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
                    try w.print("  Synced {d} from server, fetching remaining from providers...\n", .{completed});
                    if (self.color) try fmt.ansiReset(w);
                    try w.flush();
                }
            },
            .complete => {},
        }
    }

    pub fn callback(self: *AggregateProgress) zfin.DataService.AggregateProgressCallback {
        return .{
            .context = @ptrCast(self),
            .on_progress = onProgress,
        };
    }
};

/// Map a `RefreshPolicy` to per-call `FetchOptions`. Single-symbol
/// commands use this to thread `--refresh-data` through to
/// `getCandles`/`getDividends`/etc. The mapping is:
///
///   `.auto`  → `.{}`                          (default; respect TTL)
///   `.force` → `.{ .force_refresh = true }`   (ignore TTL, fetch fresh)
///   `.never` → `.{ .skip_network = true }`    (offline mode)
pub fn fetchOptionsFromPolicy(policy: framework.RefreshPolicy) zfin.FetchOptions {
    return switch (policy) {
        .auto => .{},
        .force => .{ .force_refresh = true },
        .never => .{ .skip_network = true },
    };
}

/// Unified price loading for both CLI and TUI.
/// Handles parallel server sync when ZFIN_SERVER is configured,
/// with sequential provider fallback for failures.
pub fn loadPortfolioPrices(
    io: std.Io,
    svc: *zfin.DataService,
    portfolio_syms: ?[]const []const u8,
    watch_syms: []const []const u8,
    refresh: framework.RefreshPolicy,
    color: bool,
) zfin.DataService.LoadAllResult {
    var aggregate = AggregateProgress{ .io = io, .color = color };
    var symbol_progress = LoadProgress{
        .io = io,
        .svc = svc,
        .color = color,
        .index_offset = 0,
        .grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len,
    };

    // Map RefreshPolicy → LoadAllConfig:
    //   .force → ignore TTL; incremental candle top-up (no wipe).
    //   .auto  → respect TTL, fetch on stale.
    //   .never → offline mode: never touch the network. Stale cache
    //            entries are returned; cache misses fail the symbol.
    const result = svc.loadAllPrices(
        portfolio_syms,
        watch_syms,
        .{
            .force_refresh = refresh == .force,
            .skip_network = refresh == .never,
            .color = color,
        },
        aggregate.callback(),
        symbol_progress.callback(),
    );

    // Print summary
    const total = symbol_progress.grand_total;
    const from_cache = result.cached_count;
    const from_server = result.server_synced_count;
    const from_provider = result.provider_fetched_count;
    const failed = result.failed_count;
    const stale = result.stale_count;

    if (from_cache == total) {
        printLoadSummary(io, color, .{ .total = total, .from_cache = from_cache, .from_server = from_server, .from_provider = from_provider, .failed = failed, .stale = stale });
    } else {
        printLoadSummary(io, color, .{ .total = total, .from_cache = from_cache, .from_server = from_server, .from_provider = from_provider, .failed = failed, .stale = stale });
    }

    return result;
}

pub const LoadSummaryStats = struct {
    total: usize,
    from_cache: usize,
    from_server: usize,
    from_provider: usize,
    failed: usize,
    stale: usize,
};

/// Print the per-load summary line. Best-effort: a stderr-write
/// failure here would only mean the user doesn't see the
/// "Loaded N symbols ..." line; the load itself already
/// succeeded. Catch + log at the boundary.
pub fn printLoadSummary(io: std.Io, color: bool, s: LoadSummaryStats) void {
    if (builtin.is_test) return;
    printLoadSummaryImpl(io, color, s) catch |err| {
        std.log.debug("printLoadSummary failed: {t}", .{err});
    };
}

fn printLoadSummaryImpl(io: std.Io, color: bool, s: LoadSummaryStats) !void {
    var buf: [256]u8 = undefined;
    var writer = std.Io.File.stderr().writer(io, &buf);
    const out = &writer.interface;

    if (s.from_cache == s.total) {
        if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
        try out.print("  Loaded {d} symbols from cache\n", .{s.total});
        if (color) try fmt.ansiReset(out);
    } else if (s.failed > 0) {
        if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
        if (s.stale > 0) {
            try out.print("  Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed, s.stale });
        } else {
            try out.print("  Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed });
        }
        if (color) try fmt.ansiReset(out);
    } else {
        if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
        if (s.from_server > 0 and s.from_provider > 0) {
            try out.print("  Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider });
        } else if (s.from_server > 0) {
            try out.print("  Loaded {d} symbols ({d} cached, {d} server)\n", .{ s.total, s.from_cache, s.from_server });
        } else {
            try out.print("  Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ s.total, s.from_cache, s.from_provider });
        }
        if (color) try fmt.ansiReset(out);
    }
    try out.flush();
}

// ── Portfolio loading ────────────────────────────────────────
//
// The actual loader lives in `src/portfolio_loader.zig` so the
// TUI can import it without depending on `commands/common.zig`
// (which is otherwise CLI-shaped: stderr printing, color helpers,
// progress trackers). What's left here is a single CLI-side
// convenience that bridges a `*RunCtx` to the loader's
// `(io, allocator, config, patterns)` signature, plus re-exports
// so existing CLI commands don't have to change their
// `cli.<thing>` references.

const portfolio_loader = @import("../portfolio_loader.zig");

pub const LoadedPortfolio = portfolio_loader.LoadedPortfolio;
pub const PortfolioData = portfolio_loader.PortfolioData;
pub const loadPortfolioFromConfig = portfolio_loader.loadPortfolioFromConfig;
pub const loadPortfolioFromPaths = portfolio_loader.loadPortfolioFromPaths;
pub const buildPortfolioData = portfolio_loader.buildPortfolioData;

/// Resolve `-p`/`--portfolio` patterns through `ctx`, then load the
/// union of all matched portfolio files. The one-stop loader for
/// CLI commands: returns `null` (with a stderr message already
/// printed) on any error path, including pattern resolution failure,
/// no-files-found, mixed-directory rejection, read errors, and parse
/// errors.
///
/// Caller must `deinit(allocator)` the returned `LoadedPortfolio`.
///
/// Thin wrapper over `portfolio_loader.loadPortfolioFromConfig`
/// that pulls `(io, allocator, config, patterns)` out of the
/// RunCtx. Both CLI dispatch and the TUI go through the same
/// `loadPortfolioFromConfig`, so the resulting `LoadedPortfolio`
/// is byte-identical regardless of which surface invoked it.
pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio {
    return portfolio_loader.loadPortfolioFromConfig(
        ctx.io,
        ctx.allocator,
        ctx.config,
        ctx.globals.portfolio_patterns,
        as_of,
    );
}

// ── As-of date parsing (shared by CLI --as-of and TUI date popup) ──

pub const AsOfParseError = error{
    InvalidFormat,
    EmptyUnit,
    UnknownUnit,
    ZeroQuantity,
};

/// Parse a user-supplied as-of string into an optional `Date`.
///
/// Return value: `null` means live (today's portfolio); a non-null
/// `Date` is the resolved absolute date the caller should look up in
/// the snapshot directory. Relative forms (`1M`, `3Y`, ...) are
/// converted here — callers receive the resolved date, not the
/// shortcut string.
///
/// Accepted forms (case-insensitive for keywords and unit letters):
///   - ""             → null      (empty = live)
///   - "live" / "now" → null
///   - "YYYY-MM-DD"   → explicit date
///   - "N[WMQY]"      → today − N units; calendar arithmetic
///
/// Units:
///   - W = weeks (subtract N * 7 days)
///   - M = months (calendar; Mar 31 - 1M → Feb 28/29)
///   - Q = quarters (3 months)
///   - Y = years (calendar; Feb 29 - 1Y → Feb 28)
///
/// `as_of` is injected rather than read from the clock so tests are
/// deterministic. In production call sites this is `fmt.todayDate(io)`.
///
/// Fractional forms like `1.5Y` are not accepted — keep the parser
/// small and unambiguous.
pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.Date {
    const s = std.mem.trim(u8, input, " \t\r\n");

    if (s.len == 0) return null;

    // Keyword forms.
    if (std.ascii.eqlIgnoreCase(s, "live") or std.ascii.eqlIgnoreCase(s, "now")) {
        return null;
    }

    // Explicit YYYY-MM-DD.
    if (s.len == 10 and s[4] == '-' and s[7] == '-') {
        return zfin.Date.parse(s) catch error.InvalidFormat;
    }

    // Relative: N[WMQY].
    // Digits prefix then a single unit letter.
    var i: usize = 0;
    while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) {}
    if (i == 0) return error.InvalidFormat;
    if (i >= s.len) return error.EmptyUnit;
    if (i + 1 != s.len) return error.InvalidFormat;

    // u16 is the widest quantity that all downstream ops (subtractYears,
    // subtractMonths, addDays) accept without further narrowing.
    const n = std.fmt.parseInt(u16, s[0..i], 10) catch return error.InvalidFormat;
    if (n == 0) return error.ZeroQuantity;

    const unit = std.ascii.toLower(s[i]);
    return switch (unit) {
        'w' => as_of.addDays(-@as(i32, n) * 7),
        'm' => as_of.subtractMonths(n),
        'q' => as_of.subtractMonths(n * 3),
        'y' => as_of.subtractYears(n),
        else => error.UnknownUnit,
    };
}

/// Human-readable explanation of why a given string failed to parse.
/// Caller-owned buffer; returns a slice. No trailing newline — the
/// caller is responsible for formatting the surrounding message.
pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 {
    return switch (err) {
        error.InvalidFormat => std.fmt.bufPrint(buf, "Invalid as-of value: {s}. Expected YYYY-MM-DD, N[WMQY] (e.g. 1M, 3Q, 2Y), or 'live'.", .{input}) catch input,
        error.EmptyUnit => std.fmt.bufPrint(buf, "As-of value {s} is missing a unit. Expected one of W, M, Q, Y.", .{input}) catch input,
        error.UnknownUnit => std.fmt.bufPrint(buf, "As-of value {s} has an unknown unit. Expected one of W (weeks), M (months), Q (quarters), Y (years).", .{input}) catch input,
        error.ZeroQuantity => std.fmt.bufPrint(buf, "As-of quantity must be at least 1 (got {s}).", .{input}) catch input,
    };
}

/// Parse a user-facing date argument that must resolve to a concrete
/// absolute date — no "live"/"now"/empty. Accepts the same grammar
/// as `parseAsOfDate` (`YYYY-MM-DD` or relative shortcuts like `1W`,
/// `1M`, `1Q`, `1Y`, case-insensitive) minus the null-producing
/// inputs. Used by commands where a date-argument bound to a
/// specific date makes sense but "live" doesn't — e.g. `compare`'s
/// positional args, `history --since`/`--until`, `snapshot --as-of`.
///
/// `as_of` is injected for test determinism. Production callers pass
/// `fmt.todayDate(io)`.
pub const RequiredDateError = AsOfParseError || error{LiveNotAllowed};

pub fn parseRequiredDate(input: []const u8, as_of: zfin.Date) RequiredDateError!zfin.Date {
    const parsed = try parseAsOfDate(input, as_of);
    return parsed orelse error.LiveNotAllowed;
}

/// Convenience pattern: parse a required date, print a helpful error
/// to stderr if it fails, and map every failure mode to a single
/// `error.InvalidDate`. Callers get a uniform error, stderr gets a
/// message that tells the user exactly what grammar is accepted
/// including the relative-shortcut syntax.
///
/// `as_of` is injected for test determinism.
pub fn parseRequiredDateOrStderr(
    io: std.Io,
    input: []const u8,
    as_of: zfin.Date,
    arg_label: []const u8,
) error{InvalidDate}!zfin.Date {
    return parseRequiredDate(input, as_of) catch |err| {
        var ebuf: [256]u8 = undefined;
        const msg = switch (err) {
            error.LiveNotAllowed => std.fmt.bufPrint(
                &ebuf,
                "Error: {s} must be a concrete date, not 'live'/'now'.\n",
                .{arg_label},
            ) catch "Error: invalid date\n",
            else => |e| blk: {
                var inner: [256]u8 = undefined;
                const detail = fmtAsOfParseError(&inner, input, e);
                break :blk std.fmt.bufPrint(
                    &ebuf,
                    "Error: {s}: {s} (expected YYYY-MM-DD or a relative shortcut like 1W/1M/1Q/1Y)\n",
                    .{ arg_label, detail },
                ) catch "Error: invalid date\n";
            },
        };
        stderrPrint(io, msg);
        return error.InvalidDate;
    };
}

/// Consume the value argument for a value-taking flag during a
/// command's `parseArgs` loop.
///
/// Call with `i` pointing at the flag token. On success, advances
/// `i.*` past the value (so the caller's `: (i += 1)` loop step
/// lands on the token after the value) and returns the value slice.
///
/// Enforces the two invariants every value-flag wants:
///   1. a value must follow the flag (not end-of-args), and
///   2. the value must not be flag-shaped (a leading `-` with more
///      characters after it), which almost always means the user
///      forgot the value and the next flag got silently swallowed
///      as the "value".
///
/// The lone `-` is deliberately ALLOWED through: it's the
/// conventional stdin/stdout sentinel (e.g. `import --wells-fargo
/// -`), not a flag. An empty-string value (`--flag ""`) is allowed
/// too (not flag-shaped); a deliberate empty argument is the
/// caller's to interpret.
///
/// On violation, prints a specific stderr message naming `flag`
/// (and the offending token) and returns `error.MissingFlagValue`;
/// `i.*` is left unchanged. Callers list `MissingFlagValue` in
/// their `meta.user_errors` so the dispatcher maps it to a clean
/// exit 1.
pub fn requireFlagValue(
    io: std.Io,
    cmd_args: []const []const u8,
    i: *usize,
    flag: []const u8,
) error{MissingFlagValue}![]const u8 {
    if (i.* + 1 >= cmd_args.len) {
        stderrPrint(io, "Error: ");
        stderrPrint(io, flag);
        stderrPrint(io, " requires a value\n");
        return error.MissingFlagValue;
    }
    const value = cmd_args[i.* + 1];
    if (value.len > 1 and value[0] == '-') {
        stderrPrint(io, "Error: ");
        stderrPrint(io, flag);
        stderrPrint(io, " requires a value, got flag: ");
        stderrPrint(io, value);
        stderrPrint(io, "\n");
        return error.MissingFlagValue;
    }
    i.* += 1;
    return value;
}

// ── Commit-spec parsing (shared by contributions / compare) ──

/// Re-export of `git.CommitSpec` so call sites already using `cli.*`
/// don't need a second import.
pub const CommitSpec = git.CommitSpec;

pub const CommitSpecError = error{
    Empty,
    InvalidFormat,
    /// Catch-all for a token that doesn't match any known commit-spec
    /// shape. Different from `InvalidFormat` in that the string
    /// could be a SHA or ref — git will decide at invocation time.
    /// We err on this only when the token has obviously wrong shape.
    UnknownForm,
};

/// Parse a user-facing commit spec into a `CommitSpec`.
///
/// Accepts (in priority order):
///   - case-insensitive `working` / `WORKING` / `wc` / `WC` /
///     `working-copy` → `.working_copy`
///   - `YYYY-MM-DD` → `.date_at_or_before`
///   - Relative date form (`1W`, `1M`, `1Q`, `1Y` — same grammar as
///     `--as-of`), resolved against `today` → `.date_at_or_before`
///   - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) → `.git_ref`
///   - Pure hex ≥ 7 chars → `.git_ref` (SHA, full or abbreviated)
///
/// Anything else is rejected as `UnknownForm`. Trimming applied.
///
/// `as_of` is injected for test determinism, matching
/// `parseAsOfDate`'s contract.
pub fn parseCommitSpec(input: []const u8, as_of: zfin.Date) CommitSpecError!CommitSpec {
    const s = std.mem.trim(u8, input, " \t\r\n");
    if (s.len == 0) return error.Empty;

    // Working-copy sentinel (case-insensitive).
    if (std.ascii.eqlIgnoreCase(s, "working") or
        std.ascii.eqlIgnoreCase(s, "wc") or
        std.ascii.eqlIgnoreCase(s, "working-copy"))
    {
        return .working_copy;
    }

    // YYYY-MM-DD — 10 chars, two dashes at fixed positions.
    if (s.len == 10 and s[4] == '-' and s[7] == '-') {
        const d = zfin.Date.parse(s) catch return error.InvalidFormat;
        return .{ .date_at_or_before = d };
    }

    // Relative date form (1W, 1M, 1Q, 1Y). Disambiguated from short
    // SHAs (both can lead with digits) by the trailing unit letter —
    // W/M/Q/Y case-insensitive. Without it, a token like "1234567"
    // could be either a 7-char abbreviated SHA or garbage; we treat
    // it as SHA and let git decide.
    if (s.len >= 2 and std.ascii.isDigit(s[0])) {
        const last = std.ascii.toLower(s[s.len - 1]);
        if (last == 'w' or last == 'm' or last == 'q' or last == 'y') {
            const resolved = parseAsOfDate(s, as_of) catch return error.InvalidFormat;
            if (resolved) |d| return .{ .date_at_or_before = d };
            return error.InvalidFormat;
        }
    }

    // HEAD / HEAD~N refs.
    if (std.mem.startsWith(u8, s, "HEAD")) {
        return .{ .git_ref = s };
    }

    // Pure hex with sensible length → treat as SHA; let git validate.
    if (s.len >= 7) {
        var all_hex = true;
        for (s) |c| {
            if (!std.ascii.isHex(c)) {
                all_hex = false;
                break;
            }
        }
        if (all_hex) return .{ .git_ref = s };
    }

    return error.UnknownForm;
}

/// Human-friendly explanation of a `parseCommitSpec` error.
pub fn fmtCommitSpecError(buf: []u8, input: []const u8, err: CommitSpecError) []const u8 {
    return switch (err) {
        error.Empty => std.fmt.bufPrint(buf, "Commit spec is empty.", .{}) catch "Commit spec is empty.",
        error.InvalidFormat => std.fmt.bufPrint(
            buf,
            "Commit spec {s} has a date-like shape but couldn't be parsed. Expected YYYY-MM-DD or N[WMQY].",
            .{input},
        ) catch input,
        error.UnknownForm => std.fmt.bufPrint(
            buf,
            "Commit spec {s} is not recognized. Accepts: 'working', YYYY-MM-DD, relative (1W/1M/1Q/1Y), HEAD / HEAD~N, or a 7+ hex SHA.",
            .{input},
        ) catch input,
    };
}

// ── parseCommitSpec tests ──────────────────────────────────

test "parseCommitSpec: working-copy sentinels" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    try std.testing.expect((try parseCommitSpec("working", today)) == .working_copy);
    try std.testing.expect((try parseCommitSpec("WORKING", today)) == .working_copy);
    try std.testing.expect((try parseCommitSpec("wc", today)) == .working_copy);
    try std.testing.expect((try parseCommitSpec("WC", today)) == .working_copy);
    try std.testing.expect((try parseCommitSpec("working-copy", today)) == .working_copy);
}

test "parseCommitSpec: YYYY-MM-DD → date" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    const spec = try parseCommitSpec("2026-05-04", today);
    switch (spec) {
        .date_at_or_before => |d| {
            try std.testing.expectEqual(@as(i16, 2026), d.year());
            try std.testing.expectEqual(@as(u8, 5), d.month());
            try std.testing.expectEqual(@as(u8, 4), d.day());
        },
        else => try std.testing.expect(false),
    }
}

test "parseCommitSpec: relative 1W → date" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    const spec = try parseCommitSpec("1W", today);
    switch (spec) {
        .date_at_or_before => |d| {
            // 1W ago from 2026-05-09 is 2026-05-02
            try std.testing.expectEqual(@as(i16, 2026), d.year());
            try std.testing.expectEqual(@as(u8, 5), d.month());
            try std.testing.expectEqual(@as(u8, 2), d.day());
        },
        else => try std.testing.expect(false),
    }
}

test "parseCommitSpec: HEAD and HEAD~N as git_ref" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    switch (try parseCommitSpec("HEAD", today)) {
        .git_ref => |r| try std.testing.expectEqualStrings("HEAD", r),
        else => try std.testing.expect(false),
    }
    switch (try parseCommitSpec("HEAD~1", today)) {
        .git_ref => |r| try std.testing.expectEqualStrings("HEAD~1", r),
        else => try std.testing.expect(false),
    }
    switch (try parseCommitSpec("HEAD~12", today)) {
        .git_ref => |r| try std.testing.expectEqualStrings("HEAD~12", r),
        else => try std.testing.expect(false),
    }
}

test "parseCommitSpec: SHA as git_ref" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    switch (try parseCommitSpec("6942020", today)) {
        .git_ref => |r| try std.testing.expectEqualStrings("6942020", r),
        else => try std.testing.expect(false),
    }
    switch (try parseCommitSpec("6942020abcdef1234567890", today)) {
        .git_ref => |r| try std.testing.expectEqualStrings("6942020abcdef1234567890", r),
        else => try std.testing.expect(false),
    }
}

test "parseCommitSpec: rejects unknown shapes" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    try std.testing.expectError(error.Empty, parseCommitSpec("", today));
    try std.testing.expectError(error.Empty, parseCommitSpec("   ", today));
    try std.testing.expectError(error.UnknownForm, parseCommitSpec("xyz", today)); // < 7 chars, not HEAD/working
    try std.testing.expectError(error.UnknownForm, parseCommitSpec("banana", today));
}

test "parseCommitSpec: trims whitespace" {
    const today = zfin.Date.fromYmd(2026, 5, 9);
    try std.testing.expect((try parseCommitSpec("  working  ", today)) == .working_copy);
}

test "requireFlagValue: returns value and advances index past it" {
    const args = [_][]const u8{ "--out", "snap.srf" };
    var i: usize = 0;
    const v = try requireFlagValue(std.testing.io, &args, &i, "--out");
    try std.testing.expectEqualStrings("snap.srf", v);
    try std.testing.expectEqual(@as(usize, 1), i);
}

test "requireFlagValue: missing value at end of args is rejected, index unchanged" {
    const args = [_][]const u8{"--out"};
    var i: usize = 0;
    try std.testing.expectError(error.MissingFlagValue, requireFlagValue(std.testing.io, &args, &i, "--out"));
    try std.testing.expectEqual(@as(usize, 0), i);
}

test "requireFlagValue: flag-shaped value is rejected (next flag not swallowed), index unchanged" {
    const args = [_][]const u8{ "--out", "--force" };
    var i: usize = 0;
    try std.testing.expectError(error.MissingFlagValue, requireFlagValue(std.testing.io, &args, &i, "--out"));
    try std.testing.expectEqual(@as(usize, 0), i);
}

test "requireFlagValue: empty-string value is accepted (not flag-shaped)" {
    const args = [_][]const u8{ "--out", "" };
    var i: usize = 0;
    const v = try requireFlagValue(std.testing.io, &args, &i, "--out");
    try std.testing.expectEqualStrings("", v);
    try std.testing.expectEqual(@as(usize, 1), i);
}

test "requireFlagValue: a value containing a non-leading dash is fine" {
    const args = [_][]const u8{ "--out", "year-end.srf" };
    var i: usize = 0;
    const v = try requireFlagValue(std.testing.io, &args, &i, "--out");
    try std.testing.expectEqualStrings("year-end.srf", v);
}

test "requireFlagValue: lone '-' stdin sentinel is accepted" {
    const args = [_][]const u8{ "--wells-fargo", "-" };
    var i: usize = 0;
    const v = try requireFlagValue(std.testing.io, &args, &i, "--wells-fargo");
    try std.testing.expectEqualStrings("-", v);
    try std.testing.expectEqual(@as(usize, 1), i);
}

/// Snap a requested snapshot date to the nearest earlier snapshot
/// that exists in `hist_dir`, printing CLI-friendly stderr messages
/// when resolution fails.
///
/// Thin wrapper over `history.resolveSnapshotDate` that bundles the
/// "no snapshot at or before X, earliest available is Y" hint that
/// both `projections --as-of` and `compare` surface to the user.
/// Returns the full `ResolvedSnapshot` so callers can distinguish
/// exact vs. inexact matches (compare uses this to print a muted
/// "snapped to …" notice, projections uses `actual != requested` to
/// drive the header).
///
/// On `error.NoSnapshotAtOrBefore` the stderr messages are emitted
/// and the error is propagated verbatim; callers typically map it to
/// their own command-level error (`error.NoSnapshot`,
/// `error.SnapshotNotFound`, etc.). Other errors propagate without a
/// stderr write — they indicate filesystem-level failures the caller
/// should surface itself.
///
/// Uses `arena` for the intermediate message strings; pass a
/// short-lived arena.
pub fn resolveSnapshotOrExplain(
    io: std.Io,
    arena: std.mem.Allocator,
    hist_dir: []const u8,
    requested: zfin.Date,
) !history.ResolvedSnapshot {
    return history.resolveSnapshotDate(io, arena, hist_dir, requested) catch |err| switch (err) {
        error.NoSnapshotAtOrBefore => {
            const msg = std.fmt.allocPrint(arena, "No snapshot at or before {f}.\n", .{requested}) catch "No snapshot at or before the requested date.\n";
            stderrPrint(io, msg);
            // Second look at the nearest table for the "later
            // available" hint. Cheap (filesystem scan, same dir).
            const nearest = history.findNearestSnapshot(io, hist_dir, requested) catch {
                stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n");
                return err;
            };
            if (nearest.later) |later| {
                const later_msg = std.fmt.allocPrint(arena, "Earliest available: {f} (later than requested).\n", .{later}) catch "A later snapshot exists but was not used.\n";
                stderrPrint(io, later_msg);
            } else {
                stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n");
            }
            return err;
        },
        else => |e| return e,
    };
}

/// Resolve an as-of date against either a native snapshot OR an
/// `imported_values.srf` row, with a stderr explanation when neither
/// source has data at-or-before the requested date.
///
/// Snapshot wins when both are available; imported is the fallback.
/// See `history.resolveAsOfDate` for the resolution rules.
///
/// Returns `anyerror` to match the underlying resolver — the
/// imported-values reader pulls in the full file-IO error universe.
pub fn resolveAsOfOrExplain(
    io: std.Io,
    arena: std.mem.Allocator,
    hist_dir: []const u8,
    requested: zfin.Date,
) anyerror!history.ResolvedAsOf {
    return history.resolveAsOfDate(io, arena, hist_dir, requested) catch |err| switch (err) {
        error.NoDataAtOrBefore => {
            const msg = std.fmt.allocPrint(arena, "No snapshot or imported_values entry at or before {f}.\n", .{requested}) catch "No data at or before the requested date.\n";
            stderrPrint(io, msg);
            stderrPrint(io, "Run `zfin snapshot` or populate history/imported_values.srf to enable as-of mode.\n");
            return err;
        },
        else => |e| return e,
    };
}

// ── Watchlist loading ────────────────────────────────────────

/// Load a watchlist SRF file containing symbol records.
/// Returns owned symbol strings. Returns null if file missing or empty.
pub fn loadWatchlist(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
    const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return null;
    defer allocator.free(file_data);

    const WatchEntry = struct { symbol: []const u8 };

    var reader = std.Io.Reader.fixed(file_data);
    var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return null;
    defer it.deinit();

    var syms: std.ArrayList([]const u8) = .empty;
    while (it.next() catch null) |fields| {
        const entry = fields.to(WatchEntry, .{}) catch continue;
        const duped = allocator.dupe(u8, entry.symbol) catch continue;
        syms.append(allocator, duped) catch {
            allocator.free(duped);
            continue;
        };
    }
    if (syms.items.len == 0) {
        syms.deinit(allocator);
        return null;
    }
    return syms.toOwnedSlice(allocator) catch null;
}

pub fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void {
    if (watchlist) |wl| {
        for (wl) |sym| allocator.free(sym);
        allocator.free(wl);
    }
}

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

test "setFg emits ANSI when color enabled" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try setFg(&w, true, CLR_POSITIVE);
    const out = w.buffered();
    // Should contain ESC[ sequence with RGB values
    try std.testing.expect(out.len > 0);
    try std.testing.expect(std.mem.startsWith(u8, out, "\x1b["));
}

test "setFg is no-op when color disabled" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try setFg(&w, false, CLR_POSITIVE);
    try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}

test "setBold emits ANSI when color enabled" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try setBold(&w, true);
    const out = w.buffered();
    try std.testing.expect(out.len > 0);
    try std.testing.expect(std.mem.startsWith(u8, out, "\x1b["));
}

test "setBold is no-op when color disabled" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try setBold(&w, false);
    try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}

test "reset emits ANSI when color enabled" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try reset(&w, true);
    const out = w.buffered();
    try std.testing.expect(out.len > 0);
    try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
}

test "reset is no-op when color disabled" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try reset(&w, false);
    try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}

test "setGainLoss uses positive color for gains" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try setGainLoss(&w, true, 10.0);
    const out = w.buffered();
    try std.testing.expect(out.len > 0);
    // Should contain the positive green color RGB
    try std.testing.expect(std.mem.indexOf(u8, out, "127") != null);
}

test "setGainLoss uses negative color for losses" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try setGainLoss(&w, true, -5.0);
    const out = w.buffered();
    try std.testing.expect(out.len > 0);
    // Should contain the negative red color RGB
    try std.testing.expect(std.mem.indexOf(u8, out, "224") != null);
}

test "setGainLoss is no-op when color disabled" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try setGainLoss(&w, false, 10.0);
    try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}

test "setGainLoss treats zero as positive" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try setGainLoss(&w, true, 0.0);
    const out = w.buffered();
    // Should use positive (green) color for zero
    try std.testing.expect(std.mem.indexOf(u8, out, "127") != null);
}

// ── parseAsOfDate tests ─────────────────────────────────────

test "parseAsOfDate: empty string is live" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    const r = try parseAsOfDate("", today);
    try std.testing.expect(r == null);
}

test "parseAsOfDate: whitespace-only is live" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    const r = try parseAsOfDate("   \t\n", today);
    try std.testing.expect(r == null);
}

test "parseAsOfDate: literal 'live' and 'now' (case-insensitive)" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    try std.testing.expect((try parseAsOfDate("live", today)) == null);
    try std.testing.expect((try parseAsOfDate("LIVE", today)) == null);
    try std.testing.expect((try parseAsOfDate("now", today)) == null);
    try std.testing.expect((try parseAsOfDate("Now", today)) == null);
}

test "parseAsOfDate: explicit YYYY-MM-DD" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    const r = try parseAsOfDate("2026-03-13", today);
    try std.testing.expect(r != null);
    try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 3, 13)));
}

test "parseAsOfDate: weeks subtracts 7*N days" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    const r = try parseAsOfDate("2W", today);
    // 2026-04-02 - 14 days = 2026-03-19
    try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 3, 19)));
}

test "parseAsOfDate: months uses calendar arithmetic" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    const r = try parseAsOfDate("1M", today);
    try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 3, 2)));
}

test "parseAsOfDate: month-end clamping" {
    // 2026-03-31 - 1 month = 2026-02-28 (non-leap)
    const today = zfin.Date.fromYmd(2026, 3, 31);
    const r = try parseAsOfDate("1M", today);
    try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 2, 28)));
}

test "parseAsOfDate: quarter = 3 months" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    const r = try parseAsOfDate("1Q", today);
    try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2026, 1, 2)));
    const r2 = try parseAsOfDate("2Q", today);
    try std.testing.expect(r2.?.eql(zfin.Date.fromYmd(2025, 10, 2)));
}

test "parseAsOfDate: years uses calendar arithmetic" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    const r = try parseAsOfDate("3Y", today);
    try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2023, 4, 2)));
}

test "parseAsOfDate: leap year clamping via years" {
    const today = zfin.Date.fromYmd(2024, 2, 29);
    const r = try parseAsOfDate("1Y", today);
    try std.testing.expect(r.?.eql(zfin.Date.fromYmd(2023, 2, 28)));
}

test "parseAsOfDate: unit letter is case-insensitive" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    const r_lower = try parseAsOfDate("1m", today);
    const r_upper = try parseAsOfDate("1M", today);
    try std.testing.expect(r_lower.?.eql(r_upper.?));
}

test "parseAsOfDate: invalid date format" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    try std.testing.expectError(error.InvalidFormat, parseAsOfDate("2026/03/13", today));
    // Digits-only with no unit falls through to the relative-form parser.
    // It's technically 8 digits with no unit letter, so EmptyUnit is correct.
    try std.testing.expectError(error.EmptyUnit, parseAsOfDate("20260313", today));
}

test "parseAsOfDate: missing unit" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    try std.testing.expectError(error.EmptyUnit, parseAsOfDate("3", today));
}

test "parseAsOfDate: unknown unit" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    try std.testing.expectError(error.UnknownUnit, parseAsOfDate("3X", today));
    try std.testing.expectError(error.UnknownUnit, parseAsOfDate("3D", today));
}

test "parseAsOfDate: zero quantity rejected" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    try std.testing.expectError(error.ZeroQuantity, parseAsOfDate("0M", today));
}

test "parseAsOfDate: quantity that overflows u16 is InvalidFormat" {
    // 70000 doesn't fit in u16; previously rejected via an arbitrary cap.
    // Now the underlying parseInt call rejects it as a format error.
    const today = zfin.Date.fromYmd(2026, 4, 2);
    try std.testing.expectError(error.InvalidFormat, parseAsOfDate("70000Y", today));
}

test "parseAsOfDate: large-but-valid quantity accepted" {
    // 100Y is silly but parses fine — no arbitrary cap.
    const today = zfin.Date.fromYmd(2026, 4, 2);
    const r = try parseAsOfDate("100Y", today);
    try std.testing.expect(r.?.eql(zfin.Date.fromYmd(1926, 4, 2)));
}

test "parseAsOfDate: garbage after digits" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    try std.testing.expectError(error.InvalidFormat, parseAsOfDate("3MM", today));
    try std.testing.expectError(error.InvalidFormat, parseAsOfDate("3 M", today));
}

test "parseAsOfDate: garbage before digits" {
    const today = zfin.Date.fromYmd(2026, 4, 2);
    try std.testing.expectError(error.InvalidFormat, parseAsOfDate("x3M", today));
}

test "fmtAsOfParseError: mentions the input and hint" {
    var buf: [256]u8 = undefined;
    const msg = fmtAsOfParseError(&buf, "2026/03/13", error.InvalidFormat);
    try std.testing.expect(std.mem.indexOf(u8, msg, "2026/03/13") != null);
    try std.testing.expect(std.mem.indexOf(u8, msg, "YYYY-MM-DD") != null);
}

test "fmtAsOfParseError: no trailing newline" {
    var buf: [256]u8 = undefined;
    const msg = fmtAsOfParseError(&buf, "bad", error.InvalidFormat);
    try std.testing.expect(msg.len > 0);
    try std.testing.expect(msg[msg.len - 1] != '\n');
}

// ── loadPortfolio / buildPortfolioData tests ─────────────────

test "loadPortfolioFromPaths: missing file returns null" {
    const io = std.testing.io;
    const paths = [_][]const u8{"/nonexistent/portfolio-never-exists.srf"};
    const result = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8));
    try std.testing.expect(result == null);
}

test "loadPortfolioFromPaths: malformed file returns null" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.writeFile(io, .{ .sub_path = "bad.srf", .data = "this is not srf format" });

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const path = try std.fs.path.join(std.testing.allocator, &.{ path_buf[0..dir_len], "bad.srf" });
    defer std.testing.allocator.free(path);

    const paths = [_][]const u8{path};
    const result = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8));
    try std.testing.expect(result == null);
}

test "loadPortfolioFromPaths: single-file happy path returns LoadedPortfolio with positions and syms" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const data =
        \\#!srfv1
        \\symbol::AAPL,shares:num:100,open_date::2024-01-15,open_price:num:150.00
        \\symbol::MSFT,shares:num:50,open_date::2024-02-20,open_price:num:300.00
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data });

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const path = try std.fs.path.join(std.testing.allocator, &.{ path_buf[0..dir_len], "portfolio.srf" });
    defer std.testing.allocator.free(path);

    const paths = [_][]const u8{path};
    var loaded = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
    defer loaded.deinit(std.testing.allocator);

    try std.testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
    try std.testing.expectEqual(@as(usize, 2), loaded.positions.len);
    try std.testing.expectEqual(@as(usize, 2), loaded.syms.len);
}

test "loadPortfolioFromPaths: today value flows through to position computation" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Lot opens 2024-06-01. With today=2024-01-01 (before open), the
    // position record exists but with 0 open shares. With today=2025-01-01
    // (after open), shares = 100.
    const data =
        \\#!srfv1
        \\symbol::AAPL,shares:num:100,open_date::2024-06-01,open_price:num:150.00
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data });

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const path = try std.fs.path.join(std.testing.allocator, &.{ path_buf[0..dir_len], "portfolio.srf" });
    defer std.testing.allocator.free(path);

    const paths = [_][]const u8{path};
    // today before open_date → position exists but no open shares
    var loaded_before = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2024, 1, 1)) orelse return error.TestUnexpectedResult;
    defer loaded_before.deinit(std.testing.allocator);
    try std.testing.expectEqual(@as(usize, 1), loaded_before.positions.len);
    try std.testing.expectApproxEqAbs(@as(f64, 0), loaded_before.positions[0].shares, 0.01);
    try std.testing.expectEqual(@as(u32, 0), loaded_before.positions[0].open_lots);

    // today after open_date → 100 shares open
    var loaded_after = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2025, 1, 1)) orelse return error.TestUnexpectedResult;
    defer loaded_after.deinit(std.testing.allocator);
    try std.testing.expectEqual(@as(usize, 1), loaded_after.positions.len);
    try std.testing.expectApproxEqAbs(@as(f64, 100), loaded_after.positions[0].shares, 0.01);
    try std.testing.expectEqual(@as(u32, 1), loaded_after.positions[0].open_lots);
}

test "loadPortfolioFromPaths: empty path slice returns null" {
    const io = std.testing.io;
    const empty: []const []const u8 = &.{};
    const result = loadPortfolioFromPaths(io, std.testing.allocator, empty, zfin.Date.fromYmd(2026, 5, 8));
    try std.testing.expect(result == null);
}

test "loadPortfolioFromPaths: union-merges lots from two files" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const data1 =
        \\#!srfv1
        \\symbol::AAPL,shares:num:100,open_date::2024-01-15,open_price:num:150.00
        \\symbol::MSFT,shares:num:50,open_date::2024-02-20,open_price:num:300.00
        \\
    ;
    const data2 =
        \\#!srfv1
        \\symbol::GOOG,shares:num:25,open_date::2024-03-10,open_price:num:140.00
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data1 });
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = data2 });

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const dir = path_buf[0..dir_len];
    const p1 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio.srf" });
    defer std.testing.allocator.free(p1);
    const p2 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio_b.srf" });
    defer std.testing.allocator.free(p2);

    const paths = [_][]const u8{ p1, p2 };
    var loaded = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
    defer loaded.deinit(std.testing.allocator);

    // Combined view: 3 lots from across 2 files, 3 positions, 3 stock syms.
    try std.testing.expectEqual(@as(usize, 3), loaded.portfolio.lots.len);
    try std.testing.expectEqual(@as(usize, 3), loaded.positions.len);
    try std.testing.expectEqual(@as(usize, 3), loaded.syms.len);
    try std.testing.expectEqual(@as(usize, 2), loaded.file_datas.len);
}

test "loadPortfolioFromPaths: bails on first unreadable file without leaking" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const ok =
        \\#!srfv1
        \\symbol::AAPL,shares:num:1,open_date::2024-01-01,open_price:num:1
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = ok });

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const dir = path_buf[0..dir_len];
    const p1 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio.srf" });
    defer std.testing.allocator.free(p1);
    const p_bad = try std.fs.path.join(std.testing.allocator, &.{ dir, "does_not_exist.srf" });
    defer std.testing.allocator.free(p_bad);

    const paths = [_][]const u8{ p1, p_bad };
    const result = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8));
    try std.testing.expect(result == null);
}

test "loadPortfolioFromPaths: bails on parse error in second file without leaking" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const ok =
        \\#!srfv1
        \\symbol::AAPL,shares:num:1,open_date::2024-01-01,open_price:num:1
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = ok });
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "not srf format at all\n" });

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const dir = path_buf[0..dir_len];
    const p1 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio.srf" });
    defer std.testing.allocator.free(p1);
    const p2 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio_b.srf" });
    defer std.testing.allocator.free(p2);

    const paths = [_][]const u8{ p1, p2 };
    const result = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8));
    // "not srf format" is a single bad record in current parser; it
    // may either parse to zero lots or fail outright. Either way,
    // no leak is the load-bearing assertion.
    if (result) |r| {
        var mut = r;
        defer mut.deinit(std.testing.allocator);
    }
}

test "loadPortfolioFromConfig: same merged result as the CLI sees, callable without RunCtx" {
    // Pins the load-bearing CLI/TUI parity property: both
    // surfaces go through `loadPortfolioFromConfig`, so the
    // merged Portfolio is bit-for-bit the same regardless of
    // who's calling. Without this, the TUI's pre-unification
    // single-file load drifted from the CLI's multi-file load
    // and reported different totals — the bug that motivated
    // the unification.
    const io = std.testing.io;
    const allocator = std.testing.allocator;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Use a `zfintest_pf*.srf` pattern instead of the default
    // `portfolio*.srf` so the test runner's cwd (the repo root,
    // which has a real `portfolio-semilatest.srf`) doesn't
    // shadow our tmp dir via the cwd-first resolution rule.
    try tmp.dir.writeFile(io, .{
        .sub_path = "zfintest_pf.srf",
        .data =
        \\#!srfv1
        \\symbol::AAPL,shares:num:100,open_date::2024-01-15,open_price:num:150.00
        \\
        ,
    });
    try tmp.dir.writeFile(io, .{
        .sub_path = "zfintest_pf_extra.srf",
        .data =
        \\#!srfv1
        \\symbol::MSFT,shares:num:50,open_date::2024-02-20,open_price:num:300.00
        \\
        ,
    });

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

    const config: zfin.Config = .{ .cache_dir = "/tmp", .zfin_home = dir };
    const pat = "zfintest_pf*.srf";
    const patterns = [_][]const u8{pat};

    var loaded = loadPortfolioFromConfig(io, allocator, config, &patterns, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
    defer loaded.deinit(allocator);

    // Both files contributed → 2 lots in the merged portfolio.
    try std.testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
    try std.testing.expectEqual(@as(usize, 2), loaded.paths.len);
    // Anchor is the lex-first match → zfintest_pf.srf (not _extra).
    try std.testing.expect(std.mem.endsWith(u8, loaded.anchor(), "zfintest_pf.srf"));
}

test "buildPortfolioData: empty positions returns NoAllocations" {
    const config = zfin.Config{ .cache_dir = "/tmp" };
    var svc = zfin.DataService.init(std.testing.io, std.testing.allocator, config);
    defer svc.deinit();

    const lots = [_]zfin.Lot{};
    const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = std.testing.allocator };
    const positions: []const zfin.Position = &.{};
    const syms: []const []const u8 = &.{};

    var prices: std.StringHashMap(f64) = .init(std.testing.allocator);
    defer prices.deinit();

    const result = buildPortfolioData(std.testing.allocator, portfolio, positions, syms, &prices, &svc, zfin.Date.fromYmd(2026, 5, 8));
    try std.testing.expectError(error.NoAllocations, result);
}

test "buildPortfolioData: builds summary + candle_map for stock positions" {
    const config = zfin.Config{ .cache_dir = "/tmp" };
    var svc = zfin.DataService.init(std.testing.io, std.testing.allocator, config);
    defer svc.deinit();

    const today = zfin.Date.fromYmd(2026, 5, 8);
    const lots = [_]zfin.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = zfin.Date.fromYmd(2024, 1, 1), .open_price = 150 },
    };
    var portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = std.testing.allocator };
    const positions = try portfolio.positions(today, std.testing.allocator);
    defer std.testing.allocator.free(positions);
    const syms = try portfolio.stockSymbols(std.testing.allocator);
    defer std.testing.allocator.free(syms);

    var prices: std.StringHashMap(f64) = .init(std.testing.allocator);
    defer prices.deinit();
    try prices.put("AAPL", 200.0);

    var pf_data = try buildPortfolioData(std.testing.allocator, portfolio, positions, syms, &prices, &svc, today);
    defer pf_data.deinit(std.testing.allocator);

    try std.testing.expect(pf_data.summary.allocations.len > 0);
    try std.testing.expectApproxEqAbs(@as(f64, 20_000), pf_data.summary.total_value, 1.0);
}
