//! `review` — per-holding performance and risk dashboard.
//!
//! Renderer-agnostic view model for the `zfin review` CLI command and
//! the `review` TUI tab. Same shape as other view modules in this
//! directory: produces `StyleIntent`-bearing data, no rendering.
//!
//! Each row covers one holding and combines:
//!
//!   - **Sector** (mid-granularity: Communication Services, Healthcare,
//!     Bonds, Cash & Equivalents, ...) — derived from `metadata.srf`
//!     classifications + `analytics/analysis.zig`'s sector bucketing.
//!   - **Tax%** — fraction of the holding's market value held in
//!     taxable accounts, computed by walking the per-lot `account` field
//!     against `accounts.srf` (`AccountMap.taxTypeFor`).
//!   - **Weight** — share of the liquid portfolio.
//!   - **Trailing returns** at 1Y/3Y/5Y/10Y, month-end total-return
//!     methodology (Morningstar-aligned). Falls back to adj_close
//!     returns when explicit dividend data is unavailable.
//!   - **Risk metrics** at 3Y and 10Y for vol/Sharpe (the short-vs-long
//!     pairing surfaces "is this holding good or just lucky lately?")
//!     plus 5Y for max drawdown (captures the 2022 bear without 2020's
//!     COVID drawdown flooding every row).
//!
//! A bottom **portfolio totals row** uses true correlation-aware
//! portfolio risk via `analytics/portfolio_risk.zig`'s synthetic-series
//! construction. Holdings without sufficient candle coverage drop out
//! of longer windows via per-window weight renormalization; affected
//! totals-row cells are flagged so renderers can mark them.

const std = @import("std");
const zfin = @import("../root.zig");
const analysis = @import("../analytics/analysis.zig");
const performance = @import("../analytics/performance.zig");
const risk = @import("../analytics/risk.zig");
const portfolio_risk = @import("../analytics/portfolio_risk.zig");
const observations_engine = @import("../analytics/observations.zig");
const classification = @import("../models/classification.zig");
const valuation = @import("../analytics/valuation.zig");
const format = @import("../format.zig");
const Date = zfin.Date;

/// Sortable columns. The TUI tab cycles through these via `>`/`<`;
/// the CLI accepts kebab-case names via `--sort`.
pub const SortField = enum {
    sector,
    symbol,
    weight,
    tax_pct,
    return_1y,
    return_3y,
    return_5y,
    return_10y,
    vol_3y,
    vol_10y,
    sharpe_3y,
    sharpe_10y,
    maxdd_5y,
};

pub const SortDirection = enum {
    asc,
    desc,

    pub fn flip(self: SortDirection) SortDirection {
        return if (self == .asc) .desc else .asc;
    }

    /// Visual indicator glyph for the active sort column header
    /// (▲ for ascending, ▼ for descending). Same glyphs as
    /// `portfolio_tab.SortDirection.indicator` for consistency.
    pub fn indicator(self: SortDirection) []const u8 {
        return if (self == .asc) "▲" else "▼";
    }
};

/// One row per holding.
pub const ReviewRow = struct {
    /// Display ticker — same convention as `Allocation.display_symbol`.
    symbol: []const u8,
    /// Sector bucket label (`entry.bucket` from the user's
    /// `metadata.srf` classification, populated by
    /// `parseClassificationFile` via `deriveBucket`). Falls back
    /// to "Unclassified" when the symbol has no classification entry.
    bucket: []const u8,
    /// Fraction of the holding's market value in taxable accounts
    /// (0.0 = fully tax-advantaged, 1.0 = fully taxable). Computed
    /// from per-lot accounts; null when the AccountMap is missing
    /// (we don't know how to classify, so we say so).
    tax_pct: ?f64,
    /// Share of the liquid portfolio (= `Allocation.weight`).
    weight: f64,
    /// Trailing month-end total returns (Morningstar-aligned). Each
    /// `?f64` is the annualized CAGR for that window; null when the
    /// holding has insufficient candle coverage.
    return_1y: ?f64,
    return_3y: ?f64,
    return_5y: ?f64,
    return_10y: ?f64,
    /// 3Y vol (annualized stdev of monthly returns). Null when <12
    /// monthly returns are available in the 3Y window.
    vol_3y: ?f64,
    /// 10Y vol — same shape as 3Y.
    vol_10y: ?f64,
    /// 3Y Sharpe ratio. Null with the same condition as vol_3y.
    sharpe_3y: ?f64,
    /// 10Y Sharpe ratio.
    sharpe_10y: ?f64,
    /// 5Y max drawdown (positive decimal, e.g. 0.30 = 30%).
    maxdd_5y: ?f64,
};

/// Bottom totals row.
pub const ReviewTotals = struct {
    /// Always 1.0 (sums of per-row weights).
    weight: f64,
    /// Weighted-average per-position trailing returns. These DON'T use
    /// the synthetic-series math — they're the straight weighted average
    /// of per-position returns, matching how `benchmark.zig`
    /// computes `portfolio_returns`. We keep this convention because
    /// (a) per-position trailing returns are total-return-with-dividends,
    /// which the synthetic series doesn't preserve cleanly, and (b)
    /// the user's intuition for "what did my portfolio return" matches
    /// the weighted-average shape.
    return_1y: ?f64,
    return_3y: ?f64,
    return_5y: ?f64,
    return_10y: ?f64,
    /// True correlation-aware portfolio vol/Sharpe/MaxDD from synthetic
    /// series. Different from a weighted average of per-position vols
    /// (typically 20-40% lower for a diversified portfolio).
    vol_3y: ?f64,
    vol_10y: ?f64,
    sharpe_3y: ?f64,
    sharpe_10y: ?f64,
    maxdd_5y: ?f64,
    /// Weighted-average tax %.
    tax_pct: ?f64,
    /// Per-window flags marking metrics that required holding-dropout
    /// renormalization. Renderers append `*` to flagged cells and emit
    /// a footnote explaining the asterisk.
    reweight_flags: portfolio_risk.ReweightFlags,
};

/// Complete view: rows + totals + envelope.
pub const ReviewView = struct {
    rows: []ReviewRow,
    totals: ReviewTotals,
    /// Reference date the view was computed for.
    as_of: Date,
    /// Total liquid portfolio value (informational; matches
    /// `PortfolioSummary.total_value`).
    total_liquid: f64,
    /// Anchor portfolio file path for the header line. Borrowed.
    portfolio_path: []const u8,
    /// Observation engine output. Null when the engine wasn't run
    /// (e.g. unit tests of the renderer that fabricate views without
    /// going through `buildReview`). Populated by `buildReview` after
    /// computing rows + totals.
    observations: ?observations_engine.CheckPanel = null,

    pub fn deinit(self: *ReviewView, allocator: std.mem.Allocator) void {
        allocator.free(self.rows);
        if (self.observations) |*panel| panel.deinit();
    }
};

// ── Build ─────────────────────────────────────────────────────

