const std = @import("std");
const zfin = @import("root.zig");
const tui = @import("tui.zig");
const cli = @import("commands/common.zig");
const cmd_framework = @import("commands/framework.zig");

/// Comptime registry of CLI commands. Field name is the user-facing
/// subcommand name; value is the imported module struct. Order
/// follows the canonical group taxonomy in `framework.Group`
/// (symbol-lookup → portfolio → time-series → hygiene → infra) so
/// `zfin help` reads in workflow order. Adding a new command is one
/// edit here (after authoring the module). Validation runs at
/// comptime in the block below.
const command_modules = .{
    // Per-symbol lookups
    .perf = @import("commands/perf.zig"),
    .quote = @import("commands/quote.zig"),
    .history = @import("commands/history.zig"),
    .divs = @import("commands/divs.zig"),
    .splits = @import("commands/splits.zig"),
    .options = @import("commands/options.zig"),
    .earnings = @import("commands/earnings.zig"),
    .etf = @import("commands/etf.zig"),

    // Portfolio analysis
    .portfolio = @import("commands/portfolio.zig"),
    .analysis = @import("commands/analysis.zig"),
    .exposure = @import("commands/exposure.zig"),
    .review = @import("commands/review.zig"),
    .projections = @import("commands/projections.zig"),
    .milestones = @import("commands/milestones.zig"),

    // Time-series & journaling
    .snapshot = @import("commands/snapshot.zig"),
    .compare = @import("commands/compare.zig"),
    .contributions = @import("commands/contributions.zig"),

    // Data hygiene
    .audit = @import("commands/audit.zig"),
    .enrich = @import("commands/enrich.zig"),
    .import = @import("commands/import.zig"),
    .lookup = @import("commands/lookup.zig"),

    // Infrastructure
    .cache = @import("commands/cache.zig"),
    .doctor = @import("commands/doctor.zig"),
    .version = @import("commands/version.zig"),
};

comptime {
    for (std.meta.fields(@TypeOf(command_modules))) |f| {
        cmd_framework.validateCommandModule(@field(command_modules, f.name));
    }
}

const usage_header =
    \\Usage: zfin [global options] <command> [command options]
    \\
    \\Per-command help: zfin <command> --help
    \\
;

