//! Fidelity export parsers.
//!
//! Parses the CSV produced by Fidelity's "Download Positions" feature.
//! (Compact option-symbol matching against a portfolio lot now lives
//! in `models/option.zig` as `symbolMatchesLot`, since the audit
//! reconciler applies it across brokers, not just Fidelity.)
//!
//! ## Limitations of this CSV parser
//!
//! 1. NOT a general-purpose CSV parser. It handles the specific format
//!    exported by Fidelity's "Download Positions" feature.
//!
//! 2. Does NOT handle quoted fields containing commas. Fidelity's export
//!    does not quote fields with commas in practice (description fields
//!    use spaces, not commas), but a truly compliant RFC 4180 parser would
//!    need to handle "field,with,commas" as a single value.
//!
//! 3. Does NOT handle escaped quotes ("" inside quoted fields).
//!
//! 4. Does NOT handle multi-line values (newlines inside quoted fields).
//!
//! 5. Assumes UTF-8 with optional BOM (which Fidelity includes).
//!
//! 6. Stops parsing at the first blank line, which separates position data
//!    from the Fidelity legal disclaimer footer.
//!
//! 7. Hardcodes the expected column layout. If Fidelity changes the CSV
//!    format (adds/removes/reorders columns), this parser will break.
//!    The header row is validated to catch this.
//!
//! 8. Dollar values like "$1,234.56" and "+$1,234.56" are stripped of
//!    $, commas, and leading +/- signs. Negative values wrapped in
//!    parentheses are NOT handled (Fidelity uses -$X.XX format).
//!
//! 9. Money market rows (symbol ending in **) are treated as cash.
//!
//! For a production-grade CSV parser, consider a library that handles
//! RFC 4180 fully (quoted fields, escaping, multi-line values).

const std = @import("std");
const portfolio_mod = @import("../models/portfolio.zig");
const types = @import("types.zig");

const BrokeragePosition = types.BrokeragePosition;
const parseDollarAmount = types.parseDollarAmount;
const isUnitPriceCash = types.isUnitPriceCash;

const expected_columns = 16;

/// Column indices in the Fidelity CSV export.
/// Based on: Account Number, Account Name, Symbol, Description, Quantity,
///           Last Price, Last Price Change, Current Value, Today's Gain/Loss Dollar,
///           Today's Gain/Loss Percent, Total Gain/Loss Dollar, Total Gain/Loss Percent,
///           Percent Of Account, Cost Basis Total, Average Cost Basis, Type
const Col = struct {
    const account_number = 0;
    const account_name = 1;
    const symbol = 2;
    const description = 3;
    const quantity = 4;
    const last_price = 5;
    const current_value = 7;
    const cost_basis_total = 13;
    const avg_cost_basis = 14;
    const type_col = 15;
};

/// Canonical name of each column `parseCsv` actually reads, at its
/// expected index. The header is validated against these
/// case-insensitively (Fidelity re-cases labels - Title Case ->
/// sentence case) so a reorder, insertion, or removal that would shift a
/// value we depend on is caught as `error.UnexpectedHeader` instead of
/// silently reading the wrong column.
///
/// Columns the parser ignores are intentionally absent here: Fidelity
/// adding, renaming, or appending an unused column is tolerated. Keep
/// this in sync with `Col`.
const ExpectedColumn = struct { idx: usize, name: []const u8 };
const expected_header = [_]ExpectedColumn{
    .{ .idx = Col.account_number, .name = "Account number" },
    .{ .idx = Col.account_name, .name = "Account name" },
    .{ .idx = Col.symbol, .name = "Symbol" },
    .{ .idx = Col.description, .name = "Description" },
    .{ .idx = Col.quantity, .name = "Quantity" },
    .{ .idx = Col.last_price, .name = "Last price" },
    .{ .idx = Col.current_value, .name = "Current value" },
    .{ .idx = Col.cost_basis_total, .name = "Cost basis total" },
    .{ .idx = Col.avg_cost_basis, .name = "Average cost basis" },
    .{ .idx = Col.type_col, .name = "Type" },
};

