//! CLI command framework — comptime-validated registry for `zfin` subcommands.
//!
//! The CLI dispatch in `src/main.zig` walks a `command_modules` registry —
//! an anonymous struct literal — at comptime to derive the dispatch chain,
//! the grouped `zfin help` output, and the per-command `zfin <cmd> --help`
//! handler. This file defines the contract every command module must
//! satisfy. Mirrors the TUI tab framework in `src/tui/tab_framework.zig`.
//!
//! ## Contract (per command module)
//!
//! ```zig
//! pub const ParsedArgs = ...;             // struct or tagged union
//!
//! pub const meta: framework.Meta = .{
//!     .name = "perf",
//!     .group = .symbol_lookup,
//!     .synopsis = "Show 1y/3y/5y/10y trailing returns",
//!     .help = "...",                     // multi-line; rendered by `--help`
//!     .uppercase_first_arg = true,
//!     .user_errors = error{ MissingSymbol, UnexpectedArg },
//! };
//!
//! pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs;
//! pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void;
//! ```
//!
//! The `validateCommandModule(Module)` walker enforces the contract at
//! comptime with copy-paste-ready error messages. It's invoked from a
//! single registry walk in `src/main.zig`; individual command files
//! do NOT carry their own validation block. See the doc-comment on
//! `validateCommandModule` for the rationale.
//!
//! ## Global-option promotion criterion
//!
//! A flag belongs as a global iff it is *orthogonal* to every command's
//! semantics (purely an I/O / output / cache-policy concern) AND every
//! command would honor it identically. Today's globals are `--no-color`,
//! `-p`, `-w`, and `--refresh-data=<auto|never|force>`. `--as-of` does
//! NOT pass this test because its meaning varies between view-as-of
//! (`projections`) and write-as-of (`snapshot`), and the two-sided
//! commands need two endpoints, not one — so it stays per-command.

const std = @import("std");
const zfin = @import("../root.zig");
const validator = @import("../comptime_validator.zig");

// ── Group taxonomy ────────────────────────────────────────────

/// Workflow-grouping tag used for `zfin help`'s sectioned output.
/// Every command's `meta.group` field references one of these.
/// Order in this enum is the order sections render in `zfin help`.
pub const Group = enum {
    /// Per-symbol lookups: `perf`, `quote`, `history` (symbol mode),
    /// `divs`, `splits`, `options`, `earnings`, `etf`.
    symbol_lookup,
    /// Portfolio analysis: `portfolio`, `analysis`, `history`
    /// (portfolio mode), `projections`, `milestones`.
    portfolio,
    /// Time-series & journaling: `snapshot`, `compare`,
    /// `contributions`.
    timeseries,
    /// Data hygiene: `audit`, `enrich`, `lookup`.
    hygiene,
    /// Infrastructure: `cache`, `version`, `interactive`.
    infra,

    /// Display label rendered above each section in `zfin help`.
    pub fn label(self: Group) []const u8 {
        return switch (self) {
            .symbol_lookup => "Per-symbol lookups",
            .portfolio => "Portfolio analysis",
            .timeseries => "Time-series & journaling",
            .hygiene => "Data hygiene",
            .infra => "Infrastructure",
        };
    }
};

// ── Meta ──────────────────────────────────────────────────────

