class EnableBankingItem::Importer
  # Maximum number of pagination requests to prevent infinite loops
  # Enable Banking typically returns ~100 transactions per page, so 100 pages = ~10,000 transactions
  MAX_PAGINATION_PAGES = 100

  NETWORK_ERRORS = [
    ::SocketError,
    ::Errno::ECONNREFUSED,
    ::Timeout::Error,
    ::Net::ReadTimeout,
    ::Net::OpenTimeout
  ].freeze

  attr_reader :enable_banking_item, :enable_banking_provider

  def initialize(enable_banking_item, enable_banking_provider:)
    @enable_banking_item = enable_banking_item
    @enable_banking_provider = enable_banking_provider
  end

  def import
    unless enable_banking_item.session_valid?
      enable_banking_item.update!(status: :requires_update)
      return { success: false, error: I18n.t("enable_banking_items.errors.session_invalid"), accounts_updated: 0, transactions_imported: 0 }
    end

    session_data = fetch_session_data
    unless session_data
      error_msg = @session_error || I18n.t("enable_banking_items.errors.unexpected")
      return { success: false, error: error_msg, accounts_updated: 0, transactions_imported: 0 }
    end

    # Store raw payload
    begin
      enable_banking_item.upsert_enable_banking_snapshot!(session_data)
    rescue => e
      Rails.logger.error "EnableBankingItem::Importer - Failed to store session snapshot: #{e.message}"
    end

    sync_uids_from_accounts_data(session_data[:accounts])

    # Update accounts from session
    accounts_updated = 0
    accounts_failed = 0

    if session_data[:accounts].present?
      existing_uids = enable_banking_item.enable_banking_accounts
                                         .joins(:account_provider)
                                         .pluck(:uid)
                                         .map(&:to_s)

      # Enable Banking API returns accounts as an array of UIDs (strings) in the session response
      # We need to handle both array of strings and array of hashes
      session_data[:accounts].each do |account_data|
        # Handle both string UIDs and hash objects
        # Use identification_hash as the stable identifier across sessions
        uid = if account_data.is_a?(String)
          account_data
        elsif account_data.is_a?(Hash)
          (account_data[:identification_hash] || account_data[:uid] || account_data["identification_hash"] || account_data["uid"])&.to_s
        else
          nil
        end

        next unless uid.present?

        # Only update if this account was previously linked
        next unless existing_uids.include?(uid)

        begin
          # For string UIDs, we don't have account data to update - skip the import_account call
          # The account data will be fetched via balances/transactions endpoints
          if account_data.is_a?(Hash)
            import_account(account_data)
            accounts_updated += 1
          end
        rescue => e
          accounts_failed += 1
          @sync_error = promote_session_invalid(@sync_error, handle_sync_error(e))
          Rails.logger.error "EnableBankingItem::Importer - Failed to update account #{uid}: #{e.message}"
        end
      end
    end

    # Fetch balances and transactions for linked accounts
    transactions_imported = 0
    transactions_failed = 0

    linked_accounts_query = enable_banking_item.enable_banking_accounts.joins(:account_provider).joins(:account).merge(Account.visible)

    linked_accounts_query.each do |enable_banking_account|
      begin
        unless fetch_and_update_balance(enable_banking_account)
          transactions_failed += 1
          # @sync_error already set in fetch_and_update_balance
          next
        end

        result = fetch_and_store_transactions(enable_banking_account)
        if result[:success]
          transactions_imported += result[:transactions_count]
        else
          transactions_failed += 1
          @sync_error = promote_session_invalid(@sync_error, result[:error])
        end
      rescue => e
        transactions_failed += 1
        @sync_error = promote_session_invalid(@sync_error, handle_sync_error(e))
        Rails.logger.error "EnableBankingItem::Importer - Failed to process account #{enable_banking_account.uid}: #{e.message}"
      end
    end

    result = {
      success: accounts_failed == 0 && transactions_failed == 0,
      accounts_updated: accounts_updated,
      accounts_failed: accounts_failed,
      transactions_imported: transactions_imported,
      transactions_failed: transactions_failed
    }

    result[:error] = @sync_error || I18n.t("enable_banking_items.errors.unexpected") if !result[:success]
    result
  end

  private

    def handle_sync_error(exception)
      # Check the underlying cause first, then the exception itself
      exceptions = [ exception.cause, exception ].compact

      provider_error = exceptions.find { |ex| ex.is_a?(Provider::EnableBanking::EnableBankingError) }

      # Handle session expiration status update
      if provider_error && [ :unauthorized, :not_found ].include?(provider_error.error_type)
        enable_banking_item.update!(status: :requires_update)
        return I18n.t("enable_banking_items.errors.session_invalid")
      end

      is_network_error = exceptions.any? do |ex|
        NETWORK_ERRORS.any? { |err| ex.is_a?(err) } ||
          (ex.is_a?(Provider::EnableBanking::EnableBankingError) && [ :request_failed, :timeout ].include?(ex.error_type))
      end

      if is_network_error
        I18n.t("enable_banking_items.errors.network_unreachable")
      elsif provider_error
        I18n.t("enable_banking_items.errors.api_error")
      else
        I18n.t("enable_banking_items.errors.unexpected")
      end
    end

    def fetch_session_data
      enable_banking_provider.get_session(session_id: enable_banking_item.session_id)
    rescue Provider::EnableBanking::EnableBankingError => e
      Rails.logger.error "EnableBankingItem::Importer - Enable Banking API error: #{e.message}"
      @session_error = handle_sync_error(e)
      nil
    rescue => e
      Rails.logger.error "EnableBankingItem::Importer - Unexpected error fetching session: #{e.class} - #{e.message}"
      @session_error = handle_sync_error(e)
      nil
    end

    def import_account(account_data)
      # Use identification_hash as the stable identifier across sessions
      uid = account_data[:identification_hash] || account_data[:uid]

      enable_banking_account = find_enable_banking_account_by_hash(uid)
      return unless enable_banking_account

      enable_banking_account.upsert_enable_banking_snapshot!(account_data)
      enable_banking_account.save!
    end

    def fetch_and_update_balance(enable_banking_account)
      balance_data = enable_banking_provider.get_account_balances(
        account_id: enable_banking_account.api_account_id,
        psu_headers: enable_banking_item.build_psu_headers
      )

      # Enable Banking returns an array of balances. We prioritize types based on reliability.
      # closingBooked (CLBD) > interimAvailable (ITAV) > expected (XPCD)
      balances = balance_data[:balances] || []
      return true if balances.empty?

      priority_types = [ "CLBD", "ITAV", "XPCD", "CLAV", "ITBD" ]
      balance = nil

      priority_types.each do |type|
        balance = balances.find { |b| b[:balance_type] == type }
        break if balance
      end

      balance ||= balances.first

      if balance.present?
        amount = balance.dig(:balance_amount, :amount) || balance[:amount]
        currency = balance.dig(:balance_amount, :currency) || balance[:currency]

        if amount.present?
          indicator = balance[:credit_debit_indicator]
          parsed_amount = amount.to_d

          # Enable Banking uses positive amounts for both credit and debit.
          # DBIT indicates a negative balance (money owed/withdrawn).
          parsed_amount = -parsed_amount if indicator == "DBIT"

          enable_banking_account.update!(
            current_balance: parsed_amount,
            currency: currency.presence || enable_banking_account.currency
          )
        end
      end
      true
    rescue Provider::EnableBanking::EnableBankingError => e
      @sync_error = promote_session_invalid(@sync_error, handle_sync_error(e))
      Rails.logger.error "EnableBankingItem::Importer - Error fetching balance for account #{enable_banking_account.uid}: #{e.message}"
      false
    end

    def promote_session_invalid(existing, new)
      return new if existing.nil?
      return new if new == I18n.t("enable_banking_items.errors.session_invalid")
      existing
    end

    def include_pending?
      Setting.syncs_include_pending
    end

    def fetch_and_store_transactions(enable_banking_account)
      start_date = determine_sync_start_date(enable_banking_account)
      include_pending = include_pending?

      all_transactions = fetch_paginated_transactions(
        enable_banking_account,
        start_date: start_date,
        transaction_status: "BOOK",
        psu_headers: enable_banking_item.build_psu_headers
      )

      pending_transactions = []
      if include_pending
        # Also fetch pending transactions (visible for 1-3 days before they become BOOK) if setting is enabled
        pending_transactions = fetch_paginated_transactions(
          enable_banking_account,
          start_date: start_date,
          transaction_status: "PDNG",
          psu_headers: enable_banking_item.build_psu_headers
        )
      end

      book_ids = all_transactions
        .map { |tx| tx.with_indifferent_access[:transaction_id].presence }
        .compact.to_set

      book_entry_refs = all_transactions
        .select { |tx| tx.with_indifferent_access[:transaction_id].blank? }
        .map { |tx| tx.with_indifferent_access[:entry_reference].presence }
        .compact.to_set

      pending_transactions.reject! do |tx|
        tx = tx.with_indifferent_access
        tx[:transaction_id].present? ? book_ids.include?(tx[:transaction_id]) : book_entry_refs.include?(tx[:entry_reference].presence)
      end

      all_transactions = all_transactions + tag_as_pending(pending_transactions)

      # Deduplicate API response: Enable Banking sometimes returns the same logical
      # transaction with different entry_reference IDs in the same response.
      # Remove content-level duplicates before storing. (Issue #954)
      all_transactions = deduplicate_api_transactions(all_transactions)

      # Post-fetch safety filter: some ASPSPs ignore date_from or return extra transactions
      all_transactions = filter_transactions_by_date(all_transactions, start_date)

      transactions_count = all_transactions.count

      existing_transactions = enable_banking_account.raw_transactions_payload.to_a

      removed_pending = false

      unless include_pending
        removed_pending = existing_transactions.reject! do |tx|
          tx = tx.with_indifferent_access
          tx.dig(:extra, :enable_banking, :pending) || tx[:_pending]
        end
      end

      if all_transactions.any?

        # C4: Remove stored PDNG entries that have now settled as BOOK.
        # When a BOOK transaction arrives with the same transaction_id as a stored
        # PDNG entry, the pending entry is stale — drop it to avoid duplicates.
        book_ids = all_transactions
          .reject { |tx| tx.with_indifferent_access[:_pending] }
          .map { |tx| tx.with_indifferent_access[:transaction_id].presence }
          .compact.to_set

        # Fallback: collect entry_references for BOOK rows that have no transaction_id
        book_entry_refs = all_transactions
          .reject { |tx| tx.with_indifferent_access[:_pending] }
          .map { |tx| tx.with_indifferent_access[:entry_reference].presence }
          .compact.to_set

        if include_pending
          removed_pending ||= existing_transactions.reject! do |tx|
            tx = tx.with_indifferent_access
            pending_flag = tx.dig(:extra, :enable_banking, :pending) || tx[:_pending]
            next false unless pending_flag

            tx[:transaction_id].present? ?
              book_ids.include?(tx[:transaction_id]) :
              book_entry_refs.include?(tx[:entry_reference].presence)
          end
        end

        existing_ids = existing_transactions.map { |tx|
          tx = tx.with_indifferent_access
          tx[:transaction_id].presence || tx[:entry_reference].presence
        }.compact.to_set

        new_transactions = all_transactions.select do |tx|
          # Use transaction_id if present, otherwise fall back to entry_reference
          tx_id = tx[:transaction_id].presence || tx[:entry_reference].presence
          tx_id.present? && !existing_ids.include?(tx_id)
        end

        if new_transactions.any? || removed_pending
          enable_banking_account.upsert_enable_banking_transactions_snapshot!(existing_transactions + new_transactions)
        end
      elsif removed_pending
        enable_banking_account.upsert_enable_banking_transactions_snapshot!(
          existing_transactions
        )
      end

      { success: true, transactions_count: transactions_count }
    rescue Provider::EnableBanking::EnableBankingError => e
      Rails.logger.error "EnableBankingItem::Importer - Error fetching transactions for account #{enable_banking_account.uid}: #{e.message}"
      { success: false, transactions_count: 0, error: handle_sync_error(e) }
    rescue => e
      Rails.logger.error "EnableBankingItem::Importer - Unexpected error fetching transactions for account #{enable_banking_account.uid}: #{e.class} - #{e.message}"
      { success: false, transactions_count: 0, error: handle_sync_error(e) }
    end

    # Deduplicate transactions from the Enable Banking API response.
    # Some banks return the same logical transaction multiple times with different
    # entry_reference IDs. We build a composite content key that includes
    # transaction_id (when present) alongside date, amount, currency, creditor,
    # debtor, remittance_information, and status. Per the Enable Banking API docs
    # transaction_id is not guaranteed to be unique, so it cannot be used as
    # the sole dedup criterion. Including it in the composite key preserves
    # legitimately distinct transactions with identical content but different
    # transaction_ids (e.g. two laundromat payments on the same day). (Issue #954)
    def deduplicate_api_transactions(transactions)
      seen = {}
      duplicates_removed = 0

      result = transactions.select do |tx|
        tx = tx.with_indifferent_access
        key = build_transaction_content_key(tx)

        if seen[key]
          duplicates_removed += 1
          false
        else
          seen[key] = true
          true
        end
      end

      if duplicates_removed > 0
        Rails.logger.info(
          "EnableBankingItem::Importer - Removed #{duplicates_removed} content-level " \
          "duplicate(s) from API response (#{transactions.count} → #{result.count} transactions)"
        )
      end

      result
    end

    # Build a composite key for deduplication. Two transactions with different
    # entry_reference values but identical content fields (including
    # transaction_id and credit_debit_indicator) are considered duplicates.
    # transaction_id is included as one component — not a standalone key —
    # because the Enable Banking API docs state it is not guaranteed to be
    # unique. credit_debit_indicator (CRDT/DBIT) is included because
    # transaction_amount.amount is always positive — without it, a payment
    # and a same-day refund of the same amount would produce identical keys.
    # status (BOOK/PDNG) is intentionally excluded: the same logical transaction
    # may appear as PDNG then BOOK across imports and must not create duplicates.
    # Known limitation: when transaction_id is nil for both, pure content
    # comparison applies. This means two genuinely distinct transactions
    # with identical content (same date, amount, direction, creditor, etc.)
    # and no transaction_id would collapse to one. In practice, banks that
    # omit transaction_id rarely produce such exact duplicates in the same
    # API response; timestamps or remittance info usually differ. (Issue #954)
    def build_transaction_content_key(tx)
      date = tx[:booking_date].presence || tx[:value_date]
      amount = tx.dig(:transaction_amount, :amount).presence || tx[:amount]
      currency = tx.dig(:transaction_amount, :currency).presence || tx[:currency]
      creditor = tx.dig(:creditor, :name).presence || tx[:creditor_name]
      debtor = tx.dig(:debtor, :name).presence || tx[:debtor_name]
      remittance = tx[:remittance_information]
      remittance_key = remittance.is_a?(Array) ? remittance.compact.map(&:to_s).sort.join("|") : remittance.to_s
      tid = tx[:transaction_id]
      direction = tx[:credit_debit_indicator]

      [ date, amount, currency, creditor, debtor, remittance_key, tid, direction ].map(&:to_s).join("\x1F")
    end

    class PaginationTruncatedError < StandardError; end

    def fetch_paginated_transactions(enable_banking_account, start_date:, transaction_status:, psu_headers: {})
      all_transactions = []
      continuation_key = nil
      previous_continuation_key = nil
      page_count = 0

      loop do
        page_count += 1

        if page_count > MAX_PAGINATION_PAGES
          msg = "EnableBankingItem::Importer - Pagination limit exceeded for account #{enable_banking_account.uid} (status=#{transaction_status}). Stopped after #{MAX_PAGINATION_PAGES} pages."
          raise PaginationTruncatedError, msg
        end

        transactions_data = enable_banking_provider.get_account_transactions(
          account_id: enable_banking_account.api_account_id,
          date_from: start_date,
          continuation_key: continuation_key,
          transaction_status: transaction_status,
          psu_headers: psu_headers
        )

        transactions = transactions_data[:transactions] || []
        all_transactions.concat(transactions)

        previous_continuation_key = continuation_key
        continuation_key = transactions_data[:continuation_key]

        if continuation_key.present? && continuation_key == previous_continuation_key
          msg = "EnableBankingItem::Importer - Repeated continuation_key detected for account #{enable_banking_account.uid} (status=#{transaction_status}). Breaking after #{page_count} pages."
          raise PaginationTruncatedError, msg
        end

        break if continuation_key.blank?
      end

      all_transactions
    rescue PaginationTruncatedError => e
      # Log as warning and return collected partial data instead of failing entirely.
      # This ensures accounts with huge history don't lose all synced data.
      Rails.logger.warn(e.message)
      all_transactions
    end

    def filter_transactions_by_date(transactions, start_date)
      return transactions unless start_date

      transactions.reject do |tx|
        tx = tx.with_indifferent_access
        date_str = tx[:booking_date] || tx[:value_date] || tx[:transaction_date]
        next false if date_str.blank?  # Keep if no date (cannot determine)

        begin
          Date.parse(date_str.to_s) < start_date
        rescue ArgumentError
          false  # Keep if date is unparseable
        end
      end
    end

    def tag_as_pending(transactions)
      transactions.map { |tx| tx.merge(_pending: true) }
    end

    def find_enable_banking_account_by_hash(hash_value)
      return nil if hash_value.blank?

      # First: exact uid match (primary identification_hash)
      account = enable_banking_item.enable_banking_accounts.find_by(uid: hash_value.to_s)
      return account if account

      # Second: search in identification_hashes array (PostgreSQL JSONB contains operator)
      enable_banking_item.enable_banking_accounts
        .where("identification_hashes @> ?", [ hash_value.to_s ].to_json)
        .first
    end

    def sync_uids_from_accounts_data(accounts_data)
      return if accounts_data.blank?

      accounts_data.each do |ad|
        next unless ad.is_a?(Hash)
        ad = ad.with_indifferent_access
        identification_hash = ad[:identification_hash]
        current_uid = ad[:uid]
        next if identification_hash.blank? || current_uid.blank?

        eb_acc = find_enable_banking_account_by_hash(identification_hash)
        next unless eb_acc
        # Update the API account_id (UUID) if it has changed (UIDs are session-scoped)
        eb_acc.update!(account_id: current_uid) if eb_acc.account_id != current_uid
      end
    end

    def determine_sync_start_date(enable_banking_account)
      has_stored_transactions = enable_banking_account.raw_transactions_payload.to_a.any?

      # Use user-configured sync_start_date if set, otherwise default
      user_start_date = enable_banking_item.sync_start_date

      if has_stored_transactions
        # For incremental syncs, get transactions from 7 days before last sync
        if enable_banking_item.last_synced_at
          enable_banking_item.last_synced_at.to_date - 7.days
        else
          30.days.ago.to_date
        end
      else
        # Initial sync: use user's configured date or default to 3 months
        user_start_date || 3.months.ago.to_date
      end
    end
end