/// Validate that the header carries the columns `parseCsv` indexes into,
/// at their expected positions (case-insensitive). Trailing extra columns
/// are tolerated; a missing, renamed, or reordered read-column is
/// rejected with `error.UnexpectedHeader`. See `expected_header`.
fn validateHeader(header: []const u8) error{UnexpectedHeader}!void {
    // Fidelity does not quote fields containing commas, so a plain comma
    // split matches how the data rows are parsed below.
    var fields: [expected_columns][]const u8 = undefined;
    var n: usize = 0;
    var it = std.mem.splitScalar(u8, header, ',');
    while (it.next()) |f| : (n += 1) {
        if (n < expected_columns) fields[n] = std.mem.trim(u8, f, &.{ ' ', '"' });
    }
    // Need at least the columns we index into (highest index is type_col).
    if (n < expected_columns) return error.UnexpectedHeader;
    inline for (expected_header) |col| {
        if (!std.ascii.eqlIgnoreCase(fields[col.idx], col.name)) return error.UnexpectedHeader;
    }
}

/// Parse a Fidelity CSV positions export into BrokeragePosition slices.
/// All string fields in the returned positions are slices into `data`,
/// so the caller must keep `data` alive for as long as the positions are used.
/// Only the returned slice itself is heap-allocated (caller must free it).
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
    var positions = std.ArrayList(BrokeragePosition).empty;
    errdefer positions.deinit(allocator);

    // Skip UTF-8 BOM if present
    var content = data;
    if (content.len >= 3 and content[0] == 0xEF and content[1] == 0xBB and content[2] == 0xBF) {
        content = content[3..];
    }

    var lines = std.mem.splitScalar(u8, content, '\n');

    // Validate the header row. We check the specific columns parseCsv reads,
    // by name at their expected index (case-insensitive), so a column
    // reorder / insertion / removal that would shift a value we depend on is
    // caught here as UnexpectedHeader rather than silently mis-reading a
    // value. See `expected_header` / `validateHeader`.
    const header_line = lines.next() orelse return error.EmptyFile;
    const header_trimmed = std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' });
    if (header_trimmed.len == 0) return error.EmptyFile;
    try validateHeader(header_trimmed);

    // Parse data rows
    while (lines.next()) |line| {
        const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
        if (trimmed.len == 0) break;

        // Skip lines starting with " (disclaimer text)
        if (trimmed[0] == '"') break;

        var col_iter = std.mem.splitScalar(u8, trimmed, ',');
        var cols: [expected_columns][]const u8 = undefined;
        var col_count: usize = 0;
        while (col_iter.next()) |col| {
            if (col_count < expected_columns) {
                cols[col_count] = col;
                col_count += 1;
            }
        }
        if (col_count < expected_columns) continue;

        const symbol_raw = std.mem.trim(u8, cols[Col.symbol], &.{ ' ', '"' });
        if (symbol_raw.len == 0) continue;

        // Strip ** suffix from money market symbols for display
        const symbol_clean = if (std.mem.endsWith(u8, symbol_raw, "**"))
            symbol_raw[0 .. symbol_raw.len - 2]
        else
            symbol_raw;

        // Classify as cash if any of:
        //   - Fidelity's ** suffix marks a money-market position
        //   - The symbol appears in zfin's canonical money-market list
        //     (e.g. FDRXX, SPAXX - Fidelity omits ** for some of these)
        //   - price and cost both equal exactly $1.00, the catch-all for
        //     fixed-NAV instruments that we don't have in the list yet.
        const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or
            portfolio_mod.isMoneyMarketSymbol(symbol_clean) or
            isUnitPriceCash(cols[Col.last_price], cols[Col.avg_cost_basis]);

        try positions.append(allocator, .{
            .account_number = std.mem.trim(u8, cols[Col.account_number], &.{ ' ', '"' }),
            .account_name = std.mem.trim(u8, cols[Col.account_name], &.{ ' ', '"' }),
            .symbol = symbol_clean,
            .description = std.mem.trim(u8, cols[Col.description], &.{ ' ', '"' }),
            .quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]),
            .current_value = parseDollarAmount(cols[Col.current_value]),
            .cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis_total]),
            .is_cash = is_cash,
        });
    }

    return positions.toOwnedSlice(allocator);
}

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

