const std = @import("std");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const exposure = @import("../analytics/exposure.zig");
const Holding = @import("../models/etf_profile.zig").Holding;

/// Warn / flag thresholds for total single-name exposure, expressed as
/// a fraction of the portfolio. Shared conceptually with the planned
/// look-through concentration check (see CONCENTRATION_CHECK_PLAN.md);
/// here they only drive the color of the total line.
const warn_threshold: f64 = 0.05; // 5%
const flag_threshold: f64 = 0.08; // 8%

pub const ParsedArgs = struct {
    symbol: []const u8,
};

pub const meta: framework.Meta = .{
    .name = "exposure",
    .group = .portfolio,
    .synopsis = "Show true exposure to a symbol (direct + look-through via ETFs)",
    .uppercase_first_arg = true,
    .help =
    \\Usage: zfin exposure <SYMBOL>
    \\
    \\Show how much of a single underlying symbol you really hold —
    \\directly, plus look-through via the top holdings of every ETF
    \\in the portfolio. A fund worth $V that holds SYMBOL at weight w
    \\contributes V*w of exposure.
    \\
    \\ETF holdings are matched to SYMBOL by ticker when the NPORT-P
    \\filing carries one, otherwise by resolving the holding's CUSIP
    \\to a ticker (local cache -> ZFIN_SERVER -> OpenFIGI). Holdings
    \\with no ticker and no resolvable CUSIP (bonds, derivatives) are
    \\excluded.
    \\
    \\ETF profiles come from SEC EDGAR and are cached ~90 days. The
    \\first run on a cold cache fetches them and can take ~15s.
    \\
    \\Examples:
    \\  zfin exposure AAPL      # how much Apple do I really own?
    \\  zfin exposure NVDA
    \\
    ,
    .user_errors = error{ MissingSymbol, UnexpectedArg },
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    if (cmd_args.len < 1) {
        cli.stderrPrint(ctx.io, "Error: 'exposure' requires a symbol argument\n");
        return error.MissingSymbol;
    }
    if (cmd_args.len > 1) {
        cli.stderrPrint(ctx.io, "Error: 'exposure' takes a single symbol argument\n");
        return error.UnexpectedArg;
    }
    return .{ .symbol = cmd_args[0] };
}

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; // per-invocation arena
    const out = ctx.out;
    const color = ctx.color;
    const as_of = ctx.today;
    const target = parsed.symbol; // already uppercased by the framework

    var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
    defer loaded.deinit(allocator);

    const portfolio = loaded.portfolio;
    const positions = loaded.positions;
    const syms = loaded.syms;

    // Refresh prices so position market values are TTL-fresh (mirrors
    // the `analysis` command). The loader prints its own stderr summary.
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    if (syms.len > 0) {
        var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, ctx.globals.refresh_policy, color);
        defer load_result.deinit();
        var it = load_result.prices.iterator();
        while (it.next()) |entry| {
            prices.put(entry.key_ptr.*, entry.value_ptr.*) catch |err| {
                log.warn("exposure: price map put({s}): {t}", .{ entry.key_ptr.*, err });
            };
        }
    }

    var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
        error.NoAllocations, error.SummaryFailed => {
            cli.stderrPrint(io, "Error computing portfolio summary.\n");
            return;
        },
        else => return err,
    };
    defer pf_data.deinit(allocator);

    const allocations = pf_data.summary.allocations;
    const total_value = pf_data.summary.total_value;

    if (allocations.len > 0) {
        cli.stderrPrint(io, "Scanning portfolio for look-through exposure (uncached ETFs fetch from EDGAR; first run can take ~15s)...\n");
    }

    // Fetch each portfolio fund's NPORT-P holdings. `getEtfProfile`
    // returns NotFound for non-ETF symbols (and errors on fetch
    // failure); both are skipped silently. Holding strings are duped
    // into the arena so they survive the per-iteration
    // `FetchResult.deinit`; the CUSIPs we'll need resolved are gathered
    // as we go. The actual resolution + aggregation is `exposure.analyze`.
    const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
    var funds: std.ArrayList(exposure.FundInput) = .empty;
    var cusips: std.ArrayList([]const u8) = .empty;
    for (allocations) |alloc| {
        const res = svc.getEtfProfile(alloc.symbol, opts) catch continue;
        defer res.deinit();
        if (!res.data.isEtf()) continue;
        const holdings = res.data.holdings orelse continue;

        var hs: std.ArrayList(Holding) = .empty;
        for (holdings) |h| {
            const sym: ?[]const u8 = if (h.symbol) |s| try allocator.dupe(u8, s) else null;
            const cus: ?[]const u8 = if (h.cusip) |c| try allocator.dupe(u8, c) else null;
            try hs.append(allocator, .{
                .name = try allocator.dupe(u8, h.name),
                .symbol = sym,
                .cusip = cus,
                .weight = h.weight,
            });
            // Only null-ticker holdings need CUSIP->ticker resolution.
            if (sym == null) {
                if (cus) |c| try cusips.append(allocator, c);
            }
        }
        // `alloc.symbol` lives in `pf_data` (released after display), so
        // borrow it rather than duping.
        try funds.append(allocator, .{
            .fund = alloc.symbol,
            .value = alloc.market_value,
            .holdings = try hs.toOwnedSlice(allocator),
        });
    }

    // Resolve the union of unresolved CUSIPs in one batched cascade.
    // Offline mode (`--refresh-data=never`) restricts resolution to the
    // local L1 cache via `skip_network`, so cached CUSIPs still resolve
    // but nothing hits the server or OpenFIGI.
    const offline = ctx.globals.refresh_policy == .never;
    var cusip_map = svc.resolveCusips(allocator, cusips.items, offline);
    defer cusip_map.deinit();

    var directs: std.ArrayList(exposure.DirectPosition) = .empty;
    for (allocations) |alloc| {
        try directs.append(allocator, .{ .symbol = alloc.symbol, .value = alloc.market_value });
    }

    var result = try exposure.analyze(allocator, target, total_value, directs.items, funds.items, &cusip_map.map);
    defer result.deinit(allocator);

    var label_buf: [512]u8 = undefined;
    const anchor_path = loaded.anchor();
    const label: []const u8 = if (loaded.paths.len > 1)
        std.fmt.bufPrint(&label_buf, "{s} (+{d} more)", .{ anchor_path, loaded.paths.len - 1 }) catch anchor_path
    else
        anchor_path;

    try display(result, label, color, out);
}