/// Per-command metadata. Every command module declares
/// `pub const meta: framework.Meta = .{ ... };` with all fields
/// populated. The compiler enforces the contract via the field
/// types; `validateCommandModule` reduces to a single
/// `expectDeclWithType` check on the declaration's type.
///
/// Fields are deliberately required (no defaults) so command
/// authors think about each one rather than relying on framework
/// defaults. In particular, `uppercase_first_arg` deserves an
/// explicit decision per command — see field doc-comment.
pub const Meta = struct {
    /// User-facing subcommand name. Must match the field name used
    /// for this command in the `command_modules` registry literal
    /// (the dispatcher matches by `f.name`).
    name: []const u8,

    /// Workflow-grouping tag for the `zfin help` sectioned output.
    /// See `Group` for the canonical taxonomy.
    group: Group,

    /// One-line description rendered next to the command name in
    /// `zfin help`.
    synopsis: []const u8,

    /// Multi-line help text rendered by `zfin <cmd> --help`. The
    /// dispatcher emits this verbatim; include the usage line,
    /// flag descriptions, and any caveats. Trailing newline is
    /// added automatically if missing.
    help: []const u8,

    /// Whether to uppercase `cmd_args[0]` before calling
    /// `parseArgs`. Symbol-taking commands (perf, quote, divs,
    /// splits, options, earnings, etf, history-symbol-mode,
    /// lookup) set this to `true` so users can type
    /// `zfin perf aapl` and have it normalized to `AAPL`.
    /// Non-symbol commands (cache, version, portfolio, ...) set
    /// it to `false`.
    ///
    /// The dispatcher's `normalizeFirstArg` honors a "skip when
    /// arg starts with `-`" rule, so flag-leading invocations
    /// like `zfin history --since 2026-01-01` pass through
    /// unchanged regardless of this setting.
    ///
    /// No default — every command author must make an explicit
    /// choice. This is metadata about command shape, not an
    /// optional opt-in; the `false` answer is just as load-bearing
    /// as `true`.
    uppercase_first_arg: bool,

    /// Error set listing the command's user-level errors — errors
    /// where the command has already printed a useful message to
    /// stderr and the dispatcher should just return exit 1 silently.
    /// Anything NOT in this set propagates to Zig's panic handler
    /// with a stack trace, signaling a genuine bug worth investigating.
    ///
    /// Examples:
    /// - `error.MissingSymbol` (parseArgs printed "requires a symbol
    ///   argument") → user-level.
    /// - `error.SnapshotNotFound` (run printed "No snapshot at or
    ///   before X") → user-level.
    /// - `error.OutOfMemory`, `error.Unexpected*` → not user-level;
    ///   these should crash visibly so they don't get swallowed.
    ///
    /// No default — every command author must enumerate the errors
    /// their `parseArgs` and `run` deliberately return as user
    /// signals. Commands with no user-level errors (`version`)
    /// declare `error{}`. Adding a new `return error.X` to a
    /// command means you also add `X` here if it's user-level —
    /// the explicit list IS the contract.
    user_errors: type,
};

/// Returns true if `err` is a member of the comptime-known
/// `UserErrors` error set. Used by the framework dispatcher to
/// decide whether a `Module.run` error is a user-level signal
/// (return exit 1 silently) or an internal bug (propagate to
/// Zig's panic handler).
///
/// Implemented as a comptime-unrolled equality chain over the
/// error set's variants. Cost is one comparison per variant; the
/// largest user-error set today is on the order of 10 variants,
/// so this is cheap.
pub fn isUserError(comptime UserErrors: type, err: anyerror) bool {
    inline for (@typeInfo(UserErrors).error_set orelse return false) |variant| {
        if (err == @field(anyerror, variant.name)) return true;
    }
    return false;
}

// ── Refresh policy ────────────────────────────────────────────

/// Cache-freshness policy for the invocation. Set via the global
/// `--refresh-data=<value>` flag (default: `.auto`). Threaded into
/// `loadPortfolioPrices` and through every multi-symbol command's
/// price-loading code.
///
/// User-facing flag values match the variant names exactly:
///
/// - `auto`: respect cache TTLs. Fresh entries served from cache;
///   stale entries trigger a provider refetch (or server sync if
///   `ZFIN_SERVER` is configured). The right behavior for almost
///   every invocation; the default.
/// - `force`: ignore cache TTL and re-validate against providers.
///   Candle history is topped up incrementally (new bars appended,
///   existing history kept), not wiped. To force a full re-download
///   from scratch (e.g. suspected-bad data or a retroactive split
///   adjustment), use `cache clear`.
/// - `never`: serve whatever's in cache regardless of TTL. No
///   provider calls. Useful for offline operation, debugging, and
///   reproducible historical analysis.
pub const RefreshPolicy = enum { auto, force, never };

// ── Globals ───────────────────────────────────────────────────

/// Parsed global options. Shared between `parseGlobals` (in main.zig)
/// and `RunCtx`. Lives here so `command_modules` entries can reach
/// fields like `globals.portfolio_patterns` without depending on main.zig.
pub const Globals = struct {
    no_color: bool = false,
    /// Explicit portfolio patterns from `-p`/`--portfolio` (raw, may
    /// contain glob metacharacters). Each `-p VALUE` adds one entry.
    /// Empty means "use the default pattern" (`portfolio*.srf`).
    /// Resolution and union-merge happens in `RunCtx.resolvePortfolioPaths`.
    portfolio_patterns: []const []const u8 = &.{},
    /// Explicit watchlist path from -w/--watchlist (raw, null if not set).
    watchlist_path: ?[]const u8 = null,
    /// Cache-freshness policy from `--refresh-data=<value>`.
    refresh_policy: RefreshPolicy = .auto,
};

// ── RunCtx ────────────────────────────────────────────────────

