require "test_helper"

class QifImportTest < ActiveSupport::TestCase
  # ── QifParser unit tests ────────────────────────────────────────────────────

  SAMPLE_QIF = <<~QIF
    !Type:Tag
    NTRIP2025
    ^
    NVACATION2023
    DSummer Vacation 2023
    ^
    !Type:Cat
    NFood & Dining
    DFood and dining expenses
    E
    ^
    NFood & Dining:Restaurants
    DRestaurants
    E
    ^
    NSalary
    DSalary Income
    I
    ^
    !Type:CCard
    D6/ 4'20
    U-99.00
    T-99.00
    C*
    NTXFR
    PMerchant A
    LFees & Charges
    ^
    D3/29'21
    U-28,500.00
    T-28,500.00
    PTransfer Out
    L[Savings Account]
    ^
    D10/ 1'20
    U500.00
    T500.00
    PPayment Received
    LFood & Dining/TRIP2025
    ^
  QIF

  QIF_WITH_HIERARCHICAL_CATEGORIES = <<~QIF
    !Type:Bank
    D1/ 1'24
    U-150.00
    T-150.00
    PHardware Store
    LHome:Home Improvement
    ^
    D2/ 1'24
    U-50.00
    T-50.00
    PGrocery Store
    LFood:Groceries
    ^
  QIF

  # A QIF file that includes an Opening Balance entry as the first transaction.
  # This mirrors how Quicken exports bank accounts.
  QIF_WITH_OPENING_BALANCE = <<~QIF
    !Type:Bank
    D1/ 1'20
    U500.00
    T500.00
    POpening Balance
    L[Checking Account]
    ^
    D3/ 1'20
    U100.00
    T100.00
    PFirst Deposit
    ^
    D4/ 1'20
    U-25.00
    T-25.00
    PCoffee Shop
    ^
  QIF

  # A minimal investment QIF with two securities, trades, a dividend, and a cash transfer.
  SAMPLE_INVST_QIF = <<~QIF
    !Type:Security
    NACME
    SACME
    TStock
    ^
    !Type:Security
    NCORP
    SCORP
    TStock
    ^
    !Type:Invst
    D1/17'22
    NDiv
    YACME
    U190.75
    T190.75
    ^
    D1/17'22
    NBuy
    YACME
    I66.10
    Q2
    U132.20
    T132.20
    ^
    D1/ 7'22
    NXIn
    PMonthly Deposit
    U8000.00
    T8000.00
    ^
    D2/ 1'22
    NSell
    YCORP
    I45.00
    Q3
    U135.00
    T135.00
    ^
  QIF

  # A QIF file that includes split transactions (S/$ fields) with an L field category.
  QIF_WITH_SPLITS = <<~QIF
    !Type:Cat
    NFood & Dining
    E
    ^
    NHousehold
    E
    ^
    NUtilities
    E
    ^
    !Type:Bank
    D1/ 1'24
    U-150.00
    T-150.00
    PGrocery & Hardware Store
    LFood & Dining
    SFood & Dining
    $-100.00
    EGroceries
    SHousehold
    $-50.00
    ESupplies
    ^
    D1/ 2'24
    U-75.00
    T-75.00
    PElectric Company
    LUtilities
    ^
  QIF

  # A QIF file where Quicken uses --Split-- as the L field for split transactions.
  QIF_WITH_SPLIT_PLACEHOLDER = <<~QIF
    !Type:Bank
    D1/ 1'24
    U-100.00
    T-100.00
    PWalmart
    L--Split--
    SClothing
    $-25.00
    SFood
    $-25.00
    SHome Improvement
    $-50.00
    ^
    D1/ 2'24
    U-30.00
    T-30.00
    PCoffee Shop
    LFood & Dining
    ^
  QIF

  # ── QifParser: valid? ───────────────────────────────────────────────────────

  test "valid? returns true for QIF content" do
    assert QifParser.valid?(SAMPLE_QIF)
  end

  test "valid? returns false for non-QIF content" do
    refute QifParser.valid?("<OFX><STMTTRN></STMTTRN></OFX>")
    refute QifParser.valid?("date,amount,name\n2024-01-01,100,Coffee")
    refute QifParser.valid?(nil)
    refute QifParser.valid?("")
  end

  # ── QifParser: account_type ─────────────────────────────────────────────────

  test "account_type extracts transaction section type" do
    assert_equal "CCard", QifParser.account_type(SAMPLE_QIF)
  end

  test "account_type ignores Tag and Cat sections" do
    qif = "!Type:Tag\nNMyTag\n^\n!Type:Cat\nNMyCat\n^\n!Type:Bank\nD1/1'24\nT100.00\nPTest\n^\n"
    assert_equal "Bank", QifParser.account_type(qif)
  end

  # ── QifParser: parse (transactions) ─────────────────────────────────────────

  test "parse returns correct number of transactions" do
    assert_equal 3, QifParser.parse(SAMPLE_QIF).length
  end

  test "parse extracts dates correctly" do
    transactions = QifParser.parse(SAMPLE_QIF)
    assert_equal "2020-06-04", transactions[0].date
    assert_equal "2021-03-29", transactions[1].date
    assert_equal "2020-10-01", transactions[2].date
  end

  test "parse extracts negative amount with commas" do
    assert_equal "-28500.00", QifParser.parse(SAMPLE_QIF)[1].amount
  end

  test "parse extracts simple negative amount" do
    assert_equal "-99.00", QifParser.parse(SAMPLE_QIF)[0].amount
  end

  test "parse extracts payee" do
    transactions = QifParser.parse(SAMPLE_QIF)
    assert_equal "Merchant A",   transactions[0].payee
    assert_equal "Transfer Out", transactions[1].payee
  end

  test "parse extracts category and ignores transfer accounts" do
    transactions = QifParser.parse(SAMPLE_QIF)
    assert_equal "Fees & Charges", transactions[0].category
    assert_equal "",               transactions[1].category  # [Savings Account] = transfer
    assert_equal "Food & Dining",  transactions[2].category
  end

  test "parse extracts tags from L field slash suffix" do
    transactions = QifParser.parse(SAMPLE_QIF)
    assert_equal [],             transactions[0].tags
    assert_equal [],             transactions[1].tags
    assert_equal [ "TRIP2025" ], transactions[2].tags
  end

  # ── QifParser: parse_categories ─────────────────────────────────────────────

  test "parse_categories returns all categories" do
    names = QifParser.parse_categories(SAMPLE_QIF).map(&:name)
    assert_includes names, "Food & Dining"
    assert_includes names, "Food & Dining:Restaurants"
    assert_includes names, "Salary"
  end

  test "parse_categories marks income vs expense correctly" do
    categories = QifParser.parse_categories(SAMPLE_QIF)
    salary = categories.find { |c| c.name == "Salary" }
    food   = categories.find { |c| c.name == "Food & Dining" }
    assert salary.income
    refute food.income
  end

  # ── QifParser: parse_tags ───────────────────────────────────────────────────

  test "parse_tags returns all tags" do
    names = QifParser.parse_tags(SAMPLE_QIF).map(&:name)
    assert_includes names, "TRIP2025"
    assert_includes names, "VACATION2023"
  end

  test "parse_tags captures description" do
    vacation = QifParser.parse_tags(SAMPLE_QIF).find { |t| t.name == "VACATION2023" }
    assert_equal "Summer Vacation 2023", vacation.description
  end

  # ── QifParser: encoding ──────────────────────────────────────────────────────

  test "normalize_encoding returns content unchanged when already valid UTF-8" do
    result = QifParser.normalize_encoding("!Type:CCard\n")
    assert_equal "!Type:CCard\n", result
  end

  # ── QifParser: opening balance ───────────────────────────────────────────────

  test "parse skips Opening Balance transaction" do
    transactions = QifParser.parse(QIF_WITH_OPENING_BALANCE)
    assert_equal 2, transactions.length
    refute transactions.any? { |t| t.payee == "Opening Balance" }
  end

  test "parse_opening_balance returns date and amount" do
    ob = QifParser.parse_opening_balance(QIF_WITH_OPENING_BALANCE)
    assert_not_nil ob
    assert_equal Date.new(2020, 1, 1), ob[:date]
    assert_equal BigDecimal("500"),    ob[:amount]
  end

  test "parse_opening_balance returns nil when no Opening Balance entry" do
    assert_nil QifParser.parse_opening_balance(SAMPLE_QIF)
  end

  test "parse_opening_balance returns nil for blank content" do
    assert_nil QifParser.parse_opening_balance(nil)
    assert_nil QifParser.parse_opening_balance("")
  end

  # ── QifParser: split transactions ──────────────────────────────────────────

  test "parse flags split transactions" do
    transactions = QifParser.parse(QIF_WITH_SPLITS)
    split_txn = transactions.find { |t| t.payee == "Grocery & Hardware Store" }
    normal_txn = transactions.find { |t| t.payee == "Electric Company" }

    assert split_txn.split, "Expected split transaction to be flagged"
    refute normal_txn.split, "Expected normal transaction not to be flagged"
  end

  test "parse returns correct count including split transactions" do
    transactions = QifParser.parse(QIF_WITH_SPLITS)
    assert_equal 2, transactions.length
  end

  test "parse strips --Split-- placeholder from category" do
    transactions = QifParser.parse(QIF_WITH_SPLIT_PLACEHOLDER)
    walmart = transactions.find { |t| t.payee == "Walmart" }

    assert walmart.split, "Expected split transaction to be flagged"
    assert_equal "", walmart.category, "Expected --Split-- to be stripped from category"
  end

  test "parse preserves normal category alongside --Split-- placeholder" do
    transactions = QifParser.parse(QIF_WITH_SPLIT_PLACEHOLDER)
    coffee = transactions.find { |t| t.payee == "Coffee Shop" }

    refute coffee.split
    assert_equal "Food & Dining", coffee.category
  end

  # ── QifImport model ─────────────────────────────────────────────────────────

  setup do
    @family  = families(:dylan_family)
    @account = accounts(:depository)
    @import  = QifImport.create!(family: @family, account: @account)
  end

  test "generates rows from QIF content" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    assert_equal 3, @import.rows.count
  end

  test "rows_count is updated after generate_rows_from_csv" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    assert_equal 3, @import.reload.rows_count
  end

  test "generates row with correct date and amount" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    row = @import.rows.find_by(name: "Merchant A")
    assert_equal "2020-06-04", row.date
    assert_equal "-99.00",     row.amount
  end

  test "generates row with category" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    row = @import.rows.find_by(name: "Merchant A")
    assert_equal "Fees & Charges", row.category
  end

  test "generates row with tags stored as pipe-separated string" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    row = @import.rows.find_by(name: "Payment Received")
    assert_equal "TRIP2025", row.tags
  end

  test "transfer rows have blank category" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    row = @import.rows.find_by(name: "Transfer Out")
    assert row.category.blank?
  end

  test "requires_csv_workflow? is false" do
    refute @import.requires_csv_workflow?
  end

  test "qif_account_type returns CCard for credit card QIF" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    assert_equal "CCard", @import.qif_account_type
  end

  test "row_categories excludes blank categories" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    cats = @import.row_categories
    assert_includes cats, "Fees & Charges"
    assert_includes cats, "Food & Dining"
    refute_includes cats, ""
  end

  test "row_tags excludes blank tags" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    tags = @import.row_tags
    assert_includes tags, "TRIP2025"
    refute_includes tags, ""
  end

  test "split_categories returns categories from split transactions" do
    @import.update!(raw_file_str: QIF_WITH_SPLITS)
    @import.generate_rows_from_csv

    split_cats = @import.split_categories
    assert_includes split_cats, "Food & Dining"
    refute_includes split_cats, "Utilities"
  end

  test "split_categories returns empty when no splits" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    assert_empty @import.split_categories
  end

  test "has_split_transactions? returns true when splits exist" do
    @import.update!(raw_file_str: QIF_WITH_SPLITS)
    assert @import.has_split_transactions?
  end

  test "has_split_transactions? returns true for --Split-- placeholder" do
    @import.update!(raw_file_str: QIF_WITH_SPLIT_PLACEHOLDER)
    assert @import.has_split_transactions?
  end

  test "has_split_transactions? returns false when no splits" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    refute @import.has_split_transactions?
  end

  test "split_categories is empty when splits use --Split-- placeholder" do
    @import.update!(raw_file_str: QIF_WITH_SPLIT_PLACEHOLDER)
    @import.generate_rows_from_csv

    assert_empty @import.split_categories
    refute_includes @import.row_categories, "--Split--"
  end

  test "categories_selected? is false before sync_mappings" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    refute @import.categories_selected?
  end

  test "categories_selected? is true after sync_mappings" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv
    @import.sync_mappings

    assert @import.categories_selected?
  end

  test "publishable? requires account to be present" do
    import_without_account = QifImport.create!(family: @family)
    import_without_account.update_columns(raw_file_str: SAMPLE_QIF, rows_count: 1)

    refute import_without_account.publishable?
  end

  # ── Opening balance handling ─────────────────────────────────────────────────

  test "Opening Balance row is not generated as a transaction row" do
    @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE)
    @import.generate_rows_from_csv

    assert_equal 2, @import.rows.count
    refute @import.rows.exists?(name: "Opening Balance")
  end

  test "import! sets opening anchor from QIF Opening Balance entry" do
    @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE)
    @import.generate_rows_from_csv
    @import.sync_mappings
    @import.import!

    manager = Account::OpeningBalanceManager.new(@account)
    assert manager.has_opening_anchor?
    assert_equal Date.new(2020, 1, 1), manager.opening_date
    assert_equal BigDecimal("500"),    manager.opening_balance
  end

  test "import! moves opening anchor back when transactions predate it" do
    # Anchor set 2 years ago; SAMPLE_QIF has transactions from 2020 which predate it
    @account.entries.create!(
      date:      2.years.ago.to_date,
      name:      "Opening balance",
      amount:    0,
      currency:  @account.currency,
      entryable: Valuation.new(kind: "opening_anchor")
    )

    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv
    @import.sync_mappings
    @import.import!

    manager = Account::OpeningBalanceManager.new(@account.reload)
    # Day before the earliest SAMPLE_QIF transaction (2020-06-04)
    assert_equal Date.new(2020, 6, 3), manager.opening_date
    assert_equal 0, manager.opening_balance
  end

  test "import! does not move opening anchor when transactions do not predate it" do
    anchor_date = Date.new(2020, 1, 1) # before the earliest SAMPLE_QIF transaction (2020-06-04)
    @account.entries.create!(
      date:      anchor_date,
      name:      "Opening balance",
      amount:    0,
      currency:  @account.currency,
      entryable: Valuation.new(kind: "opening_anchor")
    )

    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv
    @import.sync_mappings
    @import.import!

    assert_equal anchor_date, Account::OpeningBalanceManager.new(@account.reload).opening_date
  end

  test "import! updates a pre-existing opening anchor from QIF Opening Balance entry" do
    @account.entries.create!(
      date:      2.years.ago.to_date,
      name:      "Opening balance",
      amount:    0,
      currency:  @account.currency,
      entryable: Valuation.new(kind: "opening_anchor")
    )

    @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE)
    @import.generate_rows_from_csv
    @import.sync_mappings
    @import.import!

    manager = Account::OpeningBalanceManager.new(@account.reload)
    assert_equal Date.new(2020, 1, 1), manager.opening_date
    assert_equal BigDecimal("500"),    manager.opening_balance
  end

  test "will_adjust_opening_anchor? returns true when transactions predate anchor" do
    @account.entries.create!(
      date:      2.years.ago.to_date,
      name:      "Opening balance",
      amount:    0,
      currency:  @account.currency,
      entryable: Valuation.new(kind: "opening_anchor")
    )

    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    assert @import.will_adjust_opening_anchor?
  end

  test "will_adjust_opening_anchor? returns false when QIF has Opening Balance entry" do
    @account.entries.create!(
      date:      2.years.ago.to_date,
      name:      "Opening balance",
      amount:    0,
      currency:  @account.currency,
      entryable: Valuation.new(kind: "opening_anchor")
    )

    @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE)
    @import.generate_rows_from_csv

    refute @import.will_adjust_opening_anchor?
  end

  test "adjusted_opening_anchor_date is one day before earliest transaction" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    assert_equal Date.new(2020, 6, 3), @import.adjusted_opening_anchor_date
  end

  # ── Hierarchical category (Parent:Child) ─────────────────────────────────────

  test "generates rows with hierarchical category stored as-is" do
    @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES)
    @import.generate_rows_from_csv

    row = @import.rows.find_by(name: "Hardware Store")
    assert_equal "Home:Home Improvement", row.category
  end

  test "create_mappable! creates parent and child categories for hierarchical key" do
    @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES)
    @import.generate_rows_from_csv
    @import.sync_mappings

    mapping = @import.mappings.categories.find_by(key: "Home:Home Improvement")
    mapping.update!(create_when_empty: true)
    mapping.create_mappable!

    child = @family.categories.find_by(name: "Home Improvement")
    assert_not_nil child
    assert_not_nil child.parent
    assert_equal "Home", child.parent.name
  end

  test "create_mappable! reuses existing parent category for hierarchical key" do
    existing_parent = @family.categories.create!(
      name: "Home", color: "#aabbcc", lucide_icon: "house"
    )

    @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES)
    @import.generate_rows_from_csv
    @import.sync_mappings

    mapping = @import.mappings.categories.find_by(key: "Home:Home Improvement")
    mapping.update!(create_when_empty: true)

    assert_no_difference "@family.categories.where(name: 'Home').count" do
      mapping.create_mappable!
    end

    child = @family.categories.find_by(name: "Home Improvement")
    assert_equal existing_parent.id, child.parent_id
  end

  test "mappables_by_key pre-matches hierarchical key to existing child category" do
    parent = @family.categories.create!(
      name: "Home", color: "#aabbcc", lucide_icon: "house"
    )
    child = @family.categories.create!(
      name: "Home Improvement", color: "#aabbcc", lucide_icon: "house",
      parent: parent
    )

    @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES)
    @import.generate_rows_from_csv

    mappables = Import::CategoryMapping.mappables_by_key(@import)
    assert_equal child, mappables["Home:Home Improvement"]
  end

  # ── Investment (Invst) QIF: parser ──────────────────────────────────────────

  test "parse_securities returns all securities from investment QIF" do
    securities = QifParser.parse_securities(SAMPLE_INVST_QIF)
    assert_equal 2, securities.length
    tickers = securities.map(&:ticker)
    assert_includes tickers, "ACME"
    assert_includes tickers, "CORP"
  end

  test "parse_securities maps name to ticker and type correctly" do
    acme = QifParser.parse_securities(SAMPLE_INVST_QIF).find { |s| s.ticker == "ACME" }
    assert_equal "ACME",  acme.name
    assert_equal "Stock", acme.security_type
  end

  test "parse_securities returns empty array for non-investment QIF" do
    assert_empty QifParser.parse_securities(SAMPLE_QIF)
  end

  test "parse_investment_transactions returns all investment records" do
    assert_equal 4, QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).length
  end

  test "parse_investment_transactions resolves security name to ticker" do
    buy = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Buy" }
    assert_equal "ACME", buy.security_ticker
    assert_equal "ACME", buy.security_name
  end

  test "parse_investment_transactions extracts price, qty, and amount for trade actions" do
    buy = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Buy" }
    assert_equal "66.10",  buy.price
    assert_equal "2",      buy.qty
    assert_equal "132.20", buy.amount
  end

  test "parse_investment_transactions extracts amount and ticker for dividend" do
    div = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Div" }
    assert_equal "190.75", div.amount
    assert_equal "ACME",   div.security_ticker
  end

  test "parse_investment_transactions extracts payee for cash actions" do
    xin = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "XIn" }
    assert_equal "Monthly Deposit", xin.payee
    assert_equal "8000.00",         xin.amount
  end

  # ── Investment (Invst) QIF: row generation ──────────────────────────────────

  test "qif_account_type returns Invst for investment QIF" do
    @import.update!(raw_file_str: SAMPLE_INVST_QIF)
    assert_equal "Invst", @import.qif_account_type
  end

  test "generates correct number of rows from investment QIF" do
    @import.update!(raw_file_str: SAMPLE_INVST_QIF)
    @import.generate_rows_from_csv

    assert_equal 4, @import.rows.count
  end

  test "generates trade rows with correct entity_type, ticker, qty, and price" do
    @import.update!(raw_file_str: SAMPLE_INVST_QIF)
    @import.generate_rows_from_csv

    buy_row = @import.rows.find_by(entity_type: "Buy")
    assert_not_nil buy_row
    assert_equal "ACME",   buy_row.ticker
    assert_equal "2.0",    buy_row.qty
    assert_equal "66.10",  buy_row.price
    assert_equal "132.20", buy_row.amount
  end

  test "generates sell row with negative qty" do
    @import.update!(raw_file_str: SAMPLE_INVST_QIF)
    @import.generate_rows_from_csv

    sell_row = @import.rows.find_by(entity_type: "Sell")
    assert_not_nil sell_row
    assert_equal "CORP", sell_row.ticker
    assert_equal "-3.0", sell_row.qty
  end

  test "generates transaction row for Div with security name in row name" do
    @import.update!(raw_file_str: SAMPLE_INVST_QIF)
    @import.generate_rows_from_csv

    div_row = @import.rows.find_by(entity_type: "Div")
    assert_not_nil div_row
    assert_equal "Dividend: ACME", div_row.name
    assert_equal "190.75",         div_row.amount
  end

  test "generates transaction row for XIn using payee as name" do
    @import.update!(raw_file_str: SAMPLE_INVST_QIF)
    @import.generate_rows_from_csv

    xin_row = @import.rows.find_by(entity_type: "XIn")
    assert_not_nil xin_row
    assert_equal "Monthly Deposit", xin_row.name
  end

  # ── Investment (Invst) QIF: import! ─────────────────────────────────────────

  test "import! creates Trade records for buy and sell rows" do
    import = QifImport.create!(family: @family, account: accounts(:investment))
    import.update!(raw_file_str: SAMPLE_INVST_QIF)
    import.generate_rows_from_csv
    import.sync_mappings

    Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))

    assert_difference "Trade.count", 2 do
      import.import!
    end
  end

  test "import! creates Transaction records for dividend and cash rows" do
    import = QifImport.create!(family: @family, account: accounts(:investment))
    import.update!(raw_file_str: SAMPLE_INVST_QIF)
    import.generate_rows_from_csv
    import.sync_mappings

    Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))

    assert_difference "Transaction.count", 2 do
      import.import!
    end
  end

  test "import! creates inflow Entry for Div (negative amount)" do
    import = QifImport.create!(family: @family, account: accounts(:investment))
    import.update!(raw_file_str: SAMPLE_INVST_QIF)
    import.generate_rows_from_csv
    import.sync_mappings

    Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
    import.import!

    div_entry = accounts(:investment).entries.find_by(name: "Dividend: ACME")
    assert_not_nil div_entry
    assert div_entry.amount.negative?, "Dividend should be an inflow (negative amount)"
    assert_in_delta(-190.75, div_entry.amount, 0.01)
  end

  test "import! creates outflow Entry for Buy (positive amount)" do
    import = QifImport.create!(family: @family, account: accounts(:investment))
    import.update!(raw_file_str: SAMPLE_INVST_QIF)
    import.generate_rows_from_csv
    import.sync_mappings

    Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
    import.import!

    buy_entry = accounts(:investment)
      .entries
      .joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'")
      .find_by("trades.qty > 0")
    assert_not_nil buy_entry
    assert buy_entry.amount.positive?, "Buy trade should be an outflow (positive amount)"
  end

  test "import! creates inflow Entry for Sell (negative amount)" do
    import = QifImport.create!(family: @family, account: accounts(:investment))
    import.update!(raw_file_str: SAMPLE_INVST_QIF)
    import.generate_rows_from_csv
    import.sync_mappings

    Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
    import.import!

    sell_entry = accounts(:investment)
      .entries
      .joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'")
      .find_by("trades.qty < 0")
    assert_not_nil sell_entry
    assert sell_entry.amount.negative?, "Sell trade should be an inflow (negative amount)"
  end

  test "will_adjust_opening_anchor? returns false for investment accounts" do
    import = QifImport.create!(family: @family, account: accounts(:investment))
    import.update!(raw_file_str: SAMPLE_INVST_QIF)
    import.generate_rows_from_csv

    refute import.will_adjust_opening_anchor?
  end

  # ── QifParser: normalize_qif_date ──────────────────────────────────────────

  test "normalize_qif_date converts apostrophe 2-digit year" do
    assert_equal "6/4/2020", QifParser.send(:normalize_qif_date, "6/ 4'20")
  end

  test "normalize_qif_date converts apostrophe 4-digit year" do
    assert_equal "6/4/2020", QifParser.send(:normalize_qif_date, "6/ 4'2020")
  end

  test "normalize_qif_date handles dot-separated dates" do
    assert_equal "04.06.2020", QifParser.send(:normalize_qif_date, "04.06.2020")
  end

  test "normalize_qif_date handles dot with apostrophe year" do
    assert_equal "04.06.2020", QifParser.send(:normalize_qif_date, "04.06'20")
  end

  test "normalize_qif_date handles dash-separated dates" do
    assert_equal "2020-06-04", QifParser.send(:normalize_qif_date, "2020-06-04")
  end

  test "normalize_qif_date returns nil for blank input" do
    assert_nil QifParser.send(:normalize_qif_date, nil)
    assert_nil QifParser.send(:normalize_qif_date, "")
  end

  # ── QifParser: parse_qif_date with different formats ───────────────────────

  test "parse_qif_date parses US format (MM/DD/YYYY)" do
    assert_equal "2020-06-04", QifParser.send(:parse_qif_date, "6/ 4'20", date_format: "%m/%d/%Y")
  end

  test "parse_qif_date parses European slash format (DD/MM/YYYY)" do
    # 4/ 6'20 → day=4, month=6 → June 4th
    assert_equal "2020-06-04", QifParser.send(:parse_qif_date, "4/ 6'20", date_format: "%d/%m/%Y")
  end

  test "parse_qif_date parses European dot format (DD.MM.YYYY)" do
    assert_equal "2020-06-04", QifParser.send(:parse_qif_date, "04.06.2020", date_format: "%d.%m.%Y")
  end

  test "parse_qif_date parses ISO format (YYYY-MM-DD)" do
    assert_equal "2020-06-04", QifParser.send(:parse_qif_date, "2020-06-04", date_format: "%Y-%m-%d")
  end

  test "parse_qif_date returns nil for invalid date" do
    assert_nil QifParser.send(:parse_qif_date, "13/32/2020", date_format: "%m/%d/%Y")
  end

  # ── QifParser: extract_raw_dates ───────────────────────────────────────────

  test "extract_raw_dates returns normalized date strings from D-fields" do
    dates = QifParser.extract_raw_dates(SAMPLE_QIF)
    assert_includes dates, "6/4/2020"
    assert_includes dates, "3/29/2021"
    assert_includes dates, "10/1/2020"
  end

  test "extract_raw_dates returns empty for blank content" do
    assert_empty QifParser.extract_raw_dates(nil)
    assert_empty QifParser.extract_raw_dates("")
  end

  # ── QifParser: parse with European date format ─────────────────────────────

  EUROPEAN_QIF = <<~QIF
    !Type:Bank
    D04/06/2020
    U-99.00
    T-99.00
    PMerchant A
    ^
    D29/03/2021
    U-50.00
    T-50.00
    PMerchant B
    ^
  QIF

  test "parse with DD/MM/YYYY format parses dates correctly" do
    transactions = QifParser.parse(EUROPEAN_QIF, date_format: "%d/%m/%Y")
    assert_equal "2020-06-04", transactions[0].date
    assert_equal "2021-03-29", transactions[1].date
  end

  # ── Import.detect_date_format ──────────────────────────────────────────────

  test "detect_date_format identifies US slash format" do
    samples = %w[6/4/2020 3/29/2021 10/1/2020]
    # 3/29 cannot be DD/MM (month 29 invalid), so must be MM/DD
    assert_equal "%m/%d/%Y", Import.detect_date_format(samples)
  end

  test "detect_date_format identifies European slash format" do
    samples = %w[04/06/2020 29/03/2021 01/10/2020]
    # 29/03 cannot be MM/DD (month 29 invalid), so must be DD/MM
    assert_equal "%d/%m/%Y", Import.detect_date_format(samples)
  end

  test "detect_date_format identifies European dot format" do
    samples = %w[04.06.2020 29.03.2021 01.10.2020]
    assert_equal "%d.%m.%Y", Import.detect_date_format(samples)
  end

  test "detect_date_format identifies ISO format" do
    samples = %w[2020-06-04 2021-03-29 2020-10-01]
    assert_equal "%Y-%m-%d", Import.detect_date_format(samples)
  end

  test "detect_date_format returns fallback for blank samples" do
    assert_equal "%Y-%m-%d", Import.detect_date_format([])
    assert_equal "%Y-%m-%d", Import.detect_date_format(nil)
  end

  test "detect_date_format returns fallback when no format matches" do
    samples = %w[not-a-date garbage]
    assert_equal "%Y-%m-%d", Import.detect_date_format(samples)
  end

  # ── QifImport: auto-detection integration ──────────────────────────────────

  test "generate_rows_from_csv auto-detects US date format" do
    @import.update!(raw_file_str: SAMPLE_QIF)
    @import.generate_rows_from_csv

    assert_equal "%m/%d/%Y", @import.reload.qif_date_format
    row = @import.rows.find_by(name: "Merchant A")
    assert_equal "2020-06-04", row.date
  end

  EUROPEAN_BANK_QIF = <<~QIF
    !Type:Bank
    D13/01/2024
    U-100.00
    T-100.00
    PEuropean Store
    ^
    D25/12/2023
    U-50.00
    T-50.00
    PChristmas Shop
    ^
  QIF

  test "generate_rows_from_csv auto-detects European DD/MM/YYYY format" do
    @import.update!(raw_file_str: EUROPEAN_BANK_QIF)
    @import.generate_rows_from_csv

    assert_equal "%d/%m/%Y", @import.reload.qif_date_format
    row = @import.rows.find_by(name: "European Store")
    assert_equal "2024-01-13", row.date
  end

  test "generate_rows_from_csv respects manually set qif_date_format" do
    @import.update!(raw_file_str: EUROPEAN_BANK_QIF)
    @import.qif_date_format = "%d/%m/%Y"
    @import.save!(validate: false)
    @import.generate_rows_from_csv

    # Should not re-detect since qif_date_format is already set
    assert_equal "%d/%m/%Y", @import.reload.qif_date_format
  end

  # ── QifParser: try_parse_date ───────────────────────────────────────────────

  test "try_parse_date returns ISO date for valid format" do
    assert_equal "2020-06-04", QifParser.try_parse_date("6/ 4'20", date_format: "%m/%d/%Y")
  end

  test "try_parse_date returns nil for incompatible format" do
    assert_nil QifParser.try_parse_date("2020-06-04", date_format: "%d.%m.%Y")
  end

  # ── QifImport: valid_date_formats_with_preview ──────────────────────────────

  test "valid_date_formats_with_preview excludes formats that cannot parse the file dates" do
    @import.update!(raw_file_str: EUROPEAN_BANK_QIF)
    formats = @import.valid_date_formats_with_preview

    format_strs = formats.map { |f| f[:format] }

    # DD/MM/YYYY should be valid (13/01/2024)
    assert_includes format_strs, "%d/%m/%Y"

    # MM/DD/YYYY should be excluded (month 13 is invalid)
    assert_not_includes format_strs, "%m/%d/%Y"

    # Each valid format should have a preview date
    formats.each do |f|
      assert_not_nil f[:preview], "Expected preview for #{f[:label]}"
    end
  end

  test "valid_date_formats_with_preview returns empty array when no raw dates" do
    @import.update!(raw_file_str: "")
    formats = @import.valid_date_formats_with_preview

    assert_empty formats
  end
end
