//! Observations engine: portfolio sanity checks for the review surface.
//!
//! Each "check" is a self-contained function that examines the live
//! review-view data (rows + totals) and returns a `CheckResult`:
//!
//!   - `pass` — the check ran, no issue.
//!   - `warn` — approaching a threshold; user should pay attention.
//!   - `flag` — over a threshold; user should consider acting.
//!   - `skipped` — the check is registered but disabled for this run
//!     (drift falls into this slot until temporal observations ship).
//!   - `err` — the check ran but couldn't compute (missing data, etc).
//!
//! The engine runs registered checks via `runChecks` and returns a
//! `CheckPanel` that the renderer reads to draw the status grid + the
//! findings table. Checks are async-ready via `is_long_running`: today
//! every check is sync, but the contract supports background dispatch
//! via `std.Io.async` for future slow checks (drift detection over
//! snapshot diffs, benchmark-relative thresholds, etc).
//!
//! ## Threshold scaling
//!
//! Concentration thresholds (position, sector) scale with portfolio
//! size — fixed-percentage thresholds break for portfolios outside the
//! typical 10-30 position range. Each scale-aware check uses a
//! multiplier-with-clamps formula:
//!
//!   threshold = clamp(multiplier × equal_weight, floor, cap)
//!
//! For 27 positions, equal_weight = 3.7%, position warn at 4× = ~15%
//! (clamps at floor 10%). For 10 positions, equal_weight = 10%, warn
//! at 4× = 40% (clamps at cap 50%). Multipliers + clamps tuned for
//! typical real-world portfolios; revisit annually via the
//! `observation_thresholds_last_reviewed` staleness anchor below.

const std = @import("std");
const Date = @import("../Date.zig");
const review_view = @import("../views/review.zig");

// ── Public types ──────────────────────────────────────────────

/// Severity of a flagged finding. `pass` and `skipped` checks don't
/// produce findings; only warn / flag / err do.
pub const Severity = enum { warn, flag, err };

/// One finding produced by a check. Multiple findings per check are
/// allowed (e.g. position concentration emits one per overweight
/// holding). Pure data; allocator-owned.
pub const Observation = struct {
    severity: Severity,
    /// Stable observation kind — the `Check.name` of the check that
    /// produced this finding. Used by the journal as the
    /// `observation` field of an `Acknowledgment`.
    kind: []const u8,
    /// Per-check target string convention. `"NVDA"` for single-symbol
    /// observations; `"sector:Technology"` for sector-scoped;
    /// `"VTI,SCHD"` for pair-based dominance. The journal looks up
    /// acks by `(observation, target)` so the convention must be
    /// stable per check.
    target: []const u8,
    /// Human-readable text rendered in the findings table. Includes
    /// thresholds and current value; e.g. "NVDA at 18.2% of liquid
    /// (warn at 14.8%, flag at 22.2%)".
    text: []const u8,
};

/// Result of running one check. Allocator semantics: `warn` and
/// `flag` slices, plus the strings inside their `Observation`s, are
/// allocated by the runner against the panel's allocator and freed
/// in `CheckPanel.deinit`. `err` strings are similarly allocated.
pub const CheckResult = union(enum) {
    pass,
    warn: []const Observation,
    flag: []const Observation,
    skipped,
    err: []const u8,
};

/// Inputs every check sees. The `allocator` is for the check to
/// allocate its result strings against; the runner takes ownership
/// of those allocations at the end of the dispatch.
pub const CheckCtx = struct {
    allocator: std.mem.Allocator,
    rows: []const review_view.ReviewRow,
    totals: review_view.ReviewTotals,
};

/// Definition of a single check. Multiple `Check` values are
/// registered in `default_checks` below.
pub const Check = struct {
    /// Stable identifier; used as the `observation` field on journal
    /// acks. Snake_case, no whitespace. Changing this is a breaking
    /// change for any portfolio with existing acks.
    name: []const u8,
    /// Human-readable label for the status grid. ~21 chars or less
    /// for the current 4-column layout budget.
    label: []const u8,
    /// Hint to the engine: should this run on a background thread?
    /// All milestone-2 checks are pure-CPU and complete in
    /// microseconds; this stays `false`. Drift detection (when it
    /// ships) sets it `true` because snapshot diffing is I/O.
    is_long_running: bool = false,
    /// The check itself. Pure function over `CheckCtx`. Returns a
    /// `CheckResult` whose owned allocations belong to `ctx.allocator`.
    run: *const fn (ctx: CheckCtx) CheckResult,
};