/// Per-invocation context bundle. Every command's `parseArgs` and
/// `run` receive this. Commands ignore fields they don't need.
///
/// Lifetime: created in `runCli` after globals + arena setup, freed
/// implicitly when the invocation ends. `allocator` IS the arena;
/// commands should allocate against it freely without tracking
/// per-allocation deinits.
///
/// Fields here are *invocation context*, not per-command state.
/// Per-command state belongs in the command's own module. If you're
/// tempted to add a field used by only one command, push back —
/// it probably belongs in the command's `ParsedArgs` instead.
pub const RunCtx = struct {
    io: std.Io,
    /// Per-invocation arena. Use this for almost all allocations.
    allocator: std.mem.Allocator,
    /// General-purpose allocator. Reserved for handoff to the TUI
    /// (which has different lifetime requirements than CLI batch
    /// commands). Most commands should ignore this.
    gpa: std.mem.Allocator,
    /// Process environment. Used by `interactive` to thread into
    /// the TUI's Config refresh, and by anything that needs to
    /// inspect env vars at command time.
    environ_map: *const std.process.Environ.Map,
    config: zfin.Config,
    /// Null for commands that don't need provider access (`version`,
    /// `cache`). All other commands must error gracefully if they
    /// receive a null here.
    svc: ?*zfin.DataService,
    globals: Globals,
    /// Today's date, captured once at invocation entry. Stable
    /// across all uses within this invocation per the AGENTS.md
    /// `today` vs `as_of` rule.
    today: zfin.Date,
    /// Wall-clock seconds since epoch, captured once at invocation
    /// entry. For metadata fields like snapshot `captured_at` and
    /// rollup `#!created=`.
    now_s: i64,
    color: bool,
    out: *std.Io.Writer,

    /// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
    /// the default `portfolio*.srf` pattern) through cwd → ZFIN_HOME.
    /// Returns the union of all matched files; an empty list if no
    /// patterns matched anywhere.
    ///
    /// Caller MUST `result.deinit()` to release the per-path
    /// allocations (paths and their containing slice). Allocations
    /// come from the arena allocator.
    pub fn resolvePortfolioPaths(self: *RunCtx) !ResolvedPaths {
        return resolvePatterns(
            self.io,
            self.allocator,
            self.config,
            self.globals.portfolio_patterns,
        );
    }

    /// Single-path convenience: returns the *first* resolved
    /// portfolio path. Used by sibling-file derivation
    /// (`accounts.srf`, `metadata.srf`, `transaction_log.srf`,
    /// `history/`) — these files always live next to the first
    /// portfolio file. Returns the default pattern's first match
    /// when -p is not set; falls through to a literal default if
    /// nothing matched.
    ///
    /// **Do NOT use this for portfolio CONTENT loading.** Portfolio
    /// content must come from the multi-file glob via
    /// `cli.loadPortfolio` (live) or
    /// `portfolio_loader.loadPortfolioFromPathsAtRev` (git
    /// historical). This singular helper is for choosing ONE
    /// concrete path to derive sibling files from — never for
    /// reading lots out of.
    pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath {
        var paths = self.resolvePortfolioPaths() catch {
            return .{ .path = zfin.Config.default_portfolio_filename, .resolved = null };
        };
        if (paths.inner.paths.len == 0) {
            paths.deinit();
            return .{ .path = zfin.Config.default_portfolio_filename, .resolved = null };
        }
        // Hand off the first inner ResolvedPath; release the rest.
        // We can't reuse paths.deinit() because that would free
        // the path we want to keep.
        const keep = paths.inner.paths[0];
        for (paths.inner.paths[1..]) |rp| rp.deinit(self.allocator);
        self.allocator.free(paths.inner.paths);
        // The convenience view (`paths.paths`) is allocated separately.
        self.allocator.free(paths.paths);
        return .{ .path = keep.path, .resolved = keep };
    }

    /// Same as `resolvePortfolioPath` but for the watchlist file.
    pub fn resolveWatchlistPath(self: *RunCtx) ResolvedPath {
        return resolveUserPath(
            self.io,
            self.allocator,
            self.config,
            self.globals.watchlist_path,
            zfin.Config.default_watchlist_filename,
        );
    }
};

pub const ResolvedPath = struct {
    path: []const u8,
    resolved: ?zfin.Config.ResolvedPath,

    pub fn deinit(self: ResolvedPath, allocator: std.mem.Allocator) void {
        if (self.resolved) |r| r.deinit(allocator);
    }
};

