//! `zfin audit` command dispatcher.
//!
//! Thin entry point: argument parsing plus routing to one of the
//! per-responsibility modules in the `audit/` directory:
//!
//!   - `audit/hygiene.zig`  - flagless portfolio hygiene check (no flags)
//!   - `audit/fidelity.zig` - `--fidelity` positions-CSV reconciler
//!   - `audit/schwab.zig`   - `--schwab` positions-CSV + `--schwab-summary`
//!                            reconcilers
//!   - `audit/common.zig`   - shared comparison types + per-account display
//!
//! This file sits beside its `audit/` directory (the `tui.zig` +
//! `tui/` convention), not as a `mod.zig` inside it.
//!
//! The split keeps each broker's reconcile/display logic in its own
//! file so adding a broker is a one-file add next to the others, and
//! so a future `zfin doctor` can reuse the hygiene check without
//! pulling in the reconciliation surface.

const std = @import("std");
const cli = @import("common.zig");
const framework = @import("framework.zig");

const common = @import("audit/common.zig");
const fidelity = @import("../analytics/reconcile/fidelity.zig");
const schwab = @import("audit/schwab.zig");
const hygiene = @import("audit/hygiene.zig");

// ── CLI entry point ─────────────────────────────────────────

pub const ParsedArgs = struct {
    fidelity_csv: ?[]const u8 = null,
    schwab_csv: ?[]const u8 = null,
    schwab_summary: bool = false,
    verbose: bool = false,
    stale_days: u32 = hygiene.default_stale_days,
};

pub const meta: framework.Meta = .{
    .name = "audit",
    .group = .hygiene,
    .synopsis = "Reconcile portfolio against brokerage exports + portfolio hygiene check",
    .help =
    \\Usage: zfin audit [opts]
    \\
    \\Two modes in one command:
    \\
    \\  Flagless: run the portfolio hygiene check - surfaces stale
    \\  manual prices, account-cadence violations, and brokerage-file
    \\  candidates discovered automatically.
    \\
    \\  With brokerage flags: reconcile the portfolio against the
    \\  given export and report discrepancies.
    \\
    \\Options:
    \\  --verbose             Show full reconciliation output even when clean
    \\  --stale-days <N>      Manual price staleness threshold (default 3)
    \\  --fidelity <CSV>      Fidelity positions CSV export
    \\                        ("All accounts" -> Positions tab -> Download)
    \\  --schwab <CSV>        Schwab per-account positions CSV export
    \\  --schwab-summary      Schwab account summary; copy from accounts
    \\                        summary page, paste to stdin, then ^D
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{ UnexpectedArg, EmptyFile, NoAccountsFound, UnexpectedHeader, MissingFlagValue },
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    var parsed: ParsedArgs = .{};
    var i: usize = 0;
    while (i < cmd_args.len) : (i += 1) {
        const a = cmd_args[i];
        if (std.mem.eql(u8, a, "--fidelity")) {
            parsed.fidelity_csv = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
        } else if (std.mem.eql(u8, a, "--schwab")) {
            parsed.schwab_csv = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
        } else if (std.mem.eql(u8, a, "--schwab-summary")) {
            parsed.schwab_summary = true;
        } else if (std.mem.eql(u8, a, "--verbose")) {
            parsed.verbose = true;
        } else if (std.mem.eql(u8, a, "--stale-days")) {
            const v = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
            // Reject a non-integer value loudly instead of silently
            // falling back to the default; a typo'd threshold that
            // silently reverts to 3 is a foot-gun.
            parsed.stale_days = std.fmt.parseInt(u32, v, 10) catch {
                cli.stderrPrint(ctx.io, "Error: --stale-days requires a non-negative integer, got: ");
                cli.stderrPrint(ctx.io, v);
                cli.stderrPrint(ctx.io, "\n");
                return error.UnexpectedArg;
            };
        } else {
            // Unknown flag or stray positional: reject explicitly
            // rather than silently ignoring it (which previously let
            // `audit --bogus` run flagless hygiene mode with no error).
            cli.stderrPrint(ctx.io, "Error: unknown argument to 'audit': ");
            cli.stderrPrint(ctx.io, a);
            cli.stderrPrint(ctx.io, "\nRun 'zfin audit --help' for usage.\n");
            return error.UnexpectedArg;
        }
    }
    return parsed;
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    const svc = ctx.svc orelse return error.MissingDataService;
    const io = ctx.io;
    const allocator = ctx.allocator;
    const out = ctx.out;
    const color = ctx.color;
    const as_of = ctx.today;
    const now_s = ctx.now_s;

    const fidelity_csv = parsed.fidelity_csv;
    const schwab_csv = parsed.schwab_csv;
    const schwab_summary = parsed.schwab_summary;
    const verbose = parsed.verbose;
    const stale_days = parsed.stale_days;

    // Flagless mode: run portfolio hygiene check (single-file
    // semantics - git blame, commit SHAs, etc.). Resolve paths
    // just to find the anchor; we don't need the merged view.
    if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
        const pf = ctx.resolvePortfolioPath();
        defer pf.deinit(allocator);
        return hygiene.runHygieneCheck(io, allocator, ctx.environ_map, svc, pf.path, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out);
    }

    // Reconciliation modes (--fidelity / --schwab / --schwab-summary):
    // load the union of all portfolio files so the comparison sees
    // every lot the user holds, even if they're split across multiple
    // portfolio_*.srf files.
    var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
    defer loaded.deinit(allocator);
    const portfolio = loaded.portfolio;
    const portfolio_path = loaded.anchor();

    // Load accounts.srf
    var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse {
        cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
        return;
    };
    defer account_map.deinit();

    // Build prices map, shared by all audit modes.
    //
    // Route through `cli.loadPortfolioPrices` so the audit gets the same
    // TTL-based cache refresh behavior `zfin portfolio` uses. Previously
    // this read cached last-closes directly, which silently used stale
    // data after long weekends / when the cache hadn't been refreshed.
    // TTL-driven refetch keeps numbers current without forcing a full
    // provider hit every run.
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    {
        const pos_syms = try portfolio.stockSymbols(allocator);
        defer allocator.free(pos_syms);

        if (pos_syms.len > 0) {
            var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, ctx.globals.refresh_policy, color);
            defer load_result.deinit();
            var it = load_result.prices.iterator();
            while (it.next()) |entry| {
                try prices.put(entry.key_ptr.*, entry.value_ptr.*);
            }
        }

        // Manual `price::` overrides from portfolio.srf still win for lots
        // that carry them (e.g. 401k CIT shares with no API coverage).
        for (portfolio.lots) |lot| {
            if (lot.price) |p| {
                if (!prices.contains(lot.priceSymbol())) {
                    // Pre-multiply - see "Pricing model" in models/portfolio.zig.
                    try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
                }
            }
        }
    }

    // Schwab summary from stdin
    if (schwab_summary) {
        cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
        var stdin_reader_buf: [4096]u8 = undefined;
        var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
        const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch {
            cli.stderrPrint(io, "Error: Cannot read stdin\n");
            return;
        };
        defer allocator.free(stdin_data);

        const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) {
            error.OutOfMemory => return err,
            else => {
                cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
                return;
            },
        };
        defer allocator.free(results);

        try schwab.displaySchwabResults(results, color, out);
        try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);

        const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
        defer allocator.free(present);
        const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
        defer allocator.free(absent);
        try common.displayAbsentAccounts(absent, color, "this export", out);
    }

    // Fidelity CSV
    if (fidelity_csv) |csv_path| {
        const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
            var msg_buf: [256]u8 = undefined;
            const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
            cli.stderrPrint(io, msg);
            return;
        };
        defer allocator.free(csv_data);

        const results = fidelity.reconcile(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
            error.OutOfMemory => return err,
            else => {
                cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
                return;
            },
        };
        defer {
            for (results) |r| allocator.free(r.comparisons);
            allocator.free(results);
        }

        try common.displayResults(results, color, out);
        try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);

        const present = try common.presentNumbers(allocator, common.AccountComparison, results);
        defer allocator.free(present);
        const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of);
        defer allocator.free(absent);
        try common.displayAbsentAccounts(absent, color, "this export", out);
    }

    // Schwab per-account CSV
    if (schwab_csv) |csv_path| {
        const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
            var msg_buf: [256]u8 = undefined;
            const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
            cli.stderrPrint(io, msg);
            return;
        };
        defer allocator.free(csv_data);

        const results = schwab.reconcileCsv(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
            error.OutOfMemory => return err,
            else => {
                cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
                return;
            },
        };
        defer {
            for (results) |r| allocator.free(r.comparisons);
            allocator.free(results);
        }

        try common.displayResults(results, color, out);
        try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);

        const present = try common.presentNumbers(allocator, common.AccountComparison, results);
        defer allocator.free(present);
        const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
        defer allocator.free(absent);
        try common.displayAbsentAccounts(absent, color, "this export", out);
    }
}

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