/// Build the review view from a loaded portfolio. The caller is
/// responsible for having already populated:
///
///   - `summary.allocations`: per-symbol market values + weights
///   - `candle_map`: symbol → daily candle slices (cached or fetched)
///   - `dividend_map` (optional): symbol → dividend slices, used to
///     compute total-return numbers. Pass `null` to fall back to
///     adj_close-derived returns. (Most providers bake dividends into
///     adj_close, so the fallback is usually fine — `withDividendFallback`
///     picks whichever number is higher per period.)
///   - `classifications`: parsed `metadata.srf`
///   - `account_map` (optional): parsed `accounts.srf`. When null,
///     Tax% is reported as null on every row.
///
/// `as_of` is the reference date for trailing-window math.
pub fn buildReview(
    allocator: std.mem.Allocator,
    io: std.Io,
    summary: valuation.PortfolioSummary,
    candle_map: *const std.StringHashMap([]const zfin.Candle),
    dividend_map: ?*const std.StringHashMap([]const zfin.Dividend),
    portfolio: zfin.Portfolio,
    classifications: classification.ClassificationMap,
    account_map: ?analysis.AccountMap,
    as_of: Date,
    portfolio_path: []const u8,
) !ReviewView {
    var rows = try std.ArrayList(ReviewRow).initCapacity(allocator, summary.allocations.len);
    errdefer rows.deinit(allocator);

    // Synthetic-risk position list — we build it during the row pass so
    // we don't double-walk allocations.
    var positions = try std.ArrayList(portfolio_risk.PositionCandles).initCapacity(allocator, summary.allocations.len);
    defer positions.deinit(allocator);

    for (summary.allocations) |a| {
        const candles = candle_map.get(a.symbol) orelse &.{};
        const dividends: ?[]const zfin.Dividend = if (dividend_map) |dm|
            (dm.get(a.symbol) orelse null)
        else
            null;

        const bucket = bucketForSymbol(a.symbol, classifications);
        const tax_pct = computeTaxPct(a.symbol, portfolio, account_map, as_of);

        const tr_returns = computeTrailingReturns(candles, dividends, as_of);
        const tr_risk = if (candles.len > 0) risk.trailingRisk(candles) else risk.TrailingRisk{};

        try rows.append(allocator, .{
            .symbol = a.display_symbol,
            .bucket = bucket,
            .tax_pct = tax_pct,
            .weight = a.weight,
            .return_1y = annualizedFromResult(tr_returns.one_year, false),
            .return_3y = annualizedFromResult(tr_returns.three_year, true),
            .return_5y = annualizedFromResult(tr_returns.five_year, true),
            .return_10y = annualizedFromResult(tr_returns.ten_year, true),
            .vol_3y = if (tr_risk.three_year) |m| m.volatility else null,
            .vol_10y = if (tr_risk.ten_year) |m| m.volatility else null,
            .sharpe_3y = if (tr_risk.three_year) |m| m.sharpe else null,
            .sharpe_10y = if (tr_risk.ten_year) |m| m.sharpe else null,
            .maxdd_5y = if (tr_risk.five_year) |m| m.max_drawdown else null,
        });

        try positions.append(allocator, .{
            .symbol = a.symbol,
            .candles = candles,
            .weight = a.weight,
        });
    }

    // Compute correlation-aware portfolio risk for the totals row.
    const synth = try portfolio_risk.syntheticPortfolioRisk(allocator, positions.items, as_of);

    const totals = computeTotals(rows.items, synth);

    const rows_slice = try rows.toOwnedSlice(allocator);
    errdefer allocator.free(rows_slice);

    // Run the observations engine over the now-built rows + totals.
    // Sync today; the API takes `io` so a future async dispatch path
    // doesn't require a signature change.
    const obs_ctx: observations_engine.CheckCtx = .{
        .allocator = allocator,
        .rows = rows_slice,
        .totals = totals,
    };
    const panel = try observations_engine.runChecks(allocator, io, obs_ctx, &observations_engine.default_checks);

    return .{
        .rows = rows_slice,
        .totals = totals,
        .as_of = as_of,
        .total_liquid = summary.total_value,
        .portfolio_path = portfolio_path,
        .observations = panel,
    };
}

// ── Risk-color thresholds ─────────────────────────────────────
//
// Translates volatility / Sharpe / max-drawdown numbers into a
// `StyleIntent` so renderers can color cells consistently. Thresholds
// are hardcoded for now; AGENTS.md commits to making these
// runtime-configurable as a fast-follow once milestone 2 ships.
//
// **Temporal sensitivity.** Vol and Sharpe thresholds are stable
// across decades — Sharpe at 0 / 0.5 are mathematically meaningful
// reference points (risk-free rate as floor, "good" as conventional
// 0.5+), and broad-market vol has hovered ~13-18% on rolling windows
// since the 1950s. The MaxDD thresholds, by contrast, are calibrated
// against whichever crisis sits inside the rolling 5Y window; they
// drift as time passes (see `maxdd_thresholds_last_reviewed` below).
//
// Defaults reflect typical broad-market intuitions:
//
//   - Annualized volatility:
//       < 12%  → calm (green)        (broad bond funds, money markets,
//                                     diversified balanced ETFs)
//       12-22% → typical (yellow)    (S&P 500 ≈ 15%, total-market ≈ 16%)
//       > 22%  → high (red)          (concentrated single names, small
//                                     caps, sector funds)
//
//   - Sharpe ratio (geometric annualized return - rfr) / vol:
//       > 0.5  → strong (green)      (Sharpe > 1 is standout, but 0.5+
//                                     covers most healthy holdings over
//                                     reasonable windows)
//       0-0.5  → mediocre (yellow)
//       < 0    → losing money on a risk-adjusted basis (red)
//
//   - Max drawdown (5Y window — captures the 2022 bear, excludes COVID):
//       < 15%  → shallow (green)     (bond funds, balanced/conservative)
//       15-30% → typical (yellow)    (broad equity index ≈ 24-25% in
//                                     the 2022 bear)
//       > 30%  → deep (red)          (concentrated single names,
//                                     sector funds, growth-heavy)
//     Same green/yellow/red scheme as vol — there's nothing
//     intrinsically "always bad" about a MaxDD number; magnitude
//     determines severity just like with vol.

pub const vol_calm_threshold: f64 = 0.12;
pub const vol_high_threshold: f64 = 0.22;
pub const sharpe_strong_threshold: f64 = 0.5;
pub const sharpe_negative_threshold: f64 = 0.0;
pub const maxdd_shallow_threshold: f64 = 0.15;
pub const maxdd_deep_threshold: f64 = 0.30;