/// Multi-path resolution result returned by `resolvePortfolioPaths`.
/// Wraps a `Config.ResolvedPaths` plus a borrowed slice of the
/// `[]const u8` paths for ergonomic iteration. Caller must
/// `deinit()` to release everything.
pub const ResolvedPaths = struct {
    inner: zfin.Config.ResolvedPaths,
    /// Convenience view: the path strings, one per resolved file.
    /// Slices borrow from `inner.paths[i].path`. Allocated in the
    /// same allocator as `inner`.
    paths: []const []const u8,

    pub fn deinit(self: ResolvedPaths) void {
        self.inner.allocator.free(self.paths);
        self.inner.deinit();
    }
};

/// Resolve portfolio path `patterns` against the config (cwd then
/// ZFIN_HOME) and return the union of all matched files. Same
/// semantics as `RunCtx.resolvePortfolioPaths` but takes the inputs
/// directly so non-CLI callers (the TUI, tests) can use the same
/// resolution logic without constructing a `RunCtx`.
///
/// `patterns` may be empty, in which case the default
/// `Config.default_portfolio_filename` glob is used.
///
/// Caller MUST `result.deinit()` to release the per-path
/// allocations (paths and their containing slice).
pub fn resolvePatterns(
    io: std.Io,
    allocator: std.mem.Allocator,
    config: zfin.Config,
    patterns: []const []const u8,
) !ResolvedPaths {
    if (patterns.len == 0) {
        // Default pattern.
        const inner = try config.resolveUserFiles(io, allocator, zfin.Config.default_portfolio_filename);
        const paths = try inner.paths_slice(allocator);
        return .{ .inner = inner, .paths = paths };
    }
    // Multi-pattern: resolve each, concatenate, de-duplicate by path.
    // For non-glob patterns that resolve to nothing, we still surface
    // the literal pattern so the caller can produce a useful "Cannot
    // read portfolio file: <path>" error. Mirrors the legacy
    // single-path behavior where an explicit -p VALUE was always
    // returned, present-on-disk or not.
    var all_paths: std.ArrayList(zfin.Config.ResolvedPath) = .empty;
    errdefer {
        for (all_paths.items) |rp| rp.deinit(allocator);
        all_paths.deinit(allocator);
    }
    for (patterns) |pat| {
        const part = try config.resolveUserFiles(io, allocator, pat);
        var consumed: usize = 0;
        for (part.paths) |rp| {
            var dup = false;
            for (all_paths.items) |existing| {
                if (std.mem.eql(u8, existing.path, rp.path)) {
                    dup = true;
                    break;
                }
            }
            if (dup) {
                rp.deinit(allocator);
            } else {
                try all_paths.append(allocator, rp);
            }
            consumed += 1;
        }
        // Free only the outer slice; paths were transferred above.
        allocator.free(part.paths);

        // Explicit-but-not-found: if `pat` is a literal (not a glob)
        // and resolved to zero matches, surface it as a literal
        // ResolvedPath so the caller's "cannot read" error message
        // names the right file. Globs that match nothing are
        // dropped silently (the user knows they passed a glob).
        if (consumed == 0 and !zfin.Config.isGlobPattern(pat)) {
            // Avoid duplicating if a previous iteration already added
            // this literal path.
            var dup = false;
            for (all_paths.items) |existing| {
                if (std.mem.eql(u8, existing.path, pat)) {
                    dup = true;
                    break;
                }
            }
            if (!dup) {
                const path_copy = try allocator.dupe(u8, pat);
                try all_paths.append(allocator, .{ .path = path_copy, .owned = true });
            }
        }
    }

    // Mixed-directory check: sibling files (accounts.srf, metadata.srf,
    // transaction_log.srf) are derived from the *first* portfolio file's
    // directory. If patterns resolved to files spanning more than one
    // directory, sibling-file lookup would silently use only the first
    // directory's siblings — confusing and almost certainly not what
    // the user wants. Error out and tell the user to consolidate.
    //
    // The errdefer above handles cleanup; we just need to surface the
    // error and let it run. (Don't free in-line — that would be a
    // double-free.)
    if (all_paths.items.len > 1) {
        const first_dir = std.fs.path.dirnamePosix(all_paths.items[0].path) orelse ".";
        for (all_paths.items[1..]) |rp| {
            const this_dir = std.fs.path.dirnamePosix(rp.path) orelse ".";
            if (!std.mem.eql(u8, first_dir, this_dir)) {
                return error.MixedPortfolioDirs;
            }
        }
    }

    const owned = try all_paths.toOwnedSlice(allocator);
    const inner: zfin.Config.ResolvedPaths = .{ .paths = owned, .allocator = allocator };
    const path_view = try inner.paths_slice(allocator);
    return .{ .inner = inner, .paths = path_view };
}