const usage_footer =
    \\
    \\Other commands:
    \\  interactive [opts]    Launch interactive TUI
    \\  help / --help         Show this message
    \\
    \\Global options (must appear before the subcommand):
    \\  --no-color                  Disable colored output
    \\  --refresh-data=<value>      Cache freshness policy (default: auto):
    \\                                auto   respect cache TTLs (default)
    \\                                force  re-fetch every symbol regardless
    \\                                       of TTL freshness
    \\                                never  serve cache contents only;
    \\                                       no provider calls (offline mode)
    \\  -p, --portfolio <PATTERN>   Portfolio file or glob pattern (repeatable;
    \\                              default: portfolio*.srf). Resolved against
    \\                              ZFIN_HOME when set (exclusive — cwd is NOT
    \\                              consulted), else cwd. Quote globs to
    \\                              prevent shell expansion:
    \\                                -p 'portfolio_*.srf'
    \\                              Or repeat the flag for multiple files:
    \\                                -p portfolio.srf -p portfolio_other.srf
    \\                              metadata.srf and accounts.srf are loaded from
    \\                              the same directory as the first resolved
    \\                              portfolio file.
    \\  -w, --watchlist <FILE>      Watchlist file (default: watchlist.srf)
    \\
    \\Interactive command options:
    \\  -s, --symbol <SYMBOL>    Pre-load a symbol and open on the
    \\                           Quote tab. Without this flag, the TUI
    \\                           opens on the Portfolio tab.
    \\  --chart <MODE>           Chart graphics: auto, braille, or WxH
    \\  --default-keys           Print default keybindings
    \\  --default-theme          Print default theme
    \\
    \\Environment Variables:
    \\  TWELVEDATA_API_KEY    Twelve Data API key (primary: prices)
    \\  POLYGON_API_KEY       Polygon.io API key (dividends, splits)
    \\  FMP_API_KEY           Financial Modeling Prep API key (earnings)
    \\  ZFIN_USER_EMAIL       Contact email for SEC EDGAR + Wikidata User-Agent
    \\                        (required for ETF profiles + portfolio enrichment)
    \\  OPENFIGI_API_KEY      OpenFIGI API key (CUSIP lookup, optional)
    \\  ZFIN_CACHE_DIR        Cache directory (default: ~/.cache/zfin)
    \\  ZFIN_HOME             User file directory (portfolio, watchlist, .env)
    \\  NO_COLOR              Disable colored output (https://no-color.org)
    \\
;

/// Help text for `zfin interactive --help` / `zfin i --help`.
/// `interactive` is hand-dispatched (not framework-registered) so its
/// help isn't auto-derived from a `Meta.help` field. Kept in sync
/// with the "Interactive command options:" block in `usage_footer`.
const interactive_help =
    \\Usage: zfin interactive [options]
    \\Alias: zfin i [options]
    \\
    \\Launch the interactive TUI: a vaxis-rendered, multi-tab terminal
    \\interface for browsing your portfolio, per-symbol data, options
    \\chains, earnings, and projections. Press `?` inside the TUI to
    \\see all keybindings; `q` or Ctrl-C to exit.
    \\
    \\Options:
    \\  -s, --symbol <SYMBOL>    Pre-load a symbol and open on the
    \\                           Quote tab. Without this flag, the TUI
    \\                           opens on the Portfolio tab.
    \\  --chart <MODE>           Chart graphics: auto, braille, or WxH
    \\                           (e.g. 80x24); `auto` picks Kitty graphics
    \\                           if the terminal supports it, otherwise
    \\                           braille
    \\  --default-keys           Print default keybindings as a `keys.srf`
    \\                           template and exit (no TUI launched).
    \\                           Pipe to `~/.config/zfin/keys.srf` to
    \\                           customize.
    \\  --default-theme          Print default theme as a `theme.srf`
    \\                           template and exit (no TUI launched).
    \\
    \\Global flags (`--no-color`, `-p`, `-w`, `--refresh-data=<value>`)
    \\are honored; see `zfin help` for the full list.
    \\
;

fn writeUsage(out: *std.Io.Writer) !void {
    try cmd_framework.printGroupedUsage(out, @TypeOf(command_modules), command_modules, usage_header, usage_footer);
}

/// Parsed global options. Paths are raw (not yet resolved through ZFIN_HOME).
const Globals = struct {
    no_color: bool = false,
    /// Explicit portfolio patterns from `-p`/`--portfolio` (raw, may
    /// contain glob metacharacters). Each `-p VALUE` appends one entry;
    /// resolution and union-merge happens later in `RunCtx`. Empty
    /// (len == 0) means "use the default pattern".
    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>`.
    /// Default: `.auto` (TTL-respecting). Other values: `.force`
    /// (re-fetch regardless of TTL) and `.never` (offline mode).
    refresh_policy: cmd_framework.RefreshPolicy = .auto,
    /// Index into args of the first post-global token (the subcommand).
    cursor: usize,
};

const GlobalParseError = error{
    MissingValue,
    UnknownGlobalFlag,
    /// `--refresh-data=<value>` got something other than auto/force/never.
    InvalidRefreshDataValue,
    /// Multiple `.srf` files appeared as a single -p argument, almost
    /// certainly because the shell expanded an unquoted glob. We
    /// surface this as a dedicated error so the user gets a friendly
    /// "quote your glob OR repeat -p" message.
    UnquotedGlobLikely,
    OutOfMemory,
};

/// Heuristic: returns true if `cursor` in args points at one or more
/// contiguous `.srf` files that are NOT prefixed by a `-p`/`--portfolio`
/// flag, suggesting the user typed `-p portfolio_*.srf` and the shell
/// expanded the glob into space-separated args.
///
/// Conservative: only fires when at least one extra `.srf` file
/// appears, AND no flags or non-srf tokens are interleaved before
/// the next subcommand-shaped token. False positives here would be
/// more annoying than the original "unknown command: portfolio_2.srf"
/// error, so the bar is high.
fn looksLikeUnquotedGlob(args: []const []const u8, cursor: usize) bool {
    var i = cursor;
    var srf_count: usize = 0;
    while (i < args.len) : (i += 1) {
        const a = args[i];
        if (a.len == 0) return false;
        // A flag-shaped token (starts with -) means we've left the
        // suspicious run; only "all srf files until the end (or another
        // -p)" counts as the unquoted-glob shape.
        if (a[0] == '-') return srf_count > 0;
        if (std.mem.endsWith(u8, a, ".srf")) {
            srf_count += 1;
            continue;
        }
        // Non-srf positional token (probably a subcommand). Stop the
        // run; if we already saw an srf file, that's the glob shape.
        return srf_count > 0;
    }
    return srf_count > 0;
}

/// Parse global flags from args[1..] up to the first non-flag (subcommand)
/// token. Errors if a pre-subcommand token starts with '-' but isn't a
/// recognized global, or if a value-taking flag is missing its value.
///
/// Allocator is used only for the `portfolio_patterns` slice. Caller
/// owns the slice on success and must free it with `freeGlobals` (or
/// rely on arena cleanup).
fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalParseError!Globals {
    var g: Globals = .{ .cursor = 1 };
    var patterns: std.ArrayList([]const u8) = .empty;
    errdefer patterns.deinit(allocator);

    var i: usize = 1;
    while (i < args.len) {
        const a = args[i];
        if (a.len == 0 or a[0] != '-') break;

        if (std.mem.eql(u8, a, "--no-color")) {
            g.no_color = true;
            i += 1;
            continue;
        }
        if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio")) {
            if (i + 1 >= args.len) return error.MissingValue;
            // Reject a flag-shaped value (leading '-' with more after
            // it): almost certainly the user forgot the value and the
            // next flag would otherwise be silently consumed as the
            // pattern. The lone '-' is left alone (harmless; resolves
            // to nothing).
            const value = args[i + 1];
            if (value.len > 1 and value[0] == '-') return error.MissingValue;
            try patterns.append(allocator, value);
            // Detect the unquoted-glob shape: we just consumed `-p VALUE`,
            // and the next args are more `.srf` files with no flag in
            // between. That's almost always the shell expanding `-p
            // portfolio_*.srf` into multiple args.
            if (looksLikeUnquotedGlob(args, i + 2)) {
                return error.UnquotedGlobLikely;
            }
            i += 2;
            continue;
        }
        if (std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist")) {
            if (i + 1 >= args.len) return error.MissingValue;
            const value = args[i + 1];
            if (value.len > 1 and value[0] == '-') return error.MissingValue;
            g.watchlist_path = value;
            i += 2;
            continue;
        }
        // `--refresh-data=<value>` is a single token: the flag name,
        // an `=`, and the value (one of auto / force / never). The
        // single-flag tri-state shape is more honest than the
        // earlier two-flag (`--refresh` / `--no-refresh`) design
        // because the user-facing values map 1:1 to the
        // `RefreshPolicy` enum and impossible-state combinations
        // are unrepresentable.
        if (std.mem.startsWith(u8, a, "--refresh-data=")) {
            const value = a["--refresh-data=".len..];
            if (std.mem.eql(u8, value, "auto")) {
                g.refresh_policy = .auto;
            } else if (std.mem.eql(u8, value, "force")) {
                g.refresh_policy = .force;
            } else if (std.mem.eql(u8, value, "never")) {
                g.refresh_policy = .never;
            } else {
                return error.InvalidRefreshDataValue;
            }
            i += 1;
            continue;
        }
        if (std.mem.eql(u8, a, "--refresh-data")) {
            // Bare `--refresh-data` without `=value` is a user
            // mistake (probably tried `--refresh-data force` with a
            // space). Surface the shape mismatch explicitly.
            return error.MissingValue;
        }
        // Help flags are subcommand-like tokens, stop scanning.
        if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;

        return error.UnknownGlobalFlag;
    }
    g.cursor = i;
    g.portfolio_patterns = try patterns.toOwnedSlice(allocator);
    return g;
}