const log = std.log.scoped(.exposure);

/// Render an exposure result. Pulled out of `run` so it can be tested
/// without a portfolio, network, or DataService.
pub fn display(result: exposure.ExposureResult, label: []const u8, color: bool, out: *std.Io.Writer) !void {
    try cli.printBold(out, color, "\nExposure to {s}  ({s})\n", .{ result.symbol, label });
    try out.print("========================================\n\n", .{});

    if (result.totalValue() <= 0) {
        try cli.printFg(out, color, cli.CLR_MUTED, "  No exposure found — {s} is not held directly or in the top holdings of any ETF in the portfolio.\n\n", .{result.symbol});
        return;
    }

    const total_w = result.totalWeight();
    const total_color = if (total_w >= flag_threshold)
        cli.CLR_NEGATIVE
    else if (total_w >= warn_threshold)
        cli.CLR_WARNING
    else
        cli.CLR_ACCENT;

    var pbuf: [16]u8 = undefined;
    try cli.setBold(out, color);
    try cli.printFg(out, color, total_color, "  Total exposure   {s:>6}   {f}\n", .{ fmt.fmtPct(&pbuf, total_w, .{}), Money.from(result.totalValue()).padRight(13) });
    try cli.printFg(out, color, cli.CLR_MUTED, "    Direct         {s:>6}   {f}\n", .{ fmt.fmtPct(&pbuf, result.directWeight(), .{}), Money.from(result.direct_value).padRight(13) });
    try cli.printFg(out, color, cli.CLR_MUTED, "    Look-through   {s:>6}   {f}\n", .{ fmt.fmtPct(&pbuf, result.lookthroughWeight(), .{}), Money.from(result.lookthrough_value).padRight(13) });

    if (result.contributions.len > 0) {
        try cli.printBold(out, color, "\n  Via funds:\n", .{});
        var wbuf: [16]u8 = undefined;
        for (result.contributions) |c| {
            try cli.printFg(out, color, cli.CLR_ACCENT, "    {s:<8}", .{c.fund});
            try out.print(" {s:>6}   {f}   ", .{ fmt.fmtPct(&pbuf, result.fractionOf(c.value), .{}), Money.from(c.value).padRight(13) });
            try cli.printFg(out, color, cli.CLR_MUTED, "({s} is {s} of {s})\n", .{ result.symbol, fmt.fmtPct(&wbuf, c.weight_in_fund, .{}), c.fund });
        }
    }

    try out.print("\n", .{});
    try cli.printFg(out, color, cli.CLR_MUTED, "  Based on each ETF's latest NPORT-P top holdings, matched by CUSIP.\n", .{});
    if (result.unresolved_holdings > 0) {
        try cli.printFg(out, color, cli.CLR_MUTED, "  {d} holding(s) without a resolvable US identifier — typically\n", .{result.unresolved_holdings});
        try cli.printFg(out, color, cli.CLR_MUTED, "  foreign-listed securities and cash — are outside look-through.\n\n", .{});
    }
    if (result.fund_of_funds.len > 0) {
        var nbuf: [16]u8 = undefined;
        try cli.printFg(out, color, cli.CLR_MUTED, "  Funds-of-funds not expanded (", .{});
        for (result.fund_of_funds, 0..) |name, i| {
            try cli.printFg(out, color, cli.CLR_MUTED, "{s}{s}", .{ if (i == 0) "" else ", ", name });
        }
        try cli.printFg(out, color, cli.CLR_MUTED, ") hold ~{f}\n", .{Money.from(result.nested_fund_value).whole()});
        try cli.printFg(out, color, cli.CLR_MUTED, "  ({s} of the portfolio); {s} inside them isn't counted.\n", .{ fmt.fmtPct(&nbuf, result.fractionOf(result.nested_fund_value), .{}), result.symbol });
    }
    try out.print("\n", .{});
}

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