fn resolveUserPath(
    io: std.Io,
    allocator: std.mem.Allocator,
    config: zfin.Config,
    explicit: ?[]const u8,
    default_name: []const u8,
) ResolvedPath {
    if (explicit) |p| {
        if (config.resolveUserFile(io, allocator, p)) |r| {
            return .{ .path = r.path, .resolved = r };
        }
        return .{ .path = p, .resolved = null };
    }
    if (config.resolveUserFile(io, allocator, default_name)) |r| {
        return .{ .path = r.path, .resolved = r };
    }
    return .{ .path = default_name, .resolved = null };
}

// ── Comptime contract validation ──────────────────────────────

/// Validate a command module against the framework contract. Emits
/// a `@compileError` with the full expected signature for any
/// missing or wrong-shape decl.
///
/// **Call site policy: registry-walk only.** This function should
/// be invoked exactly once per command, from the `command_modules`
/// registry walker in `src/main.zig`. Do NOT add in-file
/// `comptime { framework.validateCommandModule(@This()); }` blocks
/// to individual command files — they're redundant with the
/// registry walk under both `zig build` and ZLS build-on-save (the
/// only ZLS mode that evaluates comptime; ZLS's own semantic
/// analyzer doesn't run comptime reliably). The registry walk also
/// produces a better diagnostic, identifying the offending command
/// by its registry name (`commands.cache`) rather than just its
/// module identity. The TUI tab framework follows the same policy
/// in `src/tui.zig`.
pub fn validateCommandModule(comptime Module: type) void {
    comptime {
        const mod_name = @typeName(Module);

        // ── Top-level decls ────────────────────────────────────
        if (!@hasDecl(Module, "ParsedArgs")) {
            @compileError("Command module `" ++ mod_name ++ "` is missing `pub const ParsedArgs = ...;` " ++
                "(use `pub const ParsedArgs = struct {};` if the command takes no arguments)");
        }

        const ParsedArgs = Module.ParsedArgs;

        // ── meta ───────────────────────────────────────────────
        // One check covers all five fields. The compiler enforces
        // field types via `Meta`'s declaration; missing fields
        // surface as Zig's native "missing field" comptime errors
        // pointing at the call site.
        validator.expectDeclWithType(
            "Command module",
            mod_name,
            Module,
            "meta",
            Meta,
            "pub const meta: framework.Meta = .{ .name = \"...\", .group = ..., .synopsis = \"...\", .help = \"...\", .uppercase_first_arg = ... };",
        );

        // ── parseArgs ──────────────────────────────────────────
        validator.expectFnInferredError(
            "Command module",
            mod_name,
            Module,
            "parseArgs",
            &.{ *RunCtx, []const []const u8 },
            ParsedArgs,
            "pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs",
        );

        // ── run ────────────────────────────────────────────────
        validator.expectFnInferredError(
            "Command module",
            mod_name,
            Module,
            "run",
            &.{ *RunCtx, ParsedArgs },
            void,
            "pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void",
        );
    }
}

// ── Help rendering ────────────────────────────────────────────

/// If `cmd_args[0]` exists, is non-empty, and isn't a flag, return
/// a copy with the first byte-string uppercased. Otherwise return
/// `cmd_args` unchanged. The new slice is owned by `allocator`
/// (typically `RunCtx.allocator`, the per-invocation arena).
///
/// Mirrors the existing pre-dispatch normalization in
/// `main.zig:runCli` so migrated commands behave identically to
/// the legacy if-else chain.
pub fn normalizeFirstArg(
    allocator: std.mem.Allocator,
    cmd_args: []const []const u8,
) ![]const []const u8 {
    if (cmd_args.len == 0) return cmd_args;
    const first = cmd_args[0];
    if (first.len == 0 or first[0] == '-') return cmd_args;

    const upper = try allocator.dupe(u8, first);
    for (upper) |*c| c.* = std.ascii.toUpper(c.*);

    const owned = try allocator.alloc([]const u8, cmd_args.len);
    owned[0] = upper;
    for (cmd_args[1..], 1..) |a, i| owned[i] = a;
    return owned;
}

/// Print a single command's help text. Called by main.zig when the
/// user invokes `zfin <cmd> --help` or `zfin <cmd> -h`. The help
/// text comes from the module's `meta.help` field verbatim — no
/// post-processing, so multi-paragraph caveats render exactly as
/// authored.
pub fn printCommandHelp(out: *std.Io.Writer, comptime Module: type) !void {
    try out.writeAll(Module.meta.help);
    if (Module.meta.help.len == 0 or Module.meta.help[Module.meta.help.len - 1] != '\n') {
        try out.writeAll("\n");
    }
}