test "parseArgs: defaults" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expect(parsed.fidelity_csv == null);
    try std.testing.expect(parsed.schwab_csv == null);
    try std.testing.expect(!parsed.schwab_summary);
    try std.testing.expect(!parsed.verbose);
    try std.testing.expectEqual(hygiene.default_stale_days, parsed.stale_days);
}

test "parseArgs: --fidelity captures CSV path" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--fidelity", "/tmp/fid.csv" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("/tmp/fid.csv", parsed.fidelity_csv.?);
}

test "parseArgs: --schwab captures CSV path" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--schwab", "/tmp/sch.csv" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("/tmp/sch.csv", parsed.schwab_csv.?);
}

test "parseArgs: --schwab-summary boolean" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--schwab-summary"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expect(parsed.schwab_summary);
}

test "parseArgs: --verbose boolean" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--verbose"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expect(parsed.verbose);
}

test "parseArgs: --stale-days parses integer" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--stale-days", "5" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqual(@as(u32, 5), parsed.stale_days);
}

test "parseArgs: unknown flag is rejected (not silently ignored)" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--bogus"};
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "parseArgs: stray positional is rejected" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"AAPL"};
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "parseArgs: --fidelity without a value is rejected (no silent mode switch)" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--fidelity"};
    try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}

test "parseArgs: --fidelity followed by a flag does not swallow the flag" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--fidelity", "--verbose" };
    try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}

test "parseArgs: --schwab without a value is rejected" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--schwab"};
    try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}

test "parseArgs: --stale-days with a non-integer value is rejected (not a silent default)" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--stale-days", "abc" };
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "parseArgs: combined valid flags parse together" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--fidelity", "/tmp/fid.csv", "--verbose", "--stale-days", "7" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("/tmp/fid.csv", parsed.fidelity_csv.?);
    try std.testing.expect(parsed.verbose);
    try std.testing.expectEqual(@as(u32, 7), parsed.stale_days);
}