/// Annual sanity-check anchor for the MaxDD color thresholds above.
/// Vol and Sharpe thresholds are stable across decades and are not
/// registered for nags; MaxDD is the only threshold pair with rolling-
/// window sensitivity:
///
///   - Today (2026): the 5Y MaxDD window covers 2021-2026 and is
///     anchored on the 2022 bear (~25% on SPY).
///   - ~2027: 2022 starts to roll out of the window.
///   - ~2028+: if no comparable event has happened since, "typical"
///     5Y MaxDD for broad equity drops materially. The 30% red
///     threshold becomes too lenient.
///
/// Annual recheck procedure (run on or after the nag date):
///
///   1. Run `zfin perf SPY` and read the 5Y Max DD row. SPY should
///      land in the yellow band (15-30%) — this is the "typical
///      broad-equity drawdown" anchor.
///   2. Run `zfin perf BND`. BND (or any broad bond fund) should
///      land in green (<15%) — the "shallow" anchor.
///   3. Run `zfin perf NVDA` (or any concentrated growth name).
///      Should land in red (>30%) — the "deep" anchor.
///
/// If all three still fall in the right bands, just bump the date.
/// If SPY has rolled out of yellow into green (markets calm, 2022
/// no longer in window), shrink `maxdd_deep_threshold` until the
/// concentrated name lands red and bump the date. If a fresh crisis
/// has pushed everything red, widen `maxdd_deep_threshold` until SPY
/// lands yellow again and bump the date.
///
/// Registered with the staleness checker in `src/data/staleness.zig`.
pub const maxdd_thresholds_last_reviewed: Date = Date.fromYmd(2026, 6, 5);

/// Map a volatility value (annualized, decimal) to a StyleIntent.
/// Lower is better. Null returns `.muted` (no data).
pub fn volIntent(v: ?f64) format.StyleIntent {
    const val = v orelse return .muted;
    if (val < vol_calm_threshold) return .positive;
    if (val < vol_high_threshold) return .warning;
    return .negative;
}

/// Map a Sharpe ratio to a StyleIntent. Higher is better. Null
/// returns `.muted` (no data).
pub fn sharpeIntent(v: ?f64) format.StyleIntent {
    const val = v orelse return .muted;
    if (val < sharpe_negative_threshold) return .negative;
    if (val < sharpe_strong_threshold) return .warning;
    return .positive;
}

/// Map a max-drawdown value (positive decimal, e.g. 0.30 = 30% drawdown)
/// to a StyleIntent. Lower (shallower) is better. Null returns `.muted`.
/// Same green/yellow/red scheme as `volIntent` — drawdown magnitude
/// determines severity, not the existence of a drawdown.
pub fn maxddIntent(v: ?f64) format.StyleIntent {
    const val = v orelse return .muted;
    if (val < maxdd_shallow_threshold) return .positive;
    if (val < maxdd_deep_threshold) return .warning;
    return .negative;
}

/// Map a signed return to a StyleIntent — positive = green, negative
/// = red, zero = normal, null = muted. Used for the trailing-return
/// columns where the user reads the sign as a win/loss.
pub fn returnIntent(v: ?f64) format.StyleIntent {
    const val = v orelse return .muted;
    if (val > 0) return .positive;
    if (val < 0) return .negative;
    return .normal;
}

// ── Sorting ───────────────────────────────────────────────────

/// In-place sort by the given field/direction. Stable.
///
/// **Sector sort note:** the sector column applies a symbol-asc
/// pre-pass so rows within a sector group are alphabetized — the
/// "looks random within Technology" problem otherwise. This makes
/// `sortRows(rows, .sector, .asc)` and the older
/// `sortGroupedByDefault` produce identical output, simplifying
/// the entry state for the TUI tab.
pub fn sortRows(rows: []ReviewRow, field: SortField, dir: SortDirection) void {
    if (field == .sector) {
        // Pre-pass: symbol asc. The stable sort below preserves
        // this order within each sector group.
        const sym_ctx: SortCtx = .{ .field = .symbol, .dir = .asc };
        std.sort.block(ReviewRow, rows, sym_ctx, sortLessThan);
    }
    const ctx: SortCtx = .{ .field = field, .dir = dir };
    std.sort.block(ReviewRow, rows, ctx, sortLessThan);
}

/// Default grouping. Now an alias for `sortRows(rows, .sector, .asc)`
/// — the sector path itself bakes in the symbol-asc tiebreaker, so
/// the two paths produce identical output. Kept as a named function
/// because callers that mean "default state" still read more
/// clearly than a magic field/direction pair.
pub fn sortGroupedByDefault(rows: []ReviewRow) void {
    sortRows(rows, .sector, .asc);
}

const SortCtx = struct {
    field: SortField,
    dir: SortDirection,
};

fn sortLessThan(ctx: SortCtx, a: ReviewRow, b: ReviewRow) bool {
    return switch (ctx.field) {
        .symbol, .sector => sortStringByDir(extractStr(ctx.field, a), extractStr(ctx.field, b), ctx.dir),
        else => sortFloatByDir(extractFloat(ctx.field, a), extractFloat(ctx.field, b), ctx.dir),
    };
}

fn extractStr(field: SortField, r: ReviewRow) []const u8 {
    return switch (field) {
        .symbol => r.symbol,
        .sector => r.bucket,
        else => unreachable,
    };
}

fn extractFloat(field: SortField, r: ReviewRow) ?f64 {
    return switch (field) {
        .weight => r.weight,
        .tax_pct => r.tax_pct,
        .return_1y => r.return_1y,
        .return_3y => r.return_3y,
        .return_5y => r.return_5y,
        .return_10y => r.return_10y,
        .vol_3y => r.vol_3y,
        .vol_10y => r.vol_10y,
        .sharpe_3y => r.sharpe_3y,
        .sharpe_10y => r.sharpe_10y,
        .maxdd_5y => r.maxdd_5y,
        else => unreachable,
    };
}

fn sortStringByDir(a: []const u8, b: []const u8, dir: SortDirection) bool {
    return switch (dir) {
        .asc => std.mem.order(u8, a, b) == .lt,
        .desc => std.mem.order(u8, a, b) == .gt,
    };
}

/// Float sort comparator with nulls always pinned to the END of the
/// list, regardless of direction. The user's intuition is "show me
/// the best (or worst) values, and put unknowns out of the way at the
/// bottom" — flipping null-position with direction would surprise them.
fn sortFloatByDir(a: ?f64, b: ?f64, dir: SortDirection) bool {
    if (a == null and b == null) return false;
    if (a == null) return false; // a goes last → not less-than-b
    if (b == null) return true; // b goes last → a is less-than-b
    return switch (dir) {
        .asc => a.? < b.?,
        .desc => a.? > b.?,
    };
}

// ── Internal helpers ──────────────────────────────────────────

/// Resolve a symbol's display bucket. Walks the classification
/// map; returns the most-weighted entry's `bucket` field. The
/// `bucket` field is always populated by `parseClassificationFile`
/// (via `deriveBucket` fallback when not user-curated), so the
/// returned slice is always non-empty and borrows from the
/// ClassificationMap's storage.
fn bucketForSymbol(
    symbol: []const u8,
    classifications: classification.ClassificationMap,
) []const u8 {
    var best_pct: f64 = -1;
    var best_idx: ?usize = null;
    for (classifications.entries, 0..) |e, i| {
        if (!std.mem.eql(u8, e.symbol, symbol)) continue;
        if (e.pct > best_pct) {
            best_pct = e.pct;
            best_idx = i;
        }
    }
    if (best_idx) |i| {
        if (classifications.entries[i].bucket) |b| return b;
    }
    return "Unclassified";
}