/// Print the grouped `zfin help` output. Walks the supplied
/// `Modules` registry (an anonymous-struct literal) and groups
/// commands by `meta.group`, rendering one section per group with
/// the group's display label as the header.
///
/// `header_text` and `footer_text` are emitted before the first
/// group and after the last respectively — the caller supplies
/// them so main.zig can keep its bespoke "Usage" line, the
/// global-options block, and the env-vars list while letting the
/// command list itself be derived from the registry.
pub fn printGroupedUsage(
    out: *std.Io.Writer,
    comptime Modules: type,
    modules: Modules,
    header_text: []const u8,
    footer_text: []const u8,
) !void {
    try out.writeAll(header_text);

    inline for (std.meta.fields(Group)) |gf| {
        const group_value: Group = @enumFromInt(gf.value);
        var first_in_group = true;
        inline for (std.meta.fields(Modules)) |mf| {
            const Module = @field(modules, mf.name);
            if (Module.meta.group == group_value) {
                if (first_in_group) {
                    try out.writeAll("\n");
                    try out.writeAll(group_value.label());
                    try out.writeAll(":\n");
                    first_in_group = false;
                }
                try out.writeAll("  ");
                try out.writeAll(mf.name);
                // Pad name to 16 cols so synopses align.
                if (mf.name.len < 16) {
                    var pad: usize = 16 - mf.name.len;
                    while (pad > 0) : (pad -= 1) try out.writeAll(" ");
                }
                try out.writeAll("  ");
                try out.writeAll(Module.meta.synopsis);
                try out.writeAll("\n");
            }
        }
    }

    try out.writeAll(footer_text);
}

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

const testing = std.testing;

// Probe module: a minimal stub satisfying the command contract.
// Used by the validator's happy-path test below.
const ProbeModule = struct {
    pub const ParsedArgs = struct {};

    pub const meta: Meta = .{
        .name = "probe",
        .group = .infra,
        .synopsis = "test probe",
        .help = "Usage: zfin probe\n",
        .uppercase_first_arg = false,
        .user_errors = error{},
    };

    pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs {
        return .{};
    }

    pub fn run(_: *RunCtx, _: ParsedArgs) !void {}
};

test "validateCommandModule: probe module passes" {
    comptime validateCommandModule(ProbeModule);
}

test "Group.label: every variant has a non-empty label" {
    inline for (std.meta.fields(Group)) |f| {
        const g: Group = @enumFromInt(f.value);
        try testing.expect(g.label().len > 0);
    }
}

test "RefreshPolicy: auto is the default variant" {
    const policy: RefreshPolicy = .auto;
    try testing.expectEqual(RefreshPolicy.auto, policy);
}

test "Globals: zero-init defaults" {
    const g: Globals = .{};
    try testing.expect(!g.no_color);
    try testing.expectEqual(@as(usize, 0), g.portfolio_patterns.len);
    try testing.expect(g.watchlist_path == null);
    try testing.expectEqual(RefreshPolicy.auto, g.refresh_policy);
}

test "printCommandHelp: writes meta.help and ensures trailing newline" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try printCommandHelp(&w, ProbeModule);
    try testing.expectEqualStrings("Usage: zfin probe\n", w.buffered());
}

test "printCommandHelp: appends newline when meta.help lacks one" {
    const NoNewline = struct {
        pub const ParsedArgs = struct {};
        pub const meta: Meta = .{
            .name = "x",
            .group = .infra,
            .synopsis = "x",
            .help = "no trailing newline",
            .uppercase_first_arg = false,
            .user_errors = error{},
        };
        pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs {
            return .{};
        }
        pub fn run(_: *RunCtx, _: ParsedArgs) !void {}
    };
    var buf: [64]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try printCommandHelp(&w, NoNewline);
    try testing.expectEqualStrings("no trailing newline\n", w.buffered());
}

test "normalizeFirstArg: empty args returns slice unchanged" {
    const empty: []const []const u8 = &.{};
    const out = try normalizeFirstArg(testing.allocator, empty);
    try testing.expectEqual(@as(usize, 0), out.len);
}