/// Per-check execution state, lives on `CheckPanel`. Sync checks
/// are `.complete` immediately after `runChecks` returns;
/// `is_long_running` checks start `.pending` and transition to
/// `.complete` via `poll` or `awaitResult`.
pub const PendingCheck = struct {
    check: *const Check,
    /// Completion flag set by the async wrapper as its final
    /// action. `poll` reads this to detect completion without
    /// blocking — `std.Io.Future` itself only offers blocking
    /// `await`/`cancel`, so the flag is what makes a
    /// non-blocking probe possible. The tiny window between
    /// flag-set and the future's internal result write is
    /// covered by the (then nearly instant) `await` in `poll`.
    done: std.atomic.Value(bool) = .init(false),
    state: union(enum) {
        complete: CheckResult,
        pending: std.Io.Future(CheckResult),
    },

    /// Returns the resolved `CheckResult`, awaiting (blocking) if
    /// necessary. Idempotent.
    pub fn awaitResult(self: *PendingCheck, io: std.Io) CheckResult {
        switch (self.state) {
            .complete => |r| return r,
            .pending => |*f| {
                const r = f.await(io);
                self.state = .{ .complete = r };
                return r;
            },
        }
    }

    /// Non-blocking completion probe. Returns true when the
    /// result is available in `.complete` (transitioning it
    /// there if the async task just finished). Returns false
    /// while the task is still running.
    pub fn poll(self: *PendingCheck, io: std.Io) bool {
        switch (self.state) {
            .complete => return true,
            .pending => |*f| {
                if (!self.done.load(.acquire)) return false;
                const r = f.await(io);
                self.state = .{ .complete = r };
                return true;
            },
        }
    }
};

/// Runtime-built panel of checks + their results. Owned by the
/// caller; `deinit` releases all allocated memory (including the
/// per-finding strings inside each `CheckResult`).
pub const CheckPanel = struct {
    allocator: std.mem.Allocator,
    io: std.Io,
    pending: []PendingCheck,

    pub fn deinit(self: *CheckPanel) void {
        for (self.pending) |*pc| {
            // Resolve any still-pending future before freeing.
            // `cancel` requests cancelation and blocks until the
            // task returns — the task may still produce a full
            // result (checks don't hit cancelation points today),
            // which we then free like any complete result.
            const result = switch (pc.state) {
                .complete => |r| r,
                .pending => |*f| f.cancel(self.io),
            };
            freeResult(self.allocator, result);
        }
        self.allocator.free(self.pending);
        self.* = undefined;
    }

    /// True iff every check has a resolved result. Non-blocking:
    /// polls each pending check, transitioning newly-finished
    /// ones to `.complete` as a side effect.
    pub fn isComplete(self: *CheckPanel) bool {
        var all = true;
        for (self.pending) |*pc| {
            if (!pc.poll(self.io)) all = false;
        }
        return all;
    }
};

/// Free the strings owned by a `CheckResult`. Called from
/// `CheckPanel.deinit`; checks themselves allocate against
/// `ctx.allocator`.
fn freeResult(a: std.mem.Allocator, result: CheckResult) void {
    switch (result) {
        .pass, .skipped => {},
        .warn => |obs| freeObservations(a, obs),
        .flag => |obs| freeObservations(a, obs),
        .err => |msg| a.free(msg),
    }
}

fn freeObservations(a: std.mem.Allocator, obs: []const Observation) void {
    for (obs) |o| {
        a.free(o.kind);
        a.free(o.target);
        a.free(o.text);
    }
    a.free(obs);
}

// ── Runner ────────────────────────────────────────────────────

/// Async wrapper for long-running checks. Runs the check, then
/// sets the completion flag as the final action so `poll` can
/// detect the result without blocking.
///
/// IMPORTANT: `done` points into the panel's `pending` array,
/// and `ctx`'s borrowed slices (`rows`, `totals`) must outlive
/// the task. Both invariants are owned by `runChecks`'s caller
/// contract: the panel and the review view that owns ctx's rows
/// live until `CheckPanel.deinit`, which resolves all futures
/// before anything is freed.
fn runCheckTask(check: *const Check, ctx: CheckCtx, done: *std.atomic.Value(bool)) CheckResult {
    const result = check.run(ctx);
    done.store(true, .release);
    return result;
}

/// Run the registered checks against the given context. Returns a
/// `CheckPanel` that renderers consume.
///
/// Sync checks (`is_long_running == false`) complete inline; the
/// panel entry is `.complete` on return. Long-running checks are
/// dispatched via `io.async` and start `.pending`; callers
/// resolve them with `PendingCheck.poll` (non-blocking, for
/// progressive TUI rendering) or `awaitResult` (blocking, for
/// CLI output).
///
/// Lifetime contract: `ctx.rows` / `ctx.totals` are borrowed by
/// in-flight async checks. The caller must keep them alive until
/// every pending check resolves — in practice both the panel and
/// the rows live on the same `ReviewView` and are torn down
/// together by `ReviewView.deinit` (panel first, which resolves
/// stragglers via cancel).
pub fn runChecks(
    allocator: std.mem.Allocator,
    io: std.Io,
    ctx: CheckCtx,
    checks: []const Check,
) !CheckPanel {
    var pending = try allocator.alloc(PendingCheck, checks.len);
    errdefer allocator.free(pending);

    // Two passes: initialize every slot first so the `done`
    // flags have stable addresses, THEN spawn the async tasks
    // that point at them. (Spawning during the init loop would
    // hand out pointers into an array we're still writing.)
    for (checks, 0..) |*check, i| {
        pending[i] = .{ .check = check, .state = .{ .complete = .skipped } };
    }

    for (checks, 0..) |*check, i| {
        if (check.is_long_running) {
            pending[i].state = .{
                .pending = io.async(runCheckTask, .{ check, ctx, &pending[i].done }),
            };
        } else {
            pending[i].state = .{ .complete = check.run(ctx) };
        }
    }

    return .{ .allocator = allocator, .io = io, .pending = pending };
}