pub fn main(init: std.process.Init) !u8 {
    return runCli(init) catch |err| switch (err) {
        // Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's
        // file writer surfaces EPIPE as WriteFailed. Treat as a clean exit
        // — the consumer got what it needed and closed the pipe; further
        // output isn't an error from our perspective. Matches `ls | head`,
        // `git log | head`, etc.
        error.WriteFailed, error.BrokenPipe => 0,
        else => err,
    };
}

fn runCli(init: std.process.Init) !u8 {
    // Juicy Main provides two allocators: `init.gpa` (debug-mode leak-checked
    // heap) and `init.arena` (process-lifetime arena). We use gpa for the
    // argv copy and long-lived TUI state; per-command work runs under a
    // fresh ArenaAllocator below.
    const gpa_alloc = init.gpa;
    const io = init.io;

    const args = try init.minimal.args.toSlice(gpa_alloc);
    defer gpa_alloc.free(args);

    // Single buffered writer for all stdout output
    var stdout_buf: [4096]u8 = undefined;
    var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buf);
    const out: *std.Io.Writer = &stdout_writer.interface;

    if (args.len < 2) {
        var sb: [4096]u8 = undefined;
        var sw = std.Io.File.stderr().writer(io, &sb);
        try writeUsage(&sw.interface);
        try sw.interface.flush();
        return 1;
    }

    // Early help handling (before global parsing so `zfin --help` works).
    if (std.mem.eql(u8, args[1], "help") or
        std.mem.eql(u8, args[1], "--help") or
        std.mem.eql(u8, args[1], "-h"))
    {
        try writeUsage(out);
        try out.flush();
        return 0;
    }

    // Parse global flags. We allocate the patterns slice up-front
    // (before the per-command arena exists) using the gpa, since
    // parseGlobals needs to handle errors before we'd want to spin
    // up an arena. Freed at the bottom of runCli.
    const globals = parseGlobals(gpa_alloc, args) catch |err| {
        switch (err) {
            error.MissingValue => cli.stderrPrint(io, "Error: global flag is missing its value\n"),
            error.UnknownGlobalFlag => {
                cli.stderrPrint(io, "Error: unknown global flag: ");
                if (globalOffender(args)) |bad| {
                    cli.stderrPrint(io, bad);
                }
                cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
            },
            error.InvalidRefreshDataValue => cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
            error.UnquotedGlobLikely => {
                cli.stderrPrint(io,
                    \\Error: -p was given a single value followed by additional .srf files.
                    \\This usually means your shell expanded a glob before zfin saw it.
                    \\
                    \\Try one of:
                    \\  -p 'portfolio_*.srf'                       (quote to prevent shell expansion)
                    \\  -p portfolio_1.srf -p portfolio_2.srf      (repeat the flag)
                    \\
                );
            },
            error.OutOfMemory => return err,
        }
        return 1;
    };
    defer gpa_alloc.free(globals.portfolio_patterns);

    if (globals.cursor >= args.len) {
        cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n");
        return 1;
    }

    // Single wall-clock capture for the rest of this invocation. `now_s`
    // is threaded into commands that record "when did this happen"
    // (snapshot metadata, audit staleness, rollup header timestamps).
    // `today` derives from the same read, so every dated computation in
    // this process sees a consistent date even if the wall clock ticks
    // over mid-run.
    //
    // wall-clock required: the one legitimate Timestamp.now() call in
    // main dispatch — everything downstream takes now_s / today.
    const Date = @import("Date.zig");
    const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
    const today = Date.fromEpoch(now_s);

    // Nag on stderr when hand-maintained data sources are overdue for
    // refresh (T-bill rates, Shiller ie_data.csv). See
    // src/data/staleness.zig for the registry and rules. Runs here —
    // after globals parse, before command dispatch — so the warning
    // lands above command output on every CLI and TUI invocation.
    //
    // Best-effort: a stderr-write failure here would mean the user
    // can't even see staleness warnings, but their actual command
    // should still proceed. Log the secondary error at debug level
    // so it's visible if anyone goes looking.
    {
        const staleness = @import("data/staleness.zig");
        var stale_buf: [2048]u8 = undefined;
        var stale_writer = std.Io.File.stderr().writer(io, &stale_buf);
        staleness.check(&stale_writer.interface, today, &staleness.entries) catch |err| {
            std.log.debug("staleness check failed: {t}", .{err});
        };
        stale_writer.interface.flush() catch |err| {
            std.log.debug("staleness flush failed: {t}", .{err});
        };
    }

    const color = @import("format.zig").shouldUseColor(io, init.environ_map, globals.no_color);

    const command = args[globals.cursor];
    const cmd_args: []const []const u8 = @ptrCast(args[globals.cursor + 1 ..]);

    // Interactive TUI: long-lived, per-frame allocations benefit from a
    // real (non-arena) allocator. Runs against `gpa` directly.
    if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
        // Per-command --help / -h, mirroring the framework dispatch's
        // behavior. `interactive` is hand-dispatched (not part of the
        // framework registry because it uses gpa, not arena, and bypasses
        // DataService construction), so it needs its own --help check.
        if (cmd_args.len > 0 and (std.mem.eql(u8, cmd_args[0], "--help") or std.mem.eql(u8, cmd_args[0], "-h"))) {
            try out.writeAll(interactive_help);
            try out.flush();
            return 0;
        }
        var tui_config = zfin.Config.fromEnv(io, gpa_alloc, init.environ_map);
        defer tui_config.deinit();
        try out.flush();
        // TUI today is single-portfolio. Pass the first explicit pattern
        // Multi-portfolio is now wired all the way through to the
        // TUI: pass the raw `-p` pattern slice and let the TUI's
        // loader resolve + union-merge the same way the CLI does.
        // This is the load-bearing fix for "CLI and TUI report
        // different totals" — there's exactly one code path now.
        tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
            // tui.run already printed an actionable stderr message
            // for invalid CLI args; surface as exit 1 without a
            // panic / stack trace.
            error.InvalidArgs => return 1,
            else => return err,
        };
        return 0;
    }

    // ── Per-invocation arena ─────────────────────────────────────
    //
    // CLI commands do a batch of work then exit. Almost every allocation
    // they make has the same lifetime (the invocation). An arena matched
    // to that unit gives us three wins: skip per-allocation bookkeeping,
    // ignore all the per-object `defer X.deinit()` calls (they become
    // no-ops but remain correct code if the function is ever called from
    // a non-arena context), and avoid gpa's leak-checking overhead for
    // ephemeral state we're about to discard anyway.
    //
    // See models/portfolio.zig for the "match the arena to the unit of
    // work" principle. Here the unit is one `zfin <subcommand>`.
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const allocator = arena.allocator();

    var config = zfin.Config.fromEnv(io, allocator, init.environ_map);
    defer config.deinit();

    var svc = zfin.DataService.init(io, allocator, config);
    defer svc.deinit();

    // ── Framework dispatch ───────────────────────────────────────
    //
    // Comptime walk over `command_modules`. Each registered command
    // owns its own flag parsing (`parseArgs`) and execution (`run`)
    // — both take `*RunCtx`. Per-command help (`zfin <cmd> --help`)
    // intercepts before parseArgs runs.
    inline for (std.meta.fields(@TypeOf(command_modules))) |f| {
        if (std.mem.eql(u8, command, f.name)) {
            const Module = @field(command_modules, f.name);

            // Per-command --help / -h: print meta.help and exit 0.
            if (cmd_args.len > 0 and (std.mem.eql(u8, cmd_args[0], "--help") or std.mem.eql(u8, cmd_args[0], "-h"))) {
                try cmd_framework.printCommandHelp(out, Module);
                try out.flush();
                return 0;
            }

            var ctx: cmd_framework.RunCtx = .{
                .io = io,
                .allocator = allocator,
                .gpa = gpa_alloc,
                .environ_map = init.environ_map,
                .config = config,
                .svc = &svc,
                .globals = .{
                    .no_color = globals.no_color,
                    .portfolio_patterns = globals.portfolio_patterns,
                    .watchlist_path = globals.watchlist_path,
                    .refresh_policy = globals.refresh_policy,
                },
                .today = today,
                .now_s = now_s,
                .color = color,
                .out = out,
            };
            const dispatched_args = if (comptime Module.meta.uppercase_first_arg)
                try cmd_framework.normalizeFirstArg(allocator, cmd_args)
            else
                cmd_args;
            const parsed = Module.parseArgs(&ctx, dispatched_args) catch |err| {
                // parseArgs errors: if the command declared this
                // error as user-level, exit 1 silently (the
                // command already printed a stderr message).
                // Otherwise propagate so genuine bugs (OOM, etc.)
                // surface with a stack trace.
                if (cmd_framework.isUserError(Module.meta.user_errors, err)) return 1;
                return err;
            };
            Module.run(&ctx, parsed) catch |err| {
                // Same treatment for run errors: user-level errors
                // become exit 1; everything else propagates.
                if (cmd_framework.isUserError(Module.meta.user_errors, err)) return 1;
                return err;
            };
            try out.flush();
            return 0;
        }
    }

    cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n");
    return 1;
}