test "parseArgs: accepts a single symbol" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"AAPL"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("AAPL", parsed.symbol);
}

test "parseArgs: missing symbol errors" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{};
    try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args));
}

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

test "display: full breakdown with direct + funds" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const contribs = [_]exposure.FundContribution{
        .{ .fund = "QQQ", .value = 20_000, .weight_in_fund = 0.10 },
        .{ .fund = "XLK", .value = 5_000, .weight_in_fund = 0.05 },
    };
    const result: exposure.ExposureResult = .{
        .symbol = "AAPL",
        .total_value = 100_000,
        .direct_value = 10_000,
        .lookthrough_value = 25_000,
        .contributions = &contribs,
        .unresolved_holdings = 1,
    };
    try display(result, "portfolio.srf", false, &w);
    const o = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, o, "Exposure to AAPL") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "Total exposure") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "Direct") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "Look-through") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "Via funds:") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "QQQ") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "10.0% of QQQ") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "$10,000") != null);
    // unresolved footnote present
    try std.testing.expect(std.mem.indexOf(u8, o, "without a resolvable US identifier") != null);
    // no color
    try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") == null);
}

test "display: funds-of-funds footnote when nested funds present" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const result: exposure.ExposureResult = .{
        .symbol = "AAPL",
        .total_value = 100_000,
        .direct_value = 5_000,
        .lookthrough_value = 10_000,
        .contributions = &.{},
        .unresolved_holdings = 0,
        .nested_fund_value = 20_000,
        .fund_of_funds = &.{ "FUNDA", "FUNDB", "FUNDC" },
    };
    try display(result, "portfolio.srf", false, &w);
    const o = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, o, "Funds-of-funds not expanded") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "FUNDA, FUNDB, FUNDC") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "$20,000") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "20.0%") != null); // 20k / 100k
}

test "display: no funds-of-funds footnote when none present" {
    var buf: [2048]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const result: exposure.ExposureResult = .{
        .symbol = "AAPL",
        .total_value = 100_000,
        .direct_value = 10_000,
        .lookthrough_value = 0,
        .contributions = &.{},
        .unresolved_holdings = 0,
    };
    try display(result, "portfolio.srf", false, &w);
    try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "Funds-of-funds") == null);
}

test "display: no exposure message" {
    var buf: [2048]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const result: exposure.ExposureResult = .{
        .symbol = "TSLA",
        .total_value = 100_000,
        .direct_value = 0,
        .lookthrough_value = 0,
        .contributions = &.{},
        .unresolved_holdings = 0,
    };
    try display(result, "portfolio.srf", false, &w);
    const o = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, o, "No exposure found") != null);
    try std.testing.expect(std.mem.indexOf(u8, o, "TSLA") != null);
    // No "Via funds" section when there's nothing.
    try std.testing.expect(std.mem.indexOf(u8, o, "Via funds") == null);
}

test "display: high concentration emits color when enabled" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    // 10% total -> above flag_threshold -> colored.
    const result: exposure.ExposureResult = .{
        .symbol = "AAPL",
        .total_value = 100_000,
        .direct_value = 10_000,
        .lookthrough_value = 0,
        .contributions = &.{},
        .unresolved_holdings = 0,
    };
    try display(result, "portfolio.srf", true, &w);
    const o = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") != null);
}