// ── Threshold constants ───────────────────────────────────────
//
// See module-level comment for the multiplier-with-clamps rationale.

// Position concentration: a single holding too large relative to a
// portfolio of N positions.
const position_warn_multiplier: f64 = 4.0;
const position_warn_floor: f64 = 0.10;
const position_warn_cap: f64 = 0.50;
const position_flag_multiplier: f64 = 6.0;
const position_flag_floor: f64 = 0.15;
const position_flag_cap: f64 = 0.70;

// Sector concentration: too much weight in a single sector relative
// to a portfolio of M represented sectors.
const sector_warn_multiplier: f64 = 2.5;
const sector_warn_floor: f64 = 0.20;
const sector_warn_cap: f64 = 0.60;
const sector_flag_multiplier: f64 = 4.0;
const sector_flag_floor: f64 = 0.30;
const sector_flag_cap: f64 = 0.75;

// Vol outlier: ratio of holding's 3Y vol to portfolio 3Y vol.
const vol_outlier_warn_ratio: f64 = 1.8;
const vol_outlier_flag_ratio: f64 = 2.5;

// Sector dominance: Sharpe spread within a same-sector pair, where
// both holdings have weight > min_weight_factor × equal_weight.
const dominance_warn_spread: f64 = 0.4;
const dominance_flag_spread: f64 = 0.7;
const dominance_min_weight_factor: f64 = 0.5;

// Tiny position: relative weight floor (no absolute-dollar
// threshold; relative-only by user decision).
const tiny_warn_weight: f64 = 0.005;
const tiny_flag_weight: f64 = 0.0025;

/// Annual sanity-check anchor for the threshold constants above.
/// Like the review-tab MaxDD anchor, these values are calibrated
/// against typical portfolios; they may need tuning over time.
///
/// Annual recheck procedure:
///   1. Run `zfin review` against your portfolio.
///   2. Status grid should show ✅ on most checks for a well-
///      diversified portfolio; ⚠️ or ❌ should be rare and
///      intentional.
///   3. If a check ALWAYS flags or NEVER flags across reasonable
///      portfolios, the multipliers / clamps need adjustment.
///
/// Bump the date when satisfied; otherwise tune first then bump.
///
/// Registered with the staleness checker in `src/data/staleness.zig`.
pub const observation_thresholds_last_reviewed: Date = Date.fromYmd(2026, 6, 8);

// ── Default check registry ────────────────────────────────────

pub const default_checks = [_]Check{
    .{
        .name = "position_concentration",
        .label = "Position concentration",
        .run = checkPositionConcentration,
    },
    .{
        .name = "sector_concentration",
        .label = "Sector concentration",
        .run = checkSectorConcentration,
    },
    .{
        .name = "sector_dominance",
        .label = "Sector dominance",
        .run = checkSectorDominance,
    },
    .{
        .name = "vol_outlier",
        .label = "Vol outlier",
        .run = checkVolOutlier,
    },
    .{
        .name = "tiny_position",
        .label = "Tiny position",
        .run = checkTinyPosition,
    },
    .{
        .name = "drift",
        .label = "Drift since last view",
        .is_long_running = true, // future: snapshot diff is I/O-bound
        .run = checkDrift,
    },
};

// ── Check implementations ─────────────────────────────────────

fn checkPositionConcentration(ctx: CheckCtx) CheckResult {
    if (ctx.rows.len == 0) return .pass;
    const n: f64 = @floatFromInt(ctx.rows.len);
    const equal_weight = 1.0 / n;
    const warn_thresh = std.math.clamp(position_warn_multiplier * equal_weight, position_warn_floor, position_warn_cap);
    const flag_thresh = std.math.clamp(position_flag_multiplier * equal_weight, position_flag_floor, position_flag_cap);

    return collectFindingsByWeight(
        ctx,
        "position_concentration",
        warn_thresh,
        flag_thresh,
        &positionFindingsBuilder,
    );
}

fn positionFindingsBuilder(
    a: std.mem.Allocator,
    row: review_view.ReviewRow,
    severity: Severity,
    warn_thresh: f64,
    flag_thresh: f64,
) !Observation {
    const text = try std.fmt.allocPrint(a, "{s} at {d:.1}% of liquid (warn at {d:.1}%, flag at {d:.1}%)", .{
        row.symbol,
        row.weight * 100.0,
        warn_thresh * 100.0,
        flag_thresh * 100.0,
    });
    return .{
        .severity = severity,
        .kind = try a.dupe(u8, "position_concentration"),
        .target = try a.dupe(u8, row.symbol),
        .text = text,
    };
}

const FindingBuilder = *const fn (
    a: std.mem.Allocator,
    row: review_view.ReviewRow,
    severity: Severity,
    warn: f64,
    flag: f64,
) anyerror!Observation;