/// Return the first args[1..] token that looks like a flag we didn't handle.
/// Used only to craft an error message; best-effort.
fn globalOffender(args: []const []const u8) ?[]const u8 {
    var i: usize = 1;
    while (i < args.len) {
        const a = args[i];
        if (a.len == 0 or a[0] != '-') return null;
        if (std.mem.eql(u8, a, "--no-color")) {
            i += 1;
            continue;
        }
        if (std.mem.startsWith(u8, a, "--refresh-data=") or
            std.mem.eql(u8, a, "--refresh-data"))
        {
            i += 1;
            continue;
        }
        if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio") or
            std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist"))
        {
            i += 2;
            continue;
        }
        return a;
    }
    return null;
}

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

test "parseGlobals: no flags, subcommand only" {
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "portfolio" };
    const g = try parseGlobals(allocator, &argv);
    defer allocator.free(g.portfolio_patterns);
    try std.testing.expectEqual(@as(usize, 1), g.cursor);
    try std.testing.expectEqual(false, g.no_color);
    try std.testing.expectEqual(@as(usize, 0), g.portfolio_patterns.len);
    try std.testing.expect(g.watchlist_path == null);
}

test "parseGlobals: --no-color, -p, -w then subcommand" {
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "--no-color", "-p", "foo.srf", "-w", "wl.srf", "analysis" };
    const g = try parseGlobals(allocator, &argv);
    defer allocator.free(g.portfolio_patterns);
    try std.testing.expectEqual(@as(usize, 6), g.cursor);
    try std.testing.expectEqual(true, g.no_color);
    try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
    try std.testing.expectEqualStrings("foo.srf", g.portfolio_patterns[0]);
    try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?);
}