test "normalizeFirstArg: lowercase symbol becomes uppercase" {
    const args = [_][]const u8{ "aapl", "extra" };
    const out = try normalizeFirstArg(testing.allocator, &args);
    // Save out[0] before freeing the slice — once `out` is freed,
    // indexing it is use-after-free.
    const upper = out[0];
    defer testing.allocator.free(out);
    defer testing.allocator.free(upper);
    try testing.expectEqualStrings("AAPL", out[0]);
    try testing.expectEqualStrings("extra", out[1]);
}

test "normalizeFirstArg: leading flag is left untouched" {
    const args = [_][]const u8{ "--since", "1W" };
    const out = try normalizeFirstArg(testing.allocator, &args);
    // Returned the original slice unchanged — no allocation to free.
    try testing.expectEqual(@as(usize, 2), out.len);
    try testing.expectEqualStrings("--since", out[0]);
    try testing.expectEqualStrings("1W", out[1]);
}

test "normalizeFirstArg: empty first arg is left untouched" {
    const args = [_][]const u8{ "", "AAPL" };
    const out = try normalizeFirstArg(testing.allocator, &args);
    try testing.expectEqualStrings("", out[0]);
}

test "printGroupedUsage: groups in canonical order with headers + synopses" {
    const FakeRegistry = struct {
        const ProbeA = struct {
            pub const ParsedArgs = struct {};
            pub const meta: Meta = .{
                .name = "alpha",
                .group = .symbol_lookup,
                .synopsis = "alpha synopsis",
                .help = "alpha help\n",
                .uppercase_first_arg = false,
                .user_errors = error{},
            };
            pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs {
                return .{};
            }
            pub fn run(_: *RunCtx, _: ParsedArgs) !void {}
        };
        const ProbeB = struct {
            pub const ParsedArgs = struct {};
            pub const meta: Meta = .{
                .name = "beta",
                .group = .infra,
                .synopsis = "beta synopsis",
                .help = "beta help\n",
                .uppercase_first_arg = false,
                .user_errors = error{},
            };
            pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs {
                return .{};
            }
            pub fn run(_: *RunCtx, _: ParsedArgs) !void {}
        };
    };
    const reg = .{
        .alpha = FakeRegistry.ProbeA,
        .beta = FakeRegistry.ProbeB,
    };
    var buf: [1024]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try printGroupedUsage(&w, @TypeOf(reg), reg, "HEAD\n", "FOOT\n");
    const out = w.buffered();

    // Header / footer present.
    try testing.expect(std.mem.startsWith(u8, out, "HEAD\n"));
    try testing.expect(std.mem.endsWith(u8, out, "FOOT\n"));

    // Group labels in canonical order.
    const sym_idx = std.mem.indexOf(u8, out, "Per-symbol lookups:") orelse return error.MissingSymbolLookups;
    const infra_idx = std.mem.indexOf(u8, out, "Infrastructure:") orelse return error.MissingInfra;
    try testing.expect(sym_idx < infra_idx);

    // Each command's name + synopsis present.
    try testing.expect(std.mem.indexOf(u8, out, "alpha") != null);
    try testing.expect(std.mem.indexOf(u8, out, "alpha synopsis") != null);
    try testing.expect(std.mem.indexOf(u8, out, "beta") != null);
    try testing.expect(std.mem.indexOf(u8, out, "beta synopsis") != null);
}

test "printGroupedUsage: omits empty groups" {
    const FakeRegistry = struct {
        const Probe = struct {
            pub const ParsedArgs = struct {};
            pub const meta: Meta = .{
                .name = "only",
                .group = .infra,
                .synopsis = "only synopsis",
                .help = "h\n",
                .uppercase_first_arg = false,
                .user_errors = error{},
            };
            pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs {
                return .{};
            }
            pub fn run(_: *RunCtx, _: ParsedArgs) !void {}
        };
    };
    const reg = .{ .only = FakeRegistry.Probe };
    var buf: [512]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try printGroupedUsage(&w, @TypeOf(reg), reg, "", "");
    const out = w.buffered();

    // The Infrastructure section appears...
    try testing.expect(std.mem.indexOf(u8, out, "Infrastructure:") != null);
    // ...but Per-symbol lookups (which has no commands in this fake
    // registry) does NOT.
    try testing.expect(std.mem.indexOf(u8, out, "Per-symbol lookups:") == null);
}

// ── resolvePortfolioPaths tests ───────────────────────────────