fn collectFindingsByWeight(
    ctx: CheckCtx,
    kind: []const u8,
    warn_thresh: f64,
    flag_thresh: f64,
    builder: FindingBuilder,
) CheckResult {
    _ = kind;
    var findings = std.ArrayList(Observation).empty;
    errdefer {
        for (findings.items) |o| {
            ctx.allocator.free(o.kind);
            ctx.allocator.free(o.target);
            ctx.allocator.free(o.text);
        }
        findings.deinit(ctx.allocator);
    }
    var has_flag = false;
    var has_warn = false;

    for (ctx.rows) |row| {
        const sev: ?Severity = if (row.weight >= flag_thresh)
            .flag
        else if (row.weight >= warn_thresh)
            .warn
        else
            null;
        const s = sev orelse continue;
        const obs = builder(ctx.allocator, row, s, warn_thresh, flag_thresh) catch return errResult(ctx.allocator, "allocation failed");
        findings.append(ctx.allocator, obs) catch return errResult(ctx.allocator, "allocation failed");
        if (s == .flag) has_flag = true else if (s == .warn) has_warn = true;
    }

    if (findings.items.len == 0) return .pass;
    const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "allocation failed");

    // If any finding is a flag, the whole check is `flag`. Otherwise
    // it's `warn`. The per-finding severity inside the slice tells
    // the renderer what color each row gets; the wrapping variant
    // tells the status-grid glyph what to show.
    if (has_flag) return .{ .flag = slice };
    if (has_warn) return .{ .warn = slice };
    return .pass;
}

fn errResult(a: std.mem.Allocator, msg: []const u8) CheckResult {
    const owned = a.dupe(u8, msg) catch return .pass; // fallback: silently pass on alloc failure
    return .{ .err = owned };
}

fn checkSectorConcentration(ctx: CheckCtx) CheckResult {
    if (ctx.rows.len == 0) return .pass;

    // Aggregate weight per sector.
    var sector_weights = std.StringHashMap(f64).init(ctx.allocator);
    defer sector_weights.deinit();

    for (ctx.rows) |row| {
        const existing = sector_weights.get(row.bucket) orelse 0;
        sector_weights.put(row.bucket, existing + row.weight) catch return errResult(ctx.allocator, "alloc failed");
    }

    const m: f64 = @floatFromInt(sector_weights.count());
    if (m == 0) return .pass;
    const equal_weight = 1.0 / m;
    const warn_thresh = std.math.clamp(sector_warn_multiplier * equal_weight, sector_warn_floor, sector_warn_cap);
    const flag_thresh = std.math.clamp(sector_flag_multiplier * equal_weight, sector_flag_floor, sector_flag_cap);

    var findings = std.ArrayList(Observation).empty;
    errdefer {
        for (findings.items) |o| {
            ctx.allocator.free(o.kind);
            ctx.allocator.free(o.target);
            ctx.allocator.free(o.text);
        }
        findings.deinit(ctx.allocator);
    }
    var has_flag = false;
    var has_warn = false;

    var it = sector_weights.iterator();
    while (it.next()) |entry| {
        const weight = entry.value_ptr.*;
        const sev: ?Severity = if (weight >= flag_thresh)
            .flag
        else if (weight >= warn_thresh)
            .warn
        else
            null;
        const s = sev orelse continue;
        const text = std.fmt.allocPrint(ctx.allocator, "{s} sector at {d:.1}% (warn at {d:.1}%, flag at {d:.1}%)", .{
            entry.key_ptr.*,
            weight * 100.0,
            warn_thresh * 100.0,
            flag_thresh * 100.0,
        }) catch return errResult(ctx.allocator, "alloc failed");
        const target = std.fmt.allocPrint(ctx.allocator, "sector:{s}", .{entry.key_ptr.*}) catch {
            ctx.allocator.free(text);
            return errResult(ctx.allocator, "alloc failed");
        };
        const kind = ctx.allocator.dupe(u8, "sector_concentration") catch {
            ctx.allocator.free(text);
            ctx.allocator.free(target);
            return errResult(ctx.allocator, "alloc failed");
        };
        findings.append(ctx.allocator, .{
            .severity = s,
            .kind = kind,
            .target = target,
            .text = text,
        }) catch return errResult(ctx.allocator, "alloc failed");
        if (s == .flag) has_flag = true else if (s == .warn) has_warn = true;
    }

    if (findings.items.len == 0) return .pass;
    const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed");
    if (has_flag) return .{ .flag = slice };
    if (has_warn) return .{ .warn = slice };
    return .pass;
}

