require "test_helper"
require "ostruct"

class Account::MarketDataImporterTest < ActiveSupport::TestCase
  include ProviderTestHelper

  SECURITY_PRICE_BUFFER = Security::Price::Importer::PROVISIONAL_LOOKBACK_DAYS.days
  EXCHANGE_RATE_BUFFER  = 5.days

  setup do
    # Ensure a clean slate for deterministic assertions
    Security::Price.delete_all
    ExchangeRate.delete_all
    Trade.delete_all
    Holding.delete_all
    Security.delete_all
    Entry.delete_all

    @provider = mock("provider")
    Provider::Registry.any_instance
                      .stubs(:get_provider)
                      .with(:twelve_data)
                      .returns(@provider)
  end

  test "syncs required exchange rates for a foreign-currency account" do
    family = Family.create!(name: "Smith", currency: "USD")

    account = family.accounts.create!(
      name: "Chequing",
      currency: "CAD",
      balance: 100,
      accountable: Depository.new
    )

    # Seed a rate for the first required day so that the importer only needs the next day forward
    existing_date = account.start_date
    ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: existing_date, rate: 2.0)
    ExchangeRate.create!(from_currency: "USD", to_currency: "CAD", date: existing_date, rate: 0.5)

    expected_start_date = (existing_date + 1.day) - EXCHANGE_RATE_BUFFER
    end_date            = Date.current.in_time_zone("America/New_York").to_date

    # Only the forward pair (CAD→USD) should be fetched; inverse (USD→CAD) is computed automatically
    @provider.expects(:fetch_exchange_rates)
             .with(from: "CAD",
                   to: "USD",
                   start_date: expected_start_date,
                   end_date: end_date)
             .returns(provider_success_response([
               OpenStruct.new(from: "CAD", to: "USD", date: existing_date, rate: 1.5)
             ]))

    before = ExchangeRate.count
    Account::MarketDataImporter.new(account).import_all
    after  = ExchangeRate.count

    assert_operator after, :>, before + 1, "Should insert at least two new exchange-rate rows (forward + computed inverse)"

    # Verify inverse rates were computed from the forward rates
    assert ExchangeRate.where(from_currency: "USD", to_currency: "CAD").where("date > ?", existing_date).exists?,
           "Inverse rates should be computed automatically"
  end

  test "syncs security prices for securities traded by the account" do
    family = Family.create!(name: "Smith", currency: "USD")

    account = family.accounts.create!(
      name: "Brokerage",
      currency: "USD",
      balance: 0,
      accountable: Investment.new
    )

    security = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS")

    trade_date = 10.days.ago.to_date
    trade      = Trade.new(security: security, qty: 1, price: 100, currency: "USD", investment_activity_label: "Buy")

    account.entries.create!(
      name: "Buy AAPL",
      date: trade_date,
      amount: 100,
      currency: "USD",
      entryable: trade
    )

    expected_start_date = trade_date - SECURITY_PRICE_BUFFER
    end_date            = Date.current.in_time_zone("America/New_York").to_date

    @provider.expects(:fetch_security_prices)
             .with(symbol: security.ticker,
                   exchange_operating_mic: security.exchange_operating_mic,
                   start_date: expected_start_date,
                   end_date: end_date)
             .returns(provider_success_response([
               OpenStruct.new(security: security,
                              date: trade_date,
                              price: 100,
                              currency: "USD")
             ]))

    @provider.stubs(:fetch_security_info)
             .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic)
             .returns(provider_success_response(OpenStruct.new(name: "Apple", logo_url: "logo")))

    # Ignore exchange-rate calls for this test
    @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([]))

    Account::MarketDataImporter.new(account).import_all

    assert_equal 1, Security::Price.where(security: security, date: trade_date).count
  end

  test "caps end_date at last holding date for securities no longer held" do
    family = Family.create!(name: "Smith", currency: "USD")

    account = family.accounts.create!(
      name: "Brokerage",
      currency: "USD",
      balance: 0,
      accountable: Investment.new
    )

    current_sec    = Security.create!(ticker: "CURR", exchange_operating_mic: "XNAS")
    historical_sec = Security.create!(ticker: "HIST", exchange_operating_mic: "XNAS")

    trade_date  = 30.days.ago.to_date
    sold_date   = 5.days.ago.to_date

    [ current_sec, historical_sec ].each do |sec|
      trade = Trade.new(security: sec, qty: 10, price: 100, currency: "USD", investment_activity_label: "Buy")
      account.entries.create!(name: "Buy #{sec.ticker}", date: trade_date, amount: 1000, currency: "USD", entryable: trade)
    end

    # Current: most-recent holding has qty > 0 — shows up in current_holdings
    account.holdings.create!(security: current_sec, date: Date.current, qty: 10, price: 110, amount: 1100, currency: "USD")

    # Historical: most-recent holding has qty == 0 (sold) — excluded from current_holdings
    account.holdings.create!(security: historical_sec, date: 10.days.ago.to_date, qty: 10, price: 105, amount: 1050, currency: "USD")
    account.holdings.create!(security: historical_sec, date: sold_date, qty: 0, price: 0, amount: 0, currency: "USD")

    expected_start_date = trade_date - SECURITY_PRICE_BUFFER

    @provider.expects(:fetch_security_prices)
             .with(symbol: current_sec.ticker,
                   exchange_operating_mic: current_sec.exchange_operating_mic,
                   start_date: expected_start_date,
                   end_date: Date.current.in_time_zone("America/New_York").to_date)
             .returns(provider_success_response([]))

    @provider.expects(:fetch_security_prices)
             .with(symbol: historical_sec.ticker,
                   exchange_operating_mic: historical_sec.exchange_operating_mic,
                   start_date: expected_start_date,
                   end_date: sold_date)
             .returns(provider_success_response([]))

    @provider.stubs(:fetch_security_info).returns(provider_success_response(OpenStruct.new(name: "Test", logo_url: nil)))
    @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([]))

    Account::MarketDataImporter.new(account).import_all
  end

  test "fetches prices through today when a sold security is repurchased before holdings are rematerialized" do
    family = Family.create!(name: "Smith", currency: "USD")

    account = family.accounts.create!(
      name: "Brokerage",
      currency: "USD",
      balance: 0,
      accountable: Investment.new
    )

    security = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS")

    original_buy_date = 60.days.ago.to_date
    sold_date         = 30.days.ago.to_date
    repurchase_date   = 2.days.ago.to_date

    # Original buy
    buy_trade = Trade.new(security: security, qty: 10, price: 100, currency: "USD", investment_activity_label: "Buy")
    account.entries.create!(name: "Buy AAPL", date: original_buy_date, amount: 1000, currency: "USD", entryable: buy_trade)

    # Sell trade
    sell_trade = Trade.new(security: security, qty: -10, price: 120, currency: "USD", investment_activity_label: "Sell")
    account.entries.create!(name: "Sell AAPL", date: sold_date, amount: 1200, currency: "USD", entryable: sell_trade)

    # Stale materialized holdings — qty=0 means current_holdings excludes this security
    account.holdings.create!(security: security, date: sold_date, qty: 0, price: 0, amount: 0, currency: "USD")

    # Repurchase trade added after last materialization — holdings haven't been rematerialized yet
    repurchase = Trade.new(security: security, qty: 5, price: 130, currency: "USD", investment_activity_label: "Buy")
    account.entries.create!(name: "Rebuy AAPL", date: repurchase_date, amount: 650, currency: "USD", entryable: repurchase)

    expected_start_date = original_buy_date - SECURITY_PRICE_BUFFER

    # end_date must be Date.current, not sold_date, because the position was reopened
    @provider.expects(:fetch_security_prices)
             .with(symbol: security.ticker,
                   exchange_operating_mic: security.exchange_operating_mic,
                   start_date: expected_start_date,
                   end_date: Date.current.in_time_zone("America/New_York").to_date)
             .returns(provider_success_response([]))

    @provider.stubs(:fetch_security_info).returns(provider_success_response(OpenStruct.new(name: "Apple", logo_url: nil)))
    @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([]))

    Account::MarketDataImporter.new(account).import_all
  end

  test "skips price fetching for offline securities" do
    family = Family.create!(name: "Smith", currency: "USD")

    account = family.accounts.create!(
      name: "Brokerage",
      currency: "USD",
      balance: 0,
      accountable: Investment.new
    )

    online_sec  = Security.create!(ticker: "ONLN", exchange_operating_mic: "XNAS")
    offline_sec = Security.create!(ticker: "OFF",  exchange_operating_mic: "XNAS", offline: true)

    trade_date = 10.days.ago.to_date

    [ online_sec, offline_sec ].each do |sec|
      trade = Trade.new(security: sec, qty: 1, price: 100, currency: "USD", investment_activity_label: "Buy")
      account.entries.create!(name: "Buy #{sec.ticker}", date: trade_date, amount: 100, currency: "USD", entryable: trade)
    end

    @provider.expects(:fetch_security_prices)
             .with(symbol: "OFF", exchange_operating_mic: anything,
                   start_date: anything, end_date: anything)
             .never

    @provider.expects(:fetch_security_prices)
             .with(symbol: online_sec.ticker,
                   exchange_operating_mic: online_sec.exchange_operating_mic,
                   start_date: anything,
                   end_date: anything)
             .returns(provider_success_response([]))

    @provider.stubs(:fetch_security_info).returns(provider_success_response(OpenStruct.new(name: "Test", logo_url: nil)))
    @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([]))

    Account::MarketDataImporter.new(account).import_all
  end

  test "handles provider error response gracefully for security prices" do
    family = Family.create!(name: "Smith", currency: "USD")

    account = family.accounts.create!(
      name: "Brokerage",
      currency: "USD",
      balance: 0,
      accountable: Investment.new
    )

    security = Security.create!(ticker: "INVALID", exchange_operating_mic: "XNAS")

    trade_date = 10.days.ago.to_date
    trade      = Trade.new(security: security, qty: 1, price: 100, currency: "USD", investment_activity_label: "Buy")

    account.entries.create!(
      name: "Buy INVALID",
      date: trade_date,
      amount: 100,
      currency: "USD",
      entryable: trade
    )

    expected_start_date = trade_date - SECURITY_PRICE_BUFFER
    end_date            = Date.current.in_time_zone("America/New_York").to_date

    # Simulate provider returning an error response
    @provider.expects(:fetch_security_prices)
             .with(symbol: security.ticker,
                   exchange_operating_mic: security.exchange_operating_mic,
                   start_date: expected_start_date,
                   end_date: end_date)
             .returns(provider_error_response(
               Provider::TwelveData::Error.new("Invalid symbol", details: { code: 400, message: "Invalid symbol" })
             ))

    @provider.stubs(:fetch_security_info)
             .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic)
             .returns(provider_success_response(OpenStruct.new(name: "Invalid Co", logo_url: "logo")))

    # Ignore exchange-rate calls for this test
    @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([]))

    # Should not raise an error, just log and continue
    assert_nothing_raised do
      Account::MarketDataImporter.new(account).import_all
    end

    assert_equal 0, Security::Price.where(security: security, date: trade_date).count
  end

  test "syncs security prices for provider-held securities without trades" do
    family = Family.create!(name: "Smith", currency: "USD")

    account = family.accounts.create!(
      name: "Brokerage",
      currency: "USD",
      balance: 0,
      accountable: Investment.new
    )

    security = Security.create!(ticker: "PE500", exchange_operating_mic: "XPAR")

    coinstats_item = family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
    coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD")
    account_provider = AccountProvider.create!(account: account, provider: coinstats_account)

    account.holdings.create!(
      security: security,
      date: Date.current,
      qty: 1,
      price: 100,
      amount: 100,
      currency: "USD",
      account_provider: account_provider
    )

    expected_start_date = account.start_date - SECURITY_PRICE_BUFFER
    end_date = Date.current.in_time_zone("America/New_York").to_date

    @provider.expects(:fetch_security_prices)
             .with(symbol: security.ticker,
                   exchange_operating_mic: security.exchange_operating_mic,
                   start_date: expected_start_date,
                   end_date: end_date)
             .returns(provider_success_response([
               OpenStruct.new(security: security,
                              date: account.start_date,
                              price: 100,
                              currency: "USD")
             ]))

    @provider.stubs(:fetch_security_info)
             .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic)
             .returns(provider_success_response(OpenStruct.new(name: "PE500", logo_url: "logo")))

    @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([]))

    Account::MarketDataImporter.new(account).import_all

    assert_equal 1, Security::Price.where(security: security, date: account.start_date).count
  end

  test "handles provider error response gracefully for exchange rates" do
    family = Family.create!(name: "Smith", currency: "USD")

    account = family.accounts.create!(
      name: "Chequing",
      currency: "CAD",
      balance: 100,
      accountable: Depository.new
    )

    # Seed a rate for the first required day
    existing_date = account.start_date
    ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: existing_date, rate: 2.0)
    ExchangeRate.create!(from_currency: "USD", to_currency: "CAD", date: existing_date, rate: 0.5)

    expected_start_date = (existing_date + 1.day) - EXCHANGE_RATE_BUFFER
    end_date            = Date.current.in_time_zone("America/New_York").to_date

    # Only the forward pair (CAD→USD) should be fetched; inverse is computed automatically
    @provider.expects(:fetch_exchange_rates)
             .with(from: "CAD",
                   to: "USD",
                   start_date: expected_start_date,
                   end_date: end_date)
             .returns(provider_error_response(
               Provider::TwelveData::Error.new("Rate limit exceeded", details: { code: 429, message: "Rate limit exceeded" })
             ))

    before = ExchangeRate.count

    # Should not raise an error, just log and continue
    assert_nothing_raised do
      Account::MarketDataImporter.new(account).import_all
    end

    after = ExchangeRate.count

    # No new rates should be added due to error
    assert_equal before, after
  end
end