test "parseCsv basic" {
    const csv =
        "\xEF\xBB\xBF" ++ // BOM
        "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\r\n" ++
        "Z123,Individual,FZFXX**,HELD IN MONEY MARKET,,,,$5000.00,,,,,50%,,,Cash,\r\n" ++
        "Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,50%,$10000.00,$100.00,Margin,\r\n" ++
        "\r\n" ++
        "\"Disclaimer text\"\r\n";

    const allocator = std.testing.allocator;
    const positions = try parseCsv(allocator, csv);
    defer allocator.free(positions);

    try std.testing.expectEqual(@as(usize, 2), positions.len);

    // Cash position
    try std.testing.expectEqualStrings("FZFXX", positions[0].symbol);
    try std.testing.expect(positions[0].is_cash);
    try std.testing.expectApproxEqAbs(@as(f64, 5000.00), positions[0].current_value.?, 0.01);
    try std.testing.expect(positions[0].quantity == null);

    // Stock position
    try std.testing.expectEqualStrings("AAPL", positions[1].symbol);
    try std.testing.expect(!positions[1].is_cash);
    try std.testing.expectApproxEqAbs(@as(f64, 100), positions[1].quantity.?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[1].current_value.?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[1].cost_basis.?, 0.01);
}

test "parseCsv treats $1.00 price+cost as cash" {
    const csv =
        "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
        "Z123,Individual,FDRXX,FID GOV CASH RESERVE,8500,$1.00,,$8500.00,,,,,10%,$8500.00,$1.00,Cash,\n";

    const allocator = std.testing.allocator;
    const positions = try parseCsv(allocator, csv);
    defer allocator.free(positions);

    try std.testing.expectEqual(@as(usize, 1), positions.len);
    try std.testing.expectEqualStrings("FDRXX", positions[0].symbol);
    try std.testing.expect(positions[0].is_cash);
    try std.testing.expect(positions[0].quantity == null);
    try std.testing.expectApproxEqAbs(@as(f64, 8500.00), positions[0].current_value.?, 0.01);
}

test "parseCsv stops at blank line" {
    const csv =
        "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
        "Z123,Individual,AAPL,APPLE INC,50,$150.00,+$2.00,$7500.00,+$100.00,+1.35%,+$2500.00,+50.00%,100%,$5000.00,$100.00,Margin,\n" ++
        "\n" ++
        "Z123,Individual,MSFT,SHOULD NOT APPEAR,10,$300.00,,,$3000.00,,,,,,,,\n";

    const allocator = std.testing.allocator;
    const positions = try parseCsv(allocator, csv);
    defer allocator.free(positions);

    try std.testing.expectEqual(@as(usize, 1), positions.len);
    try std.testing.expectEqualStrings("AAPL", positions[0].symbol);
}

test "parseCsv option row" {
    const csv =
        "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
        "Z123,Individual, -AMZN260515C220,AMZN MAY 15 2026 $220 CALL,-3,$21.00,+$8.60,-$6300.00,-$2580.00,-69.36%,-$3674.02,-139.92%,-8.85%,$2625.98,$8.75,Margin,\n";

    const allocator = std.testing.allocator;
    const positions = try parseCsv(allocator, csv);
    defer allocator.free(positions);

    try std.testing.expectEqual(@as(usize, 1), positions.len);
    try std.testing.expectEqualStrings("-AMZN260515C220", positions[0].symbol);
    try std.testing.expectApproxEqAbs(@as(f64, -3), positions[0].quantity.?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, -6300.00), positions[0].current_value.?, 0.01);
}

test "parseCsv empty file" {
    const allocator = std.testing.allocator;
    const result = parseCsv(allocator, "");
    try std.testing.expectError(error.EmptyFile, result);
}

test "parseCsv wrong header" {
    const allocator = std.testing.allocator;
    const result = parseCsv(allocator, "Wrong,Header,Format\n");
    try std.testing.expectError(error.UnexpectedHeader, result);
}

test "parseCsv cash account type is not cash position" {
    // Fidelity's Type column says "Cash" for cash-account positions (vs "Margin").
    // This does NOT mean the security is a cash holding - only ** suffix means that.
    const csv =
        "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
        "X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n";

    const allocator = std.testing.allocator;
    const positions = try parseCsv(allocator, csv);
    defer allocator.free(positions);

    try std.testing.expectEqual(@as(usize, 1), positions.len);
    try std.testing.expectEqualStrings("QTUM", positions[0].symbol);
    try std.testing.expect(!positions[0].is_cash);
    try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
}