fn checkSectorDominance(ctx: CheckCtx) CheckResult {
    if (ctx.rows.len < 2) return .pass;
    const n: f64 = @floatFromInt(ctx.rows.len);
    const min_weight = (1.0 / n) * dominance_min_weight_factor;

    var findings = std.ArrayList(Observation).empty;
    errdefer {
        for (findings.items) |o| {
            ctx.allocator.free(o.kind);
            ctx.allocator.free(o.target);
            ctx.allocator.free(o.text);
        }
        findings.deinit(ctx.allocator);
    }
    var has_flag = false;
    var has_warn = false;

    // O(n²) walk over same-bucket pairs. With n typically <50,
    // this should be 2500 comparisons of f64s.
    //
    // Skip pairs whose bucket contains '/'. Those are the
    // NPORT-P fund-decomp categories ("Equity / Corporate",
    // "Debt / Corporate", etc.) — meaningless for dominance
    // because they lump together genuinely different funds
    // (SPY, FRDM, HFXI, VTTHX all sit in "Equity / Corporate").
    // The composite-fallback buckets ("US ETF", "International
    // Developed Fund") and user-curated buckets don't contain
    // '/' and survive the filter.
    for (ctx.rows, 0..) |a_row, i| {
        if (a_row.weight < min_weight) continue;
        if (std.mem.indexOfScalar(u8, a_row.bucket, '/') != null) continue;
        const a_sharpe = a_row.sharpe_3y orelse continue;
        for (ctx.rows[i + 1 ..]) |b_row| {
            if (b_row.weight < min_weight) continue;
            if (!std.mem.eql(u8, a_row.bucket, b_row.bucket)) continue;
            const b_sharpe = b_row.sharpe_3y orelse continue;
            const spread = @abs(a_sharpe - b_sharpe);
            const sev: ?Severity = if (spread >= dominance_flag_spread)
                .flag
            else if (spread >= dominance_warn_spread)
                .warn
            else
                null;
            const s = sev orelse continue;

            // The "dominant" holding is the higher-Sharpe one.
            const winner = if (a_sharpe > b_sharpe) a_row.symbol else b_row.symbol;
            const loser = if (a_sharpe > b_sharpe) b_row.symbol else a_row.symbol;
            const winner_sharpe = if (a_sharpe > b_sharpe) a_sharpe else b_sharpe;
            const loser_sharpe = if (a_sharpe > b_sharpe) b_sharpe else a_sharpe;

            const text = std.fmt.allocPrint(ctx.allocator, "{s} ({d:.2} 3Y Sharpe) outperforms {s} ({d:.2}) in same sector ({s}); spread {d:.2}", .{
                winner,
                winner_sharpe,
                loser,
                loser_sharpe,
                a_row.bucket,
                spread,
            }) catch return errResult(ctx.allocator, "alloc failed");
            const target = std.fmt.allocPrint(ctx.allocator, "{s},{s}", .{ winner, loser }) catch {
                ctx.allocator.free(text);
                return errResult(ctx.allocator, "alloc failed");
            };
            const kind = ctx.allocator.dupe(u8, "sector_dominance") catch {
                ctx.allocator.free(text);
                ctx.allocator.free(target);
                return errResult(ctx.allocator, "alloc failed");
            };
            findings.append(ctx.allocator, .{
                .severity = s,
                .kind = kind,
                .target = target,
                .text = text,
            }) catch return errResult(ctx.allocator, "alloc failed");
            if (s == .flag) has_flag = true else if (s == .warn) has_warn = true;
        }
    }

    if (findings.items.len == 0) return .pass;
    const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed");
    if (has_flag) return .{ .flag = slice };
    if (has_warn) return .{ .warn = slice };
    return .pass;
}

fn checkVolOutlier(ctx: CheckCtx) CheckResult {
    const port_vol = ctx.totals.vol_3y orelse return .pass; // can't compare without portfolio vol
    if (port_vol <= 0) return .pass;

    var findings = std.ArrayList(Observation).empty;
    errdefer {
        for (findings.items) |o| {
            ctx.allocator.free(o.kind);
            ctx.allocator.free(o.target);
            ctx.allocator.free(o.text);
        }
        findings.deinit(ctx.allocator);
    }
    var has_flag = false;
    var has_warn = false;

    for (ctx.rows) |row| {
        const v = row.vol_3y orelse continue;
        const ratio = v / port_vol;
        const sev: ?Severity = if (ratio >= vol_outlier_flag_ratio)
            .flag
        else if (ratio >= vol_outlier_warn_ratio)
            .warn
        else
            null;
        const s = sev orelse continue;
        const text = std.fmt.allocPrint(ctx.allocator, "{s} 3Y vol {d:.1}% is {d:.1}× portfolio vol ({d:.1}%) (warn at {d:.1}×, flag at {d:.1}×)", .{
            row.symbol,
            v * 100.0,
            ratio,
            port_vol * 100.0,
            vol_outlier_warn_ratio,
            vol_outlier_flag_ratio,
        }) catch return errResult(ctx.allocator, "alloc failed");
        const target = ctx.allocator.dupe(u8, row.symbol) catch {
            ctx.allocator.free(text);
            return errResult(ctx.allocator, "alloc failed");
        };
        const kind = ctx.allocator.dupe(u8, "vol_outlier") catch {
            ctx.allocator.free(text);
            ctx.allocator.free(target);
            return errResult(ctx.allocator, "alloc failed");
        };
        findings.append(ctx.allocator, .{
            .severity = s,
            .kind = kind,
            .target = target,
            .text = text,
        }) catch return errResult(ctx.allocator, "alloc failed");
        if (s == .flag) has_flag = true else if (s == .warn) has_warn = true;
    }

    if (findings.items.len == 0) return .pass;
    const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed");
    if (has_flag) return .{ .flag = slice };
    if (has_warn) return .{ .warn = slice };
    return .pass;
}