/// Walk the open lots for `symbol` and compute the share of market
/// value held in taxable accounts. Returns null when:
///   - account_map is null (no classification metadata available)
///   - the symbol has no open lots
///   - all lots have unknown accounts (none match in the map)
fn computeTaxPct(
    symbol: []const u8,
    portfolio: zfin.Portfolio,
    account_map: ?analysis.AccountMap,
    as_of: Date,
) ?f64 {
    const am = account_map orelse return null;

    var taxable_shares: f64 = 0;
    var classified_shares: f64 = 0;
    for (portfolio.lots) |lot| {
        if (!std.mem.eql(u8, lot.symbol, symbol)) continue;
        if (!lot.lotIsOpenAsOf(as_of)) continue;
        if (lot.security_type != .stock) continue; // options/cash/CDs handled elsewhere
        const acct = lot.account orelse continue;
        const is_taxable = isTaxableAccount(am, acct);
        const is_classified = accountIsKnown(am, acct);
        if (!is_classified) continue;
        classified_shares += lot.shares;
        if (is_taxable) taxable_shares += lot.shares;
    }
    if (classified_shares <= 0) return null;
    return taxable_shares / classified_shares;
}

fn isTaxableAccount(am: analysis.AccountMap, account: []const u8) bool {
    for (am.entries) |e| {
        if (std.mem.eql(u8, e.account, account)) {
            return e.tax_type == .taxable;
        }
    }
    return false;
}

fn accountIsKnown(am: analysis.AccountMap, account: []const u8) bool {
    for (am.entries) |e| {
        if (std.mem.eql(u8, e.account, account)) return true;
    }
    return false;
}

/// Compute month-end total-return trailing returns. Falls back to
/// adj_close-only when no dividend data is available, picking the
/// higher value per period via `withDividendFallback`.
fn computeTrailingReturns(
    candles: []const zfin.Candle,
    dividends: ?[]const zfin.Dividend,
    as_of: Date,
) performance.TrailingReturns {
    if (candles.len == 0) return .{};

    const adj = performance.trailingReturnsMonthEnd(candles, as_of);
    if (dividends) |divs| {
        const div = performance.trailingReturnsMonthEndWithDividends(candles, divs, as_of);
        return performance.withDividendFallback(div, adj);
    }
    return adj;
}

/// Pull the annualized return out of a `PerformanceResult`. For 1Y,
/// the period is right at the threshold — `annualizedReturn` returns
/// null when the actual span < 0.95 years. We accept that null and
/// fall back to `total_return` only when explicitly told to (e.g. for
/// a 1-year column where a 350-day span is fine to display).
fn annualizedFromResult(r: ?performance.PerformanceResult, require_annualized: bool) ?f64 {
    const result = r orelse return null;
    if (result.annualized_return) |ann| return ann;
    if (require_annualized) return null;
    // 1Y window: a sub-1-year actual span (e.g. 360 days) reports
    // total_return verbatim — no annualization needed for a window
    // that's already supposed to be one year.
    return result.total_return;
}

/// Compute the totals row from `rows` + the synthetic risk numbers.
/// Returns/Tax% are weighted averages over participating rows; risk
/// metrics come from the synthetic series.
fn computeTotals(rows: []const ReviewRow, synth: portfolio_risk.SyntheticRisk) ReviewTotals {
    var ret_1y = WeightedAvg{};
    var ret_3y = WeightedAvg{};
    var ret_5y = WeightedAvg{};
    var ret_10y = WeightedAvg{};
    var tax = WeightedAvg{};
    var total_weight: f64 = 0;
    for (rows) |r| {
        total_weight += r.weight;
        ret_1y.add(r.weight, r.return_1y);
        ret_3y.add(r.weight, r.return_3y);
        ret_5y.add(r.weight, r.return_5y);
        ret_10y.add(r.weight, r.return_10y);
        tax.add(r.weight, r.tax_pct);
    }

    return .{
        .weight = total_weight,
        // Prefer the synthetic-series annualized return when we have it
        // (correlation-aware compounded return); fall back to the
        // weighted average per-position return when not. The synthetic
        // version IS more accurate but requires sufficient candle
        // coverage to compute.
        .return_1y = synth.return_1y orelse ret_1y.value(),
        .return_3y = synth.return_3y orelse ret_3y.value(),
        .return_5y = synth.return_5y orelse ret_5y.value(),
        .return_10y = synth.return_10y orelse ret_10y.value(),
        .vol_3y = synth.vol_3y,
        .vol_10y = synth.vol_10y,
        .sharpe_3y = synth.sharpe_3y,
        .sharpe_10y = synth.sharpe_10y,
        .maxdd_5y = synth.maxdd_5y,
        .tax_pct = tax.value(),
        .reweight_flags = synth.reweight_flags,
    };
}

const WeightedAvg = struct {
    sum: f64 = 0,
    weight: f64 = 0,

    fn add(self: *WeightedAvg, w: f64, v: ?f64) void {
        const x = v orelse return;
        self.sum += w * x;
        self.weight += w;
    }

    fn value(self: WeightedAvg) ?f64 {
        if (self.weight <= 0) return null;
        return self.sum / self.weight;
    }
};

// ── Sort flag parsing (CLI helper) ────────────────────────────

/// Parse a CLI-style sort flag value (kebab-case) into a `SortField`.
/// Returns null on unknown values; the caller should emit an error
/// message naming the valid options.
///
/// Accepted values (case-insensitive):
///   sector, symbol, weight, tax,
///   1y, 3y, 5y, 10y,
///   3y-vol, 10y-vol, 3y-sharpe, 10y-sharpe, 5y-maxdd.
pub fn parseSortField(s: []const u8) ?SortField {
    const map = [_]struct { name: []const u8, field: SortField }{
        .{ .name = "sector", .field = .sector },
        .{ .name = "symbol", .field = .symbol },
        .{ .name = "weight", .field = .weight },
        .{ .name = "tax", .field = .tax_pct },
        .{ .name = "1y", .field = .return_1y },
        .{ .name = "3y", .field = .return_3y },
        .{ .name = "5y", .field = .return_5y },
        .{ .name = "10y", .field = .return_10y },
        .{ .name = "3y-vol", .field = .vol_3y },
        .{ .name = "10y-vol", .field = .vol_10y },
        .{ .name = "3y-sharpe", .field = .sharpe_3y },
        .{ .name = "10y-sharpe", .field = .sharpe_10y },
        .{ .name = "5y-maxdd", .field = .maxdd_5y },
    };
    for (map) |entry| {
        if (std.ascii.eqlIgnoreCase(s, entry.name)) return entry.field;
    }
    return null;
}