test "resolvePatterns: empty patterns falls back to default glob" {
    // With no zfin_home configured, resolveUserFiles for the default
    // pattern returns 0 matches in a clean tmp dir. The Impl returns
    // an empty resolved list (not an error) so callers can produce
    // "no portfolio file found" themselves.
    const config: zfin.Config = .{ .cache_dir = "/tmp" };
    const empty: []const []const u8 = &.{};
    var result = try resolvePatterns(std.testing.io, testing.allocator, config, empty);
    defer result.deinit();
    // 0 or 1 match depending on whether the test runner's cwd has a
    // portfolio*.srf file. We just check it doesn't crash and the
    // shape is consistent.
    try testing.expectEqual(result.inner.paths.len, result.paths.len);
}

test "resolvePatterns: literal not-found is preserved as a literal" {
    // The legacy single-path API returned an explicit -p value even
    // when it didn't exist on disk, so the caller could produce a
    // "Cannot read: <path>" error naming the right file. We mirror
    // that for the multi-path API.
    const config: zfin.Config = .{ .cache_dir = "/tmp" };
    const patterns = [_][]const u8{"/zfin-test-no-such-portfolio.srf"};
    var result = try resolvePatterns(std.testing.io, testing.allocator, config, &patterns);
    defer result.deinit();
    try testing.expectEqual(@as(usize, 1), result.paths.len);
    try testing.expectEqualStrings("/zfin-test-no-such-portfolio.srf", result.paths[0]);
}

test "resolvePatterns: glob with no matches resolves to empty" {
    // Globs that match nothing are dropped silently — the user
    // typed a glob, they know it might match zero files.
    const config: zfin.Config = .{ .cache_dir = "/tmp" };
    const patterns = [_][]const u8{"zfin-test-nope-*.srf-xyz"};
    var result = try resolvePatterns(std.testing.io, testing.allocator, config, &patterns);
    defer result.deinit();
    try testing.expectEqual(@as(usize, 0), result.paths.len);
}

test "resolvePatterns: two patterns matching same dir union-merge" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_main.srf", .data = "x" });
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_other.srf", .data = "x" });

    var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
    const dir_path = dir_path_buf[0..dir_path_len];

    const main_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_main.srf" });
    defer testing.allocator.free(main_path);
    const other_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_other.srf" });
    defer testing.allocator.free(other_path);

    const patterns = [_][]const u8{ main_path, other_path };
    const config: zfin.Config = .{ .cache_dir = "/tmp" };
    var result = try resolvePatterns(io, testing.allocator, config, &patterns);
    defer result.deinit();
    try testing.expectEqual(@as(usize, 2), result.paths.len);
}

test "resolvePatterns: duplicate pattern de-dups" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "x" });

    var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
    const dir_path = dir_path_buf[0..dir_path_len];

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

    const patterns = [_][]const u8{ p, p };
    const config: zfin.Config = .{ .cache_dir = "/tmp" };
    var result = try resolvePatterns(io, testing.allocator, config, &patterns);
    defer result.deinit();
    // Same path passed twice → 1 entry.
    try testing.expectEqual(@as(usize, 1), result.paths.len);
}

test "resolvePatterns: mixed directories error" {
    const io = std.testing.io;

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

    try tmp_a.dir.writeFile(io, .{ .sub_path = "portfolio_a.srf", .data = "x" });
    try tmp_b.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "x" });

    var dir_a_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_a_len = try tmp_a.dir.realPath(io, &dir_a_buf);
    var dir_b_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_b_len = try tmp_b.dir.realPath(io, &dir_b_buf);

    const path_a = try std.fs.path.join(testing.allocator, &.{ dir_a_buf[0..dir_a_len], "portfolio_a.srf" });
    defer testing.allocator.free(path_a);
    const path_b = try std.fs.path.join(testing.allocator, &.{ dir_b_buf[0..dir_b_len], "portfolio_b.srf" });
    defer testing.allocator.free(path_b);

    const patterns = [_][]const u8{ path_a, path_b };
    const config: zfin.Config = .{ .cache_dir = "/tmp" };
    try testing.expectError(error.MixedPortfolioDirs, resolvePatterns(io, testing.allocator, config, &patterns));
}

test "resolvePatterns: same dir different patterns OK (no mixed-dir error)" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_a.srf", .data = "x" });
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "x" });

    var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
    const dir_path = dir_path_buf[0..dir_path_len];

    const path_a = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_a.srf" });
    defer testing.allocator.free(path_a);
    const path_b = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_b.srf" });
    defer testing.allocator.free(path_b);

    const patterns = [_][]const u8{ path_a, path_b };
    const config: zfin.Config = .{ .cache_dir = "/tmp" };
    var result = try resolvePatterns(io, testing.allocator, config, &patterns);
    defer result.deinit();
    try testing.expectEqual(@as(usize, 2), result.paths.len);
}