fn checkTinyPosition(ctx: CheckCtx) CheckResult {
    if (ctx.rows.len == 0) return .pass;

    var findings = std.ArrayList(Observation).empty;
    errdefer {
        for (findings.items) |o| {
            ctx.allocator.free(o.kind);
            ctx.allocator.free(o.target);
            ctx.allocator.free(o.text);
        }
        findings.deinit(ctx.allocator);
    }
    var has_flag = false;
    var has_warn = false;

    for (ctx.rows) |row| {
        const sev: ?Severity = if (row.weight <= tiny_flag_weight)
            .flag
        else if (row.weight <= tiny_warn_weight)
            .warn
        else
            null;
        const s = sev orelse continue;
        const text = std.fmt.allocPrint(ctx.allocator, "{s} at {d:.2}% of liquid (warn ≤ {d:.2}%, flag ≤ {d:.2}%) — consider consolidating or exiting", .{
            row.symbol,
            row.weight * 100.0,
            tiny_warn_weight * 100.0,
            tiny_flag_weight * 100.0,
        }) catch return errResult(ctx.allocator, "alloc failed");
        const target = ctx.allocator.dupe(u8, row.symbol) catch {
            ctx.allocator.free(text);
            return errResult(ctx.allocator, "alloc failed");
        };
        const kind = ctx.allocator.dupe(u8, "tiny_position") catch {
            ctx.allocator.free(text);
            ctx.allocator.free(target);
            return errResult(ctx.allocator, "alloc failed");
        };
        findings.append(ctx.allocator, .{
            .severity = s,
            .kind = kind,
            .target = target,
            .text = text,
        }) catch return errResult(ctx.allocator, "alloc failed");
        if (s == .flag) has_flag = true else if (s == .warn) has_warn = true;
    }

    if (findings.items.len == 0) return .pass;
    const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed");
    if (has_flag) return .{ .flag = slice };
    if (has_warn) return .{ .warn = slice };
    return .pass;
}

/// Drift since last view. Currently a placeholder — returns `skipped`
/// until temporal observations ship in a follow-up. The forward-compat
/// slot in the status grid stays visible (rendered as ➖) so users
/// know the check exists; the engine just never fires it.
fn checkDrift(ctx: CheckCtx) CheckResult {
    _ = ctx;
    return .skipped;
}

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

const testing = std.testing;

fn makeRow(symbol: []const u8, sector: []const u8, weight: f64) review_view.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,
    };
}

fn makeRowWithVolAndSharpe(symbol: []const u8, sector: []const u8, weight: f64, vol: f64, sharpe: f64) review_view.ReviewRow {
    var r = makeRow(symbol, sector, weight);
    r.vol_3y = vol;
    r.sharpe_3y = sharpe;
    return r;
}

fn emptyTotals() review_view.ReviewTotals {
    return .{
        .weight = 1.0,
        .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,
        .tax_pct = null,
        .reweight_flags = .{},
    };
}

test "checkPositionConcentration: balanced portfolio passes" {
    var rows = [_]review_view.ReviewRow{
        makeRow("A", "X", 0.10),
        makeRow("B", "Y", 0.10),
        makeRow("C", "Z", 0.10),
        makeRow("D", "W", 0.10),
        makeRow("E", "V", 0.10),
        makeRow("F", "U", 0.10),
        makeRow("G", "T", 0.10),
        makeRow("H", "S", 0.10),
        makeRow("I", "R", 0.10),
        makeRow("J", "Q", 0.10),
    };
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    const result = checkPositionConcentration(ctx);
    defer freeResult(testing.allocator, result);
    try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result);
}

test "checkPositionConcentration: large position flags with 27 holdings" {
    var rows: [27]review_view.ReviewRow = undefined;
    for (0..27) |i| {
        rows[i] = makeRow("X", "S", 0.03); // ~equal_weight (1/27 ≈ 0.037)
    }
    rows[0] = makeRow("BIG", "S", 0.30); // 30%, well over flag threshold
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    const result = checkPositionConcentration(ctx);
    defer freeResult(testing.allocator, result);
    switch (result) {
        .flag => |obs| {
            try testing.expect(obs.len >= 1);
            // First flagged finding should be the BIG position.
            var found = false;
            for (obs) |o| {
                if (std.mem.eql(u8, o.target, "BIG") and o.severity == .flag) found = true;
            }
            try testing.expect(found);
        },
        else => return error.TestUnexpectedResult,
    }
}

test "checkPositionConcentration: small portfolio uses cap" {
    // 4 positions, equal_weight = 25%. Multiplier × eq = 100% (warn), 150% (flag).
    // Both clamp at cap (50% warn, 70% flag). A 60% holding flags but a 40% doesn't.
    var rows = [_]review_view.ReviewRow{
        makeRow("A", "X", 0.60), // flags (over cap 50% warn, 70% flag — 60% is flag)
        makeRow("B", "Y", 0.20),
        makeRow("C", "Z", 0.10),
        makeRow("D", "W", 0.10),
    };
    // Wait: 60% > flag cap 70%? No, 60 < 70. So it should warn, not flag.
    // Adjust to 75% to actually flag.
    rows[0].weight = 0.75;
    rows[1].weight = 0.10;
    rows[2].weight = 0.10;
    rows[3].weight = 0.05;
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    const result = checkPositionConcentration(ctx);
    defer freeResult(testing.allocator, result);
    switch (result) {
        .flag => |obs| try testing.expect(obs.len >= 1),
        else => return error.TestUnexpectedResult,
    }
}