/// All valid sort-field strings, in display order. Used by the CLI
/// error message and the help text.
pub const sort_field_names = [_][]const u8{
    "sector",   "symbol",  "weight",    "tax",
    "1y",       "3y",      "5y",        "10y",
    "3y-vol",   "10y-vol", "3y-sharpe", "10y-sharpe",
    "5y-maxdd",
};

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

const testing = std.testing;

fn makeRow(symbol: []const u8, sector: []const u8, weight: f64) ReviewRow {
    return .{
        .symbol = symbol,
        .bucket = sector,
        .tax_pct = null,
        .weight = weight,
        .return_1y = null,
        .return_3y = null,
        .return_5y = null,
        .return_10y = null,
        .vol_3y = null,
        .vol_10y = null,
        .sharpe_3y = null,
        .sharpe_10y = null,
        .maxdd_5y = null,
    };
}

test "parseSortField: each supported value maps correctly" {
    try testing.expectEqual(SortField.sector, parseSortField("sector").?);
    try testing.expectEqual(SortField.symbol, parseSortField("symbol").?);
    try testing.expectEqual(SortField.weight, parseSortField("weight").?);
    try testing.expectEqual(SortField.tax_pct, parseSortField("tax").?);
    try testing.expectEqual(SortField.return_1y, parseSortField("1y").?);
    try testing.expectEqual(SortField.return_3y, parseSortField("3y").?);
    try testing.expectEqual(SortField.return_5y, parseSortField("5y").?);
    try testing.expectEqual(SortField.return_10y, parseSortField("10y").?);
    try testing.expectEqual(SortField.vol_3y, parseSortField("3y-vol").?);
    try testing.expectEqual(SortField.vol_10y, parseSortField("10y-vol").?);
    try testing.expectEqual(SortField.sharpe_3y, parseSortField("3y-sharpe").?);
    try testing.expectEqual(SortField.sharpe_10y, parseSortField("10y-sharpe").?);
    try testing.expectEqual(SortField.maxdd_5y, parseSortField("5y-maxdd").?);
}

test "parseSortField: case-insensitive" {
    try testing.expectEqual(SortField.sharpe_3y, parseSortField("3Y-SHARPE").?);
    try testing.expectEqual(SortField.sharpe_3y, parseSortField("3Y-Sharpe").?);
    try testing.expectEqual(SortField.return_1y, parseSortField("1Y").?);
}

test "parseSortField: unknown returns null" {
    try testing.expect(parseSortField("bogus") == null);
    try testing.expect(parseSortField("") == null);
    try testing.expect(parseSortField("3y-foo") == null);
}

test "SortDirection: flip and indicator" {
    try testing.expectEqual(SortDirection.desc, SortDirection.asc.flip());
    try testing.expectEqual(SortDirection.asc, SortDirection.desc.flip());
    try testing.expectEqualStrings("▲", SortDirection.asc.indicator());
    try testing.expectEqualStrings("▼", SortDirection.desc.indicator());
}

test "sortRows: by weight desc puts largest first" {
    var rows = [_]ReviewRow{
        makeRow("AAPL", "Technology", 0.05),
        makeRow("VTI", "Equity / Corporate", 0.40),
        makeRow("BND", "Bonds", 0.20),
    };
    sortRows(&rows, .weight, .desc);
    try testing.expectEqualStrings("VTI", rows[0].symbol);
    try testing.expectEqualStrings("BND", rows[1].symbol);
    try testing.expectEqualStrings("AAPL", rows[2].symbol);
}

test "sortRows: by symbol asc is alphabetical" {
    var rows = [_]ReviewRow{
        makeRow("VTI", "Equity / Corporate", 0.40),
        makeRow("AAPL", "Technology", 0.05),
        makeRow("BND", "Bonds", 0.20),
    };
    sortRows(&rows, .symbol, .asc);
    try testing.expectEqualStrings("AAPL", rows[0].symbol);
    try testing.expectEqualStrings("BND", rows[1].symbol);
    try testing.expectEqualStrings("VTI", rows[2].symbol);
}

test "sortRows: by symbol desc is reverse alphabetical (string desc path)" {
    var rows = [_]ReviewRow{
        makeRow("AAPL", "Technology", 0.05),
        makeRow("VTI", "Equity / Corporate", 0.40),
        makeRow("BND", "Bonds", 0.20),
    };
    sortRows(&rows, .symbol, .desc);
    try testing.expectEqualStrings("VTI", rows[0].symbol);
    try testing.expectEqualStrings("BND", rows[1].symbol);
    try testing.expectEqualStrings("AAPL", rows[2].symbol);
}

test "sortRows: by tax_pct asc (covers ascending float comparator path)" {
    var rows = [_]ReviewRow{
        .{ .symbol = "A", .bucket = "x", .tax_pct = 0.8, .weight = 0.1, .return_1y = null, .return_3y = null, .return_5y = null, .return_10y = null, .vol_3y = null, .vol_10y = null, .sharpe_3y = null, .sharpe_10y = null, .maxdd_5y = null },
        .{ .symbol = "B", .bucket = "x", .tax_pct = 0.1, .weight = 0.1, .return_1y = null, .return_3y = null, .return_5y = null, .return_10y = null, .vol_3y = null, .vol_10y = null, .sharpe_3y = null, .sharpe_10y = null, .maxdd_5y = null },
        .{ .symbol = "C", .bucket = "x", .tax_pct = 0.5, .weight = 0.1, .return_1y = null, .return_3y = null, .return_5y = null, .return_10y = null, .vol_3y = null, .vol_10y = null, .sharpe_3y = null, .sharpe_10y = null, .maxdd_5y = null },
    };
    sortRows(&rows, .tax_pct, .asc);
    try testing.expectEqualStrings("B", rows[0].symbol);
    try testing.expectEqualStrings("C", rows[1].symbol);
    try testing.expectEqualStrings("A", rows[2].symbol);
}

test "sortRows: every numeric SortField variant is reachable via extractFloat" {
    // Constructs rows where each numeric field has a unique value.
    // For each numeric field, we run sortRows(.field, .desc) and
    // verify the row with the largest value of THAT field bubbles
    // to the front. Catches any future `else => unreachable` arm
    // that isn't actually reachable from the dispatcher.
    const numeric_fields = [_]SortField{
        .weight,    .tax_pct,
        .return_1y, .return_3y,
        .return_5y, .return_10y,
        .vol_3y,    .vol_10y,
        .sharpe_3y, .sharpe_10y,
        .maxdd_5y,
    };
    inline for (numeric_fields) |field| {
        var rows = [_]ReviewRow{
            .{ .symbol = "low", .bucket = "x", .tax_pct = 0.1, .weight = 0.1, .return_1y = 0.1, .return_3y = 0.1, .return_5y = 0.1, .return_10y = 0.1, .vol_3y = 0.1, .vol_10y = 0.1, .sharpe_3y = 0.1, .sharpe_10y = 0.1, .maxdd_5y = 0.1 },
            .{ .symbol = "high", .bucket = "x", .tax_pct = 0.9, .weight = 0.9, .return_1y = 0.9, .return_3y = 0.9, .return_5y = 0.9, .return_10y = 0.9, .vol_3y = 0.9, .vol_10y = 0.9, .sharpe_3y = 0.9, .sharpe_10y = 0.9, .maxdd_5y = 0.9 },
        };
        sortRows(&rows, field, .desc);
        try testing.expectEqualStrings("high", rows[0].symbol);
        sortRows(&rows, field, .asc);
        try testing.expectEqualStrings("low", rows[0].symbol);
    }
}