test "parseGlobals: long forms" {
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "--portfolio", "foo.srf", "--watchlist", "wl.srf", "portfolio" };
    const g = try parseGlobals(allocator, &argv);
    defer allocator.free(g.portfolio_patterns);
    try std.testing.expectEqual(@as(usize, 5), g.cursor);
    try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
    try std.testing.expectEqualStrings("foo.srf", g.portfolio_patterns[0]);
    try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?);
}

test "parseGlobals: unknown flag errors" {
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "--bogus", "quote", "AAPL" };
    try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(allocator, &argv));
}

test "parseGlobals: flag missing value errors" {
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "-p" };
    try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv));
}

test "parseGlobals: -p with a flag-shaped value errors (next flag not swallowed)" {
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "-p", "--no-color", "portfolio" };
    try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv));
}

test "parseGlobals: -w with a flag-shaped value errors" {
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "-w", "--no-color", "portfolio" };
    try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv));
}

test "parseGlobals: -p accepts the lone '-' (not treated as a flag)" {
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "-p", "-", "portfolio" };
    const g = try parseGlobals(allocator, &argv);
    defer allocator.free(g.portfolio_patterns);
    try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
    try std.testing.expectEqualStrings("-", g.portfolio_patterns[0]);
}

test "parseGlobals: --help stops scanning" {
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "--help" };
    const g = try parseGlobals(allocator, &argv);
    defer allocator.free(g.portfolio_patterns);
    try std.testing.expectEqual(@as(usize, 1), g.cursor);
}