test "parseCsv accepts Fidelity's lowercase (sentence-case) header" {
    // Regression: Fidelity switched the export header from Title Case to
    // sentence case ("Account number,Account name,...,Last price,...,Cost
    // basis total,Average cost basis,Type"). Column order is unchanged, so
    // positional parsing must still work. The trailing comma after the Type
    // value and the blank-line-separated legal footer mirror the real export.
    const csv =
        "Account number,Account name,Symbol,Description,Quantity,Last price,Last price change,Current value,Today's gain/loss dollar,Today's gain/loss percent,Total gain/loss dollar,Total gain/loss percent,Percent of account,Cost basis total,Average cost basis,Type\n" ++
        "Z123,Individual - TOD,FZFXX**,HELD IN MONEY MARKET,,,,$24.03,,,,,0.12%,,,Cash,\n" ++
        "Z123,Individual - TOD,AAPL,APPLE INC,100,$150.00,-$2.00,$15000.00,-$200.00,-1.3%,+$5000.00,+50%,99.88%,$10000.00,$100.00,Margin,\n" ++
        "\n" ++
        "\"Brokerage services are provided by Fidelity Brokerage Services LLC (FBS)...\"\n";

    const allocator = std.testing.allocator;
    const positions = try parseCsv(allocator, csv);
    defer allocator.free(positions);

    try std.testing.expectEqual(@as(usize, 2), positions.len);

    // Money-market row -> cash, no quantity.
    try std.testing.expectEqualStrings("FZFXX", positions[0].symbol);
    try std.testing.expect(positions[0].is_cash);
    try std.testing.expect(positions[0].quantity == null);
    try std.testing.expectApproxEqAbs(@as(f64, 24.03), positions[0].current_value.?, 0.01);

    // Stock row -> positional columns still resolve correctly.
    try std.testing.expectEqualStrings("AAPL", positions[1].symbol);
    try std.testing.expect(!positions[1].is_cash);
    try std.testing.expectApproxEqAbs(@as(f64, 100), positions[1].quantity.?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[1].current_value.?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[1].cost_basis.?, 0.01);
}

test "parseCsv rejects a header whose read-columns are reordered" {
    // Quantity (idx 4) and Last price (idx 5) are swapped. Positional parsing
    // would silently read price-as-quantity, so validateHeader must reject it
    // rather than let a shifted column through.
    const csv =
        "Account number,Account name,Symbol,Description,Last price,Quantity,Last price change,Current value,Today's gain/loss dollar,Today's gain/loss percent,Total gain/loss dollar,Total gain/loss percent,Percent of account,Cost basis total,Average cost basis,Type\n" ++
        "Z123,Individual,AAPL,APPLE INC,$150.00,100,-$2.00,$15000.00,,,,,99%,$10000.00,$100.00,Margin,\n";
    const allocator = std.testing.allocator;
    try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, csv));
}

test "parseCsv tolerates an unknown trailing column" {
    // Fidelity appending a column the parser doesn't read (a 17th, here
    // "Accrued interest") must not break parsing: the columns we index into
    // are unshifted, so validateHeader passes and values still resolve.
    const csv =
        "Account number,Account name,Symbol,Description,Quantity,Last price,Last price change,Current value,Today's gain/loss dollar,Today's gain/loss percent,Total gain/loss dollar,Total gain/loss percent,Percent of account,Cost basis total,Average cost basis,Type,Accrued interest\n" ++
        "Z123,Individual,AAPL,APPLE INC,100,$150.00,-$2.00,$15000.00,,,,,99%,$10000.00,$100.00,Margin,$0.00\n";
    const allocator = std.testing.allocator;
    const positions = try parseCsv(allocator, csv);
    defer allocator.free(positions);
    try std.testing.expectEqual(@as(usize, 1), positions.len);
    try std.testing.expectEqualStrings("AAPL", positions[0].symbol);
    try std.testing.expect(!positions[0].is_cash);
    try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[0].current_value.?, 0.01);
}