test "computeTrailingReturns: empty candles returns empty struct" {
    const tr = computeTrailingReturns(&.{}, null, Date.fromYmd(2026, 6, 1));
    try testing.expect(tr.one_year == null);
    try testing.expect(tr.three_year == null);
    try testing.expect(tr.five_year == null);
    try testing.expect(tr.ten_year == null);
}

test "computeTrailingReturns: candles with no dividends uses adj_close path" {
    // 36 months of 1% monthly growth → ~12.7% annualized. Without
    // dividends, this exercises the adj-close-only branch. Even when
    // the synthetic data isn't enough to land any specific trailing
    // window (depends on calendar arithmetic against the as_of), the
    // function shouldn't crash and should return a struct.
    var candles: [36]zfin.Candle = undefined;
    var d = Date.fromYmd(2023, 1, 31);
    for (0..36) |i| {
        const price: f64 = 100.0 * std.math.pow(f64, 1.01, @as(f64, @floatFromInt(i)));
        candles[i] = .{ .date = d, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 };
        d = d.addDays(30);
    }
    const tr = computeTrailingReturns(&candles, null, Date.fromYmd(2026, 1, 31));
    // We don't assert on which windows populated — that depends on
    // calendar arithmetic against `as_of` and the candle density.
    // Coverage of the no-dividends branch (the `else` arm at the
    // end of `computeTrailingReturns`) is what we want.
    _ = tr;
}

test "computeTrailingReturns: candles with dividends prefers higher fallback" {
    // Same shape, but include a dividends slice. The fallback logic
    // takes whichever produces a higher annualized return per period;
    // for adj_close already including dividends and an explicit
    // dividends slice, the helper picks whichever is higher. Either
    // way the path through `withDividendFallback` is exercised.
    var candles: [36]zfin.Candle = undefined;
    var d = Date.fromYmd(2023, 1, 31);
    for (0..36) |i| {
        const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
        candles[i] = .{ .date = d, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 };
        d = d.addDays(30);
    }
    const divs = [_]zfin.Dividend{
        .{ .ex_date = Date.fromYmd(2023, 6, 15), .pay_date = Date.fromYmd(2023, 7, 1), .amount = 0.5 },
        .{ .ex_date = Date.fromYmd(2024, 6, 15), .pay_date = Date.fromYmd(2024, 7, 1), .amount = 0.5 },
    };
    const tr = computeTrailingReturns(&candles, &divs, Date.fromYmd(2026, 1, 31));
    // We don't need to assert exact values — coverage of the
    // dividend-fallback branch is the goal.
    _ = tr;
}

test "computeTaxPct: returns null when account_map is null" {
    var lots = [_]zfin.Lot{
        .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Brokerage" },
    };
    const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator };
    const result = computeTaxPct("VTI", portfolio, null, Date.fromYmd(2026, 1, 1));
    try testing.expect(result == null);
}

test "computeTaxPct: returns null when no lots match symbol" {
    var lots = [_]zfin.Lot{
        .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Brokerage" },
    };
    const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator };
    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Brokerage", .tax_type = .taxable },
    };
    const am: analysis.AccountMap = .{ .entries = entries[0..], .allocator = testing.allocator };
    const result = computeTaxPct("AAPL", portfolio, am, Date.fromYmd(2026, 1, 1));
    try testing.expect(result == null);
}

test "computeTaxPct: returns null when all matching lots are in unknown accounts" {
    var lots = [_]zfin.Lot{
        .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "MysteryAccount" },
    };
    const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator };
    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Brokerage", .tax_type = .taxable },
    };
    const am: analysis.AccountMap = .{ .entries = entries[0..], .allocator = testing.allocator };
    const result = computeTaxPct("VTI", portfolio, am, Date.fromYmd(2026, 1, 1));
    // No matching account in map → all classified shares = 0 → null.
    try testing.expect(result == null);
}

test "computeTaxPct: mixed accounts produces partial tax%" {
    var lots = [_]zfin.Lot{
        .{ .symbol = "VTI", .shares = 60, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Brokerage" },
        .{ .symbol = "VTI", .shares = 40, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Roth IRA" },
    };
    const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator };
    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Brokerage", .tax_type = .taxable },
        .{ .account = "Roth IRA", .tax_type = .roth },
    };
    const am: analysis.AccountMap = .{ .entries = entries[0..], .allocator = testing.allocator };
    const result = computeTaxPct("VTI", portfolio, am, Date.fromYmd(2026, 1, 1));
    // 60 / 100 = 0.6 in taxable.
    try testing.expectApproxEqAbs(@as(f64, 0.6), result.?, 0.001);
}

test "annualizedFromResult: null result returns null" {
    try testing.expect(annualizedFromResult(null, false) == null);
    try testing.expect(annualizedFromResult(null, true) == null);
}

test "annualizedFromResult: result with annualized_return passes through" {
    const r: performance.PerformanceResult = .{
        .total_return = 0.50,
        .annualized_return = 0.18,
        .from = Date.fromYmd(2022, 1, 1),
        .to = Date.fromYmd(2025, 1, 1),
    };
    try testing.expectApproxEqAbs(@as(f64, 0.18), annualizedFromResult(r, true).?, 0.001);
    try testing.expectApproxEqAbs(@as(f64, 0.18), annualizedFromResult(r, false).?, 0.001);
}

test "annualizedFromResult: short window (no annualized) returns total when not required" {
    const r: performance.PerformanceResult = .{
        .total_return = 0.10,
        .annualized_return = null, // sub-1-year window
        .from = Date.fromYmd(2024, 1, 1),
        .to = Date.fromYmd(2024, 8, 1),
    };
    try testing.expect(annualizedFromResult(r, true) == null);
    try testing.expectApproxEqAbs(@as(f64, 0.10), annualizedFromResult(r, false).?, 0.001);
}