test "checkSectorConcentration: dominant sector flags" {
    var rows = [_]review_view.ReviewRow{
        makeRow("A", "Tech", 0.40),
        makeRow("B", "Tech", 0.35),
        makeRow("C", "Bonds", 0.10),
        makeRow("D", "Cash", 0.10),
        makeRow("E", "Energy", 0.05),
    };
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    const result = checkSectorConcentration(ctx);
    defer freeResult(testing.allocator, result);
    // Tech at 0.75 → flag (5 sectors → flag_thresh = clamp(0.80, 0.30, 0.75) = 0.75).
    switch (result) {
        .flag => |obs| {
            try testing.expect(obs.len >= 1);
            var tech_found = false;
            for (obs) |o| {
                if (std.mem.indexOf(u8, o.text, "Tech") != null) tech_found = true;
            }
            try testing.expect(tech_found);
        },
        else => return error.TestUnexpectedResult,
    }
}

test "checkSectorDominance: pair with large Sharpe spread flags" {
    var rows = [_]review_view.ReviewRow{
        makeRowWithVolAndSharpe("VTI", "Equity", 0.30, 0.16, 1.20),
        makeRowWithVolAndSharpe("XLK", "Equity", 0.20, 0.20, 0.30),
        makeRowWithVolAndSharpe("BND", "Bonds", 0.30, 0.05, 0.40),
        makeRowWithVolAndSharpe("CASH", "Cash", 0.20, 0.01, 0.10),
    };
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    const result = checkSectorDominance(ctx);
    defer freeResult(testing.allocator, result);
    switch (result) {
        .flag => |obs| {
            try testing.expect(obs.len >= 1);
            // The dominant pair is VTI vs XLK (both Equity, spread 0.9).
            var pair_found = false;
            for (obs) |o| {
                if (std.mem.indexOf(u8, o.target, "VTI") != null and std.mem.indexOf(u8, o.target, "XLK") != null) {
                    pair_found = true;
                }
            }
            try testing.expect(pair_found);
        },
        else => return error.TestUnexpectedResult,
    }
}

test "checkSectorDominance: tiny holding doesn't trigger pair (min_weight filter)" {
    // VTI is meaningful at 30%; the second equity holding is tiny
    // (0.5%) so should be filtered out by the min-weight check.
    var rows = [_]review_view.ReviewRow{
        makeRowWithVolAndSharpe("VTI", "Equity", 0.30, 0.16, 1.20),
        makeRowWithVolAndSharpe("PINK", "Equity", 0.005, 0.40, 0.10), // tiny
        makeRowWithVolAndSharpe("BND", "Bonds", 0.345, 0.05, 0.40),
        makeRowWithVolAndSharpe("CASH", "Cash", 0.350, 0.01, 0.10),
    };
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    const result = checkSectorDominance(ctx);
    defer freeResult(testing.allocator, result);
    try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result);
}

test "checkSectorDominance: bucket containing '/' is skipped (NPORT-P mush filter)" {
    // Two funds with hugely different Sharpes both bucketed as
    // "Equity / Corporate" — the upstream NPORT-P category that
    // lumps genuinely-different funds. Filter should suppress
    // this pair entirely.
    var rows = [_]review_view.ReviewRow{
        makeRowWithVolAndSharpe("FRDM", "Equity / Corporate", 0.30, 0.20, 1.50),
        makeRowWithVolAndSharpe("SCHD", "Equity / Corporate", 0.30, 0.13, 0.80),
        makeRowWithVolAndSharpe("BND", "Bonds", 0.40, 0.05, 0.40),
    };
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    const result = checkSectorDominance(ctx);
    defer freeResult(testing.allocator, result);
    // Spread is 0.70 (flag-worthy) but the '/' filter suppresses it.
    try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result);
}

test "checkSectorDominance: composite-fallback bucket survives the '/' filter" {
    // After deriveBucket runs, NPORT-P sectors get composited
    // into "International Developed Fund" / similar. Those
    // strings DON'T contain '/', so dominance comparisons within
    // composite buckets ARE meaningful and should fire.
    //
    // Weights chosen to clear the min_weight = (1/n) * 0.5
    // threshold (n=3 → min 0.167; both holdings at 0.30).
    var rows = [_]review_view.ReviewRow{
        makeRowWithVolAndSharpe("IDMO", "International Developed Fund", 0.30, 0.13, 1.50),
        makeRowWithVolAndSharpe("HFXI", "International Developed Fund", 0.30, 0.11, 0.60),
        makeRowWithVolAndSharpe("BND", "Bonds", 0.40, 0.05, 0.40),
    };
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    const result = checkSectorDominance(ctx);
    defer freeResult(testing.allocator, result);
    // Spread 0.90 = flag.
    switch (result) {
        .flag => |obs| try testing.expect(obs.len >= 1),
        else => return error.TestUnexpectedResult,
    }
}

test "checkVolOutlier: holding with 3× portfolio vol flags" {
    var rows = [_]review_view.ReviewRow{
        makeRowWithVolAndSharpe("VTI", "Equity", 0.40, 0.15, 1.0),
        makeRowWithVolAndSharpe("WILD", "Equity", 0.20, 0.50, 0.5), // 3.3× of portfolio
        makeRowWithVolAndSharpe("BND", "Bonds", 0.40, 0.05, 0.3),
    };
    var totals = emptyTotals();
    totals.vol_3y = 0.15;
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = totals,
    };
    const result = checkVolOutlier(ctx);
    defer freeResult(testing.allocator, result);
    switch (result) {
        .flag => |obs| {
            try testing.expect(obs.len >= 1);
            var wild_found = false;
            for (obs) |o| if (std.mem.eql(u8, o.target, "WILD")) {
                wild_found = true;
            };
            try testing.expect(wild_found);
        },
        else => return error.TestUnexpectedResult,
    }
}