test "parseGlobals: subcommand-local flag NOT consumed as global" {
    // `--refresh` is a portfolio-command flag; should stop global scanning
    // when it appears before the subcommand (even though that's not the
    // intended usage, make sure the error is "unknown global").
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "--refresh", "portfolio" };
    try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(allocator, &argv));
}

test "parseGlobals: -p repeated builds slice in argument order" {
    // Multi-portfolio support: each `-p VALUE` appends. Order is
    // preserved so users can reason about precedence (though Feature
    // A union-merges the resolved set, the patterns themselves are
    // ordered).
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "-p", "main.srf", "-p", "mom.srf", "portfolio" };
    const g = try parseGlobals(allocator, &argv);
    defer allocator.free(g.portfolio_patterns);
    try std.testing.expectEqual(@as(usize, 2), g.portfolio_patterns.len);
    try std.testing.expectEqualStrings("main.srf", g.portfolio_patterns[0]);
    try std.testing.expectEqualStrings("mom.srf", g.portfolio_patterns[1]);
}

test "parseGlobals: -p with glob pattern (single value, quoted by shell)" {
    // The user typed -p 'portfolio_*.srf' and the shell preserved the
    // quotes; we get a single pattern arg with literal '*' in it.
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "-p", "portfolio_*.srf", "portfolio" };
    const g = try parseGlobals(allocator, &argv);
    defer allocator.free(g.portfolio_patterns);
    try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
    try std.testing.expectEqualStrings("portfolio_*.srf", g.portfolio_patterns[0]);
}