test "sortGroupedByDefault: groups by sector then symbol asc within group" {
    var rows = [_]ReviewRow{
        makeRow("AAPL", "Technology", 0.05),
        makeRow("VTI", "Equity / Corporate", 0.40),
        makeRow("MSFT", "Technology", 0.15),
        makeRow("BND", "Bonds", 0.20),
        makeRow("AGG", "Bonds", 0.10),
    };
    sortGroupedByDefault(&rows);
    // Sectors alphabetical: Bonds → Equity / Corporate → Technology.
    // Within each sector, symbols alphabetical (deterministic, easy
    // to scan for a specific ticker).
    try testing.expectEqualStrings("Bonds", rows[0].bucket);
    try testing.expectEqualStrings("AGG", rows[0].symbol);
    try testing.expectEqualStrings("Bonds", rows[1].bucket);
    try testing.expectEqualStrings("BND", rows[1].symbol);
    try testing.expectEqualStrings("Equity / Corporate", rows[2].bucket);
    try testing.expectEqualStrings("VTI", rows[2].symbol);
    try testing.expectEqualStrings("Technology", rows[3].bucket);
    try testing.expectEqualStrings("AAPL", rows[3].symbol);
    try testing.expectEqualStrings("Technology", rows[4].bucket);
    try testing.expectEqualStrings("MSFT", rows[4].symbol);
}

test "sortRows: nulls sort to end on both directions" {
    var rows_desc = [_]ReviewRow{
        .{ .symbol = "A", .bucket = "x", .tax_pct = null, .weight = 0.1, .return_1y = null, .return_3y = 0.10, .return_5y = null, .return_10y = null, .vol_3y = null, .vol_10y = null, .sharpe_3y = null, .sharpe_10y = null, .maxdd_5y = null },
        .{ .symbol = "B", .bucket = "x", .tax_pct = null, .weight = 0.1, .return_1y = null, .return_3y = 0.20, .return_5y = null, .return_10y = null, .vol_3y = null, .vol_10y = null, .sharpe_3y = null, .sharpe_10y = null, .maxdd_5y = null },
        .{ .symbol = "C", .bucket = "x", .tax_pct = null, .weight = 0.1, .return_1y = null, .return_3y = null, .return_5y = null, .return_10y = null, .vol_3y = null, .vol_10y = null, .sharpe_3y = null, .sharpe_10y = null, .maxdd_5y = null },
    };
    sortRows(&rows_desc, .return_3y, .desc);
    try testing.expectEqualStrings("B", rows_desc[0].symbol); // 0.20
    try testing.expectEqualStrings("A", rows_desc[1].symbol); // 0.10
    try testing.expectEqualStrings("C", rows_desc[2].symbol); // null at end
}

test "WeightedAvg: skips null and weights correctly" {
    var avg = WeightedAvg{};
    avg.add(0.5, 0.10);
    avg.add(0.3, 0.20);
    avg.add(0.2, null); // skipped
    // Effective: (0.5 × 0.10 + 0.3 × 0.20) / (0.5 + 0.3) = 0.11/0.8 = 0.1375
    try testing.expectApproxEqAbs(@as(f64, 0.1375), avg.value().?, 0.0001);
}

test "WeightedAvg: all-null returns null" {
    var avg = WeightedAvg{};
    avg.add(0.5, null);
    avg.add(0.5, null);
    try testing.expect(avg.value() == null);
}

test "computeTotals: weighted average returns + synthetic risk pass-through" {
    const rows = [_]ReviewRow{
        .{ .symbol = "A", .bucket = "x", .tax_pct = 1.0, .weight = 0.6, .return_1y = 0.10, .return_3y = 0.10, .return_5y = null, .return_10y = null, .vol_3y = 0.20, .vol_10y = null, .sharpe_3y = 1.0, .sharpe_10y = null, .maxdd_5y = 0.30 },
        .{ .symbol = "B", .bucket = "y", .tax_pct = 0.0, .weight = 0.4, .return_1y = 0.20, .return_3y = 0.05, .return_5y = null, .return_10y = null, .vol_3y = 0.10, .vol_10y = null, .sharpe_3y = 0.5, .sharpe_10y = null, .maxdd_5y = 0.10 },
    };
    const synth: portfolio_risk.SyntheticRisk = .{
        .vol_3y = 0.13,
        .sharpe_3y = 0.9,
        .maxdd_5y = 0.18,
        .return_3y = 0.08,
    };
    const t = computeTotals(&rows, synth);
    // Weighted-avg returns when synth doesn't carry total_return:
    // 1Y: 0.6×0.10 + 0.4×0.20 = 0.14
    try testing.expectApproxEqAbs(@as(f64, 0.14), t.return_1y.?, 0.0001);
    // 3Y: synth's return_3y wins (0.08).
    try testing.expectApproxEqAbs(@as(f64, 0.08), t.return_3y.?, 0.0001);
    // Tax%: 0.6×1.0 + 0.4×0.0 = 0.6
    try testing.expectApproxEqAbs(@as(f64, 0.6), t.tax_pct.?, 0.0001);
    // Risk numbers pass through directly from synth.
    try testing.expectApproxEqAbs(@as(f64, 0.13), t.vol_3y.?, 0.0001);
    try testing.expectApproxEqAbs(@as(f64, 0.9), t.sharpe_3y.?, 0.0001);
    try testing.expectApproxEqAbs(@as(f64, 0.18), t.maxdd_5y.?, 0.0001);
}

test "bucketForSymbol: returns Unclassified for unknown symbol" {
    var entries = [_]classification.ClassificationEntry{};
    const cm: classification.ClassificationMap = .{
        .entries = entries[0..],
        .allocator = testing.allocator,
    };
    try testing.expectEqualStrings("Unclassified", bucketForSymbol("NOPE", cm));
}

test "bucketForSymbol: returns bucket field directly (parser pre-fills it)" {
    // bucketForSymbol no longer calls deriveBucket — it just
    // reads `entry.bucket` which is pre-populated by
    // `parseClassificationFile`. Tests that synthesize entries
    // by hand must set `bucket` themselves.
    var entries = [_]classification.ClassificationEntry{
        .{ .symbol = "AAPL", .bucket = "Technology", .sector = "Technology", .pct = 100.0 },
    };
    const cm: classification.ClassificationMap = .{
        .entries = entries[0..],
        .allocator = testing.allocator,
    };
    try testing.expectEqualStrings("Technology", bucketForSymbol("AAPL", cm));
}

test "bucketForSymbol: multi-row entry picks most-weighted row's bucket" {
    var entries = [_]classification.ClassificationEntry{
        .{ .symbol = "X", .bucket = "Minor", .pct = 10.0 },
        .{ .symbol = "X", .bucket = "Major", .pct = 90.0 },
    };
    const cm: classification.ClassificationMap = .{
        .entries = entries[0..],
        .allocator = testing.allocator,
    };
    try testing.expectEqualStrings("Major", bucketForSymbol("X", cm));
}

test "volIntent: thresholds bucketize correctly" {
    try testing.expectEqual(format.StyleIntent.muted, volIntent(null));
    try testing.expectEqual(format.StyleIntent.positive, volIntent(0.05));
    try testing.expectEqual(format.StyleIntent.positive, volIntent(0.119));
    try testing.expectEqual(format.StyleIntent.warning, volIntent(0.12));
    try testing.expectEqual(format.StyleIntent.warning, volIntent(0.18));
    try testing.expectEqual(format.StyleIntent.warning, volIntent(0.219));
    try testing.expectEqual(format.StyleIntent.negative, volIntent(0.22));
    try testing.expectEqual(format.StyleIntent.negative, volIntent(0.45));
}