test "checkVolOutlier: passes when totals.vol_3y is null" {
    var rows = [_]review_view.ReviewRow{
        makeRowWithVolAndSharpe("WILD", "Equity", 0.20, 0.50, 0.5),
    };
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    const result = checkVolOutlier(ctx);
    defer freeResult(testing.allocator, result);
    try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result);
}

test "checkTinyPosition: positions below thresholds flag" {
    var rows = [_]review_view.ReviewRow{
        makeRow("LARGE", "X", 0.30),
        makeRow("SMALL", "Y", 0.003), // 0.3% — under flag threshold (0.25%) ❌ NO, 0.3% > 0.25% so warns
        makeRow("TINY", "Z", 0.002), // 0.2% — under flag threshold (0.25%) ✅ flags
    };
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    const result = checkTinyPosition(ctx);
    defer freeResult(testing.allocator, result);
    switch (result) {
        .flag => |obs| {
            try testing.expect(obs.len >= 1);
            var tiny_found = false;
            for (obs) |o| if (std.mem.eql(u8, o.target, "TINY")) {
                tiny_found = true;
            };
            try testing.expect(tiny_found);
        },
        else => return error.TestUnexpectedResult,
    }
}

test "checkDrift: returns skipped (placeholder)" {
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &.{},
        .totals = emptyTotals(),
    };
    const result = checkDrift(ctx);
    try testing.expectEqual(@as(std.meta.Tag(CheckResult), .skipped), result);
}

test "runChecks: produces a panel with one entry per check" {
    var rows = [_]review_view.ReviewRow{
        makeRow("A", "X", 0.50),
    };
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &rows,
        .totals = emptyTotals(),
    };
    var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks);
    defer panel.deinit();
    try testing.expectEqual(default_checks.len, panel.pending.len);
    // Await everything (drift is is_long_running and runs async);
    // after awaiting, the panel must report complete.
    for (panel.pending) |*pc| _ = pc.awaitResult(std.testing.io);
    try testing.expect(panel.isComplete());
}

test "runChecks: empty portfolio every check passes or skips" {
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &.{},
        .totals = emptyTotals(),
    };
    var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks);
    defer panel.deinit();
    for (panel.pending) |*pc| {
        const tag = std.meta.activeTag(pc.awaitResult(std.testing.io));
        try testing.expect(tag == .pass or tag == .skipped);
    }
}

test "runChecks: async check resolves via poll without blocking forever" {
    // Pin the async-dispatch contract: an is_long_running check
    // starts pending and becomes complete via poll once its task
    // finishes. We can't observe the intermediate pending state
    // deterministically (the task may finish before we look),
    // but we CAN assert the poll loop terminates and yields the
    // right result.
    const slow_check = [_]Check{.{
        .name = "test_async",
        .label = "Test async",
        .is_long_running = true,
        .run = struct {
            fn run(c: CheckCtx) CheckResult {
                _ = c;
                return .pass;
            }
        }.run,
    }};
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &.{},
        .totals = emptyTotals(),
    };
    var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check);
    defer panel.deinit();

    // Poll until complete (bounded loop; the task is trivially
    // fast, so thousands of iterations would indicate a real
    // hang — fail rather than spin forever).
    var iterations: usize = 0;
    while (!panel.isComplete()) : (iterations += 1) {
        try testing.expect(iterations < 1_000_000);
    }
    try testing.expectEqual(CheckResult.pass, panel.pending[0].awaitResult(std.testing.io));
}

test "runChecks: deinit with unresolved async check does not leak or crash" {
    // The panel may be torn down while a check is still pending
    // (user quits the TUI mid-poll). deinit must cancel/resolve
    // the future and free whatever result it produced.
    const slow_check = [_]Check{.{
        .name = "test_async_abandon",
        .label = "Test abandon",
        .is_long_running = true,
        .run = struct {
            fn run(c: CheckCtx) CheckResult {
                // Allocate a real finding so deinit has something
                // to free — exercises the result-ownership path.
                const obs = c.allocator.alloc(Observation, 1) catch return .pass;
                obs[0] = .{
                    .severity = .warn,
                    .kind = c.allocator.dupe(u8, "test_async_abandon") catch return .pass,
                    .target = c.allocator.dupe(u8, "TEST") catch return .pass,
                    .text = c.allocator.dupe(u8, "test finding") catch return .pass,
                };
                return .{ .warn = obs };
            }
        }.run,
    }};
    const ctx: CheckCtx = .{
        .allocator = testing.allocator,
        .rows = &.{},
        .totals = emptyTotals(),
    };
    var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check);
    // Deinit immediately — no poll, no await. testing.allocator
    // catches any leak of the result allocations.
    panel.deinit();
}

test "default_checks: every check name is unique" {
    for (default_checks, 0..) |a, i| {
        for (default_checks[i + 1 ..]) |b| {
            try testing.expect(!std.mem.eql(u8, a.name, b.name));
        }
    }
}