test "parseGlobals: unquoted-glob detector fires on multiple .srf args" {
    // The user typed `-p portfolio_*.srf` without quotes; zsh expanded
    // the glob into multiple bareword args. We surface a dedicated
    // error so the user gets a friendly fix-it message.
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "-p", "portfolio_1.srf", "portfolio_2.srf", "portfolio" };
    try std.testing.expectError(error.UnquotedGlobLikely, parseGlobals(allocator, &argv));
}

test "parseGlobals: unquoted-glob detector ignores legitimate -p + subcommand" {
    // `-p main.srf snapshot` is fine: one .srf file, then a
    // recognized-shape subcommand. Detector must NOT fire.
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "-p", "main.srf", "snapshot" };
    const g = try parseGlobals(allocator, &argv);
    defer allocator.free(g.portfolio_patterns);
    try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
    try std.testing.expectEqualStrings("main.srf", g.portfolio_patterns[0]);
}

test "parseGlobals: unquoted-glob detector handles trailing args ending the argv" {
    // `-p a.srf b.srf c.srf` (with no subcommand following). The
    // looksLikeUnquotedGlob loop hits end-of-argv with srf_count > 0
    // and reports the suspicious shape.
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "c.srf" };
    try std.testing.expectError(error.UnquotedGlobLikely, parseGlobals(allocator, &argv));
}

test "parseGlobals: unquoted-glob detector does NOT fire when only one .srf follows" {
    // Just `-p something.srf` then a subcommand — single-srf shape,
    // no detection. Critical: future maintainers might tighten the
    // heuristic and accidentally start firing here.
    const allocator = std.testing.allocator;
    const argv = [_][]const u8{ "zfin", "-p", "main.srf", "compare" };
    const g = try parseGlobals(allocator, &argv);
    defer allocator.free(g.portfolio_patterns);
    try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
}

test "looksLikeUnquotedGlob: empty cursor yields false" {
    const args = [_][]const u8{ "zfin", "-p", "main.srf" };
    try std.testing.expect(!looksLikeUnquotedGlob(&args, args.len));
}

test "looksLikeUnquotedGlob: stops at flag-shaped token" {
    // `-p a.srf -p b.srf` — the second -p halts the scan after zero
    // .srf files in the run, so the detector returns false.
    const args = [_][]const u8{ "zfin", "-p", "a.srf", "-p", "b.srf" };
    try std.testing.expect(!looksLikeUnquotedGlob(&args, 3));
}

test "looksLikeUnquotedGlob: srf followed by non-srf positional returns true" {
    // `-p a.srf b.srf compare` — a.srf is the consumed -p value, then
    // b.srf is the suspicious extra. The non-srf "compare" arrives
    // after we've already counted b.srf, so the detector fires.
    const args = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "compare" };
    try std.testing.expect(looksLikeUnquotedGlob(&args, 3));
}

test "looksLikeUnquotedGlob: empty arg returns false" {
    const args = [_][]const u8{ "zfin", "-p", "a.srf", "" };
    try std.testing.expect(!looksLikeUnquotedGlob(&args, 3));
}

// Single test binary: all source is in one module (file imports, no module
// boundaries). `std.testing.refAllDecls(@This())` walks main.zig's top-level
// decls, which transitively pulls in every file imported (directly or
// indirectly) via a `const x = @import(...)` form. As long as a file is
// reachable that way through the import graph, its `test` blocks are
// collected by the test runner — no explicit `_ = @import(...)` lines
// required here.
//
// If a new `.zig` file's tests aren't being discovered (test count doesn't
// rise after adding a file with tests), the cause is almost always that
// the file is only referenced via a *type extraction* like
// `const T = @import("foo.zig").T;` — that form pulls in the type but
// doesn't sema-touch the file struct, so its tests are skipped. Fix the
// importer to do `const foo = @import("foo.zig");` instead. See AGENTS.md
// "Test discovery" for the canary procedure.

test {
    std.testing.refAllDecls(@This());
}