test "sharpeIntent: thresholds bucketize correctly" {
    try testing.expectEqual(format.StyleIntent.muted, sharpeIntent(null));
    try testing.expectEqual(format.StyleIntent.negative, sharpeIntent(-0.1));
    try testing.expectEqual(format.StyleIntent.warning, sharpeIntent(0.0));
    try testing.expectEqual(format.StyleIntent.warning, sharpeIntent(0.3));
    try testing.expectEqual(format.StyleIntent.warning, sharpeIntent(0.499));
    try testing.expectEqual(format.StyleIntent.positive, sharpeIntent(0.5));
    try testing.expectEqual(format.StyleIntent.positive, sharpeIntent(1.5));
}

test "maxddIntent: thresholds bucketize correctly" {
    try testing.expectEqual(format.StyleIntent.muted, maxddIntent(null));
    // Shallow drawdown — bonds, balanced funds.
    try testing.expectEqual(format.StyleIntent.positive, maxddIntent(0.05));
    try testing.expectEqual(format.StyleIntent.positive, maxddIntent(0.149));
    // Typical equity-index territory (2022 bear ≈ 25%).
    try testing.expectEqual(format.StyleIntent.warning, maxddIntent(0.15));
    try testing.expectEqual(format.StyleIntent.warning, maxddIntent(0.25));
    try testing.expectEqual(format.StyleIntent.warning, maxddIntent(0.299));
    // Deep drawdown — concentrated, growth-heavy, sector funds.
    try testing.expectEqual(format.StyleIntent.negative, maxddIntent(0.30));
    try testing.expectEqual(format.StyleIntent.negative, maxddIntent(0.50));
}

test "returnIntent: signs map to gain/loss colors" {
    try testing.expectEqual(format.StyleIntent.muted, returnIntent(null));
    try testing.expectEqual(format.StyleIntent.positive, returnIntent(0.10));
    try testing.expectEqual(format.StyleIntent.negative, returnIntent(-0.05));
    try testing.expectEqual(format.StyleIntent.normal, returnIntent(0.0));
}

test "buildReview: end-to-end with testing allocator (leak check)" {
    // Allocator detects leaks: every allocation must be freed for the
    // test to pass. Exercises the full buildReview lifecycle including
    // ReviewView.deinit. Also stress-tests the sort helper afterward
    // since sortRows touches the same memory.
    const Candle = zfin.Candle;
    const D = zfin.Date;
    const Lot = @import("../models/portfolio.zig").Lot;

    // Two-symbol portfolio: VTI (taxable) + BND (Roth).
    var allocs = [_]valuation.Allocation{
        .{
            .symbol = "VTI",
            .display_symbol = "VTI",
            .shares = 100,
            .avg_cost = 200,
            .current_price = 220,
            .market_value = 22000,
            .cost_basis = 20000,
            .weight = 0.6,
            .unrealized_gain_loss = 2000,
            .unrealized_return = 0.10,
            .account = "Brokerage",
        },
        .{
            .symbol = "BND",
            .display_symbol = "BND",
            .shares = 100,
            .avg_cost = 80,
            .current_price = 75,
            .market_value = 7500,
            .cost_basis = 8000,
            .weight = 0.4,
            .unrealized_gain_loss = -500,
            .unrealized_return = -0.0625,
            .account = "Roth IRA",
        },
    };
    const summary: valuation.PortfolioSummary = .{
        .total_value = 29500,
        .total_cost = 28000,
        .unrealized_gain_loss = 1500,
        .unrealized_return = 0.0535,
        .realized_gain_loss = 0,
        .allocations = allocs[0..],
    };

    var lots = [_]Lot{
        .{ .symbol = "VTI", .shares = 100, .open_date = D.fromYmd(2022, 1, 10), .open_price = 200, .account = "Brokerage" },
        .{ .symbol = "BND", .shares = 100, .open_date = D.fromYmd(2022, 1, 10), .open_price = 80, .account = "Roth IRA" },
    };
    const portfolio: zfin.Portfolio = .{
        .lots = lots[0..],
        .allocator = testing.allocator,
    };

    // Empty candle/dividend maps — buildReview handles missing data
    // gracefully (fields render as null).
    var candle_map = std.StringHashMap([]const Candle).init(testing.allocator);
    defer candle_map.deinit();

    var class_entries = [_]classification.ClassificationEntry{
        // bucket is normally pre-filled by parseClassificationFile;
        // hand-synthesized entries set it explicitly. For NPORT-P-
        // style sectors with geo+asset_class, deriveBucket would
        // produce the composite "{geo} {asset_class}".
        .{ .symbol = "VTI", .bucket = "US ETF", .sector = "Equity / Corporate", .geo = "US", .asset_class = "ETF", .pct = 100.0 },
        .{ .symbol = "BND", .bucket = "US Fund", .sector = "Debt / US Treasury", .geo = "US", .asset_class = "Fund", .pct = 100.0 },
    };
    const cm: classification.ClassificationMap = .{
        .entries = class_entries[0..],
        .allocator = testing.allocator,
    };

    var account_entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Brokerage", .tax_type = .taxable },
        .{ .account = "Roth IRA", .tax_type = .roth },
    };
    const am: analysis.AccountMap = .{
        .entries = account_entries[0..],
        .allocator = testing.allocator,
    };

    var view = try buildReview(
        testing.allocator,
        std.testing.io,
        summary,
        &candle_map,
        null, // no dividend map
        portfolio,
        cm,
        am,
        D.fromYmd(2026, 6, 4),
        "test_portfolio.srf",
    );
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 2), view.rows.len);
    try testing.expectApproxEqAbs(@as(f64, 1.0), view.totals.weight, 0.001);

    // Tax%: VTI is in taxable account (1.0), BND in Roth (0.0).
    // Find each row by symbol since order isn't yet sorted.
    // Buckets: both are NPORT-P-style sectors so deriveBucket
    // falls through to the (geo, asset_class) composite.
    for (view.rows) |r| {
        if (std.mem.eql(u8, r.symbol, "VTI")) {
            try testing.expectApproxEqAbs(@as(f64, 1.0), r.tax_pct.?, 0.001);
            try testing.expectEqualStrings("US ETF", r.bucket);
        } else if (std.mem.eql(u8, r.symbol, "BND")) {
            try testing.expectApproxEqAbs(@as(f64, 0.0), r.tax_pct.?, 0.001);
            try testing.expectEqualStrings("US Fund", r.bucket);
        }
    }

    // Sort exercises the same memory; should not leak.
    sortGroupedByDefault(view.rows);
    sortRows(view.rows, .weight, .desc);
    sortRows(view.rows, .symbol, .asc);
}
