class QifImport < Import
  after_create :set_default_config

  # The date format used to parse the raw QIF file's D-fields (e.g. "%m/%d/%Y").
  # Stored in column_mappings so it doesn't conflict with date_format, which is
  # always "%Y-%m-%d" because QIF rows store dates in ISO 8601 after parsing.
  def qif_date_format
    column_mappings&.dig("qif_date_format") || "%m/%d/%Y"
  end

  def qif_date_format=(fmt)
    self.column_mappings = (column_mappings || {}).merge("qif_date_format" => fmt)
  end

  # Parses the stored QIF content and creates Import::Row records.
  # Overrides the base CSV-based method with QIF-specific parsing.
  #
  # On first run (qif_date_format not yet set), auto-detects the date format
  # from the QIF file's D-field samples.
  def generate_rows_from_csv
    detect_and_set_qif_date_format! unless column_mappings&.key?("qif_date_format")

    rows.destroy_all

    if investment_account?
      generate_investment_rows
    else
      generate_transaction_rows
    end

    update_column(:rows_count, rows.count)
  end

  def import!
    transaction do
      mappings.each(&:create_mappable!)

      if investment_account?
        import_investment_rows!
      else
        import_transaction_rows!

        if (ob = QifParser.parse_opening_balance(raw_file_str, date_format: qif_date_format))
          Account::OpeningBalanceManager.new(account).set_opening_balance(
            balance: ob[:amount],
            date:    ob[:date]
          )
        else
          adjust_opening_anchor_if_needed!
        end
      end
    end
  end

  # QIF has a fixed format – no CSV column mapping step needed.
  def requires_csv_workflow?
    false
  end

  def rows_ordered
    rows.order(date: :desc, id: :desc)
  end

  def column_keys
    if qif_account_type == "Invst"
      %i[date ticker qty price amount currency name]
    else
      %i[date amount name currency category tags notes]
    end
  end

  def publishable?
    account.present? && super
  end

  # Returns true if import! will move the opening anchor back to cover transactions
  # that predate the current anchor date. Used to show a notice in the confirm step.
  def will_adjust_opening_anchor?
    return false if investment_account?
    return false if QifParser.parse_opening_balance(raw_file_str, date_format: qif_date_format).present?
    return false unless account.present?

    manager = Account::OpeningBalanceManager.new(account)
    return false unless manager.has_opening_anchor?

    earliest = earliest_row_date
    earliest.present? && earliest < manager.opening_date
  end

  # The date the opening anchor will be moved to when will_adjust_opening_anchor? is true.
  def adjusted_opening_anchor_date
    earliest = earliest_row_date
    (earliest - 1.day) if earliest.present?
  end

  # The account type declared in the QIF file (e.g. "CCard", "Bank", "Invst").
  def qif_account_type
    return @qif_account_type if instance_variable_defined?(:@qif_account_type)
    @qif_account_type = raw_file_str.present? ? QifParser.account_type(raw_file_str) : nil
  end

  # Unique categories used across all rows (blank entries excluded).
  def row_categories
    rows.distinct.pluck(:category).reject(&:blank?).sort
  end

  # Returns true if the QIF file contains any split transactions.
  def has_split_transactions?
    return @has_split_transactions if defined?(@has_split_transactions)
    @has_split_transactions = parsed_transactions_with_splits.any?(&:split)
  end

  # Categories that appear on split transactions in the QIF file.
  # Split transactions use S/$ fields to break a total into sub-amounts;
  # the app does not yet support splits, so these categories are flagged.
  def split_categories
    return @split_categories if defined?(@split_categories)

    split_cats = parsed_transactions_with_splits.select(&:split).map(&:category).reject(&:blank?).uniq.sort
    @split_categories = split_cats & row_categories
  end

  # Unique tags used across all rows (blank entries excluded).
  def row_tags
    rows.flat_map(&:tags_list).uniq.reject(&:blank?).sort
  end

  # True once the category/tag selection step has been completed
  # (sync_mappings has been called, which always produces at least one mapping).
  def categories_selected?
    mappings.any?
  end

  def mapping_steps
    [ Import::CategoryMapping, Import::TagMapping ]
  end

  # QIF dates need normalization (apostrophe → separator, 2-digit year expansion)
  # before strptime can parse them, so we delegate to QifParser.
  def raw_date_samples
    QifParser.extract_raw_dates(raw_file_str)
  end

  def try_parse_date_sample(sample, format:)
    QifParser.try_parse_date(sample, date_format: format)
  end

  private

    def parsed_transactions_with_splits
      @parsed_transactions_with_splits ||= QifParser.parse(raw_file_str)
    end

    def investment_account?
      qif_account_type == "Invst"
    end

    # ------------------------------------------------------------------
    # Row generation
    # ------------------------------------------------------------------

    def generate_transaction_rows
      transactions = QifParser.parse(raw_file_str, date_format: qif_date_format)

      mapped_rows = transactions.map do |trn|
        {
          date:                   trn.date.to_s,
          amount:                 trn.amount.to_s,
          currency:               default_currency.to_s,
          name:                   (trn.payee.presence || default_row_name).to_s,
          notes:                  trn.memo.to_s,
          category:               trn.category.to_s,
          tags:                   trn.tags.join("|"),
          account:                "",
          qty:                    "",
          ticker:                 "",
          price:                  "",
          exchange_operating_mic: "",
          entity_type:            ""
        }
      end

      if mapped_rows.any?
        rows.insert_all!(mapped_rows)
        rows.reset
      end
    end

    def generate_investment_rows
      inv_transactions = QifParser.parse_investment_transactions(raw_file_str, date_format: qif_date_format)

      mapped_rows = inv_transactions.map do |trn|
        if QifParser::TRADE_ACTIONS.include?(trn.action)
          qty = trade_qty_for(trn.action, trn.qty)

          {
            date:                   trn.date.to_s,
            ticker:                 trn.security_ticker.to_s,
            qty:                    qty.to_s,
            price:                  trn.price.to_s,
            amount:                 trn.amount.to_s,
            currency:               default_currency.to_s,
            name:                   trade_row_name(trn),
            notes:                  trn.memo.to_s,
            category:               "",
            tags:                   "",
            account:                "",
            exchange_operating_mic: "",
            entity_type:            trn.action
          }
        else
          {
            date:                   trn.date.to_s,
            amount:                 trn.amount.to_s,
            currency:               default_currency.to_s,
            name:                   transaction_row_name(trn),
            notes:                  trn.memo.to_s,
            category:               trn.category.to_s,
            tags:                   trn.tags.join("|"),
            account:                "",
            qty:                    "",
            ticker:                 "",
            price:                  "",
            exchange_operating_mic: "",
            entity_type:            trn.action
          }
        end
      end

      if mapped_rows.any?
        rows.insert_all!(mapped_rows)
        rows.reset
      end
    end

    # ------------------------------------------------------------------
    # Import execution
    # ------------------------------------------------------------------

    def import_transaction_rows!
      transactions = rows.map do |row|
        category = mappings.categories.mappable_for(row.category)
        tags     = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact

        Transaction.new(
          category: category,
          tags:     tags,
          entry:    Entry.new(
            account:      account,
            date:         row.date_iso,
            amount:       row.signed_amount,
            name:         row.name,
            currency:     row.currency,
            notes:        row.notes,
            import:       self,
            import_locked: true
          )
        )
      end

      Transaction.import!(transactions, recursive: true)
    end

    def import_investment_rows!
      trade_rows       = rows.select { |r| QifParser::TRADE_ACTIONS.include?(r.entity_type) }
      transaction_rows = rows.reject { |r| QifParser::TRADE_ACTIONS.include?(r.entity_type) }

      if trade_rows.any?
        trades = trade_rows.map do |row|
          security = find_or_create_security(ticker: row.ticker)

          # Use the stored T-field amount for accuracy (includes any fees/commissions).
          # Buy-like actions are cash outflows (positive); sell-like are inflows (negative).
          entry_amount = QifParser::BUY_LIKE_ACTIONS.include?(row.entity_type) ? row.amount.to_d : -row.amount.to_d

          Trade.new(
            security:                  security,
            qty:                       row.qty.to_d,
            price:                     row.price.to_d,
            currency:                  row.currency,
            investment_activity_label: investment_activity_label_for(row.entity_type),
            entry:                     Entry.new(
              account:      account,
              date:         row.date_iso,
              amount:       entry_amount,
              name:         row.name,
              currency:     row.currency,
              import:       self,
              import_locked: true
            )
          )
        end

        Trade.import!(trades, recursive: true)
      end

      if transaction_rows.any?
        transactions = transaction_rows.map do |row|
          # Inflow actions: money entering account → negative Entry.amount
          # Outflow actions: money leaving account → positive Entry.amount
          entry_amount = QifParser::INFLOW_TRANSACTION_ACTIONS.include?(row.entity_type) ? -row.amount.to_d : row.amount.to_d

          category = mappings.categories.mappable_for(row.category)
          tags     = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact

          Transaction.new(
            category: category,
            tags:     tags,
            entry:    Entry.new(
              account:      account,
              date:         row.date_iso,
              amount:       entry_amount,
              name:         row.name,
              currency:     row.currency,
              notes:        row.notes,
              import:       self,
              import_locked: true
            )
          )
        end

        Transaction.import!(transactions, recursive: true)
      end
    end

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def adjust_opening_anchor_if_needed!
      manager = Account::OpeningBalanceManager.new(account)
      return unless manager.has_opening_anchor?

      earliest = earliest_row_date
      return unless earliest.present? && earliest < manager.opening_date

      Account::OpeningBalanceManager.new(account).set_opening_balance(
        balance: manager.opening_balance,
        date:    earliest - 1.day
      )
    end

    def earliest_row_date
      str = rows.minimum(:date)
      Date.parse(str) if str.present?
    end

    def set_default_config
      update!(
        signage_convention: "inflows_positive",
        date_format:        "%Y-%m-%d",
        number_format:      "1,234.56"
      )
    end

    # Auto-detects the QIF file's date format from D-field samples and persists it.
    # Falls back to "%m/%d/%Y" (US convention) if detection is inconclusive.
    def detect_and_set_qif_date_format!
      samples = QifParser.extract_raw_dates(raw_file_str)
      detected = Import.detect_date_format(samples, fallback: "%m/%d/%Y")
      self.qif_date_format = detected
      update_column(:column_mappings, column_mappings)
    end

    # Returns the signed qty for a trade row:
    # buy-like actions keep qty positive; sell-like negate it.
    def trade_qty_for(action, raw_qty)
      qty = raw_qty.to_d
      QifParser::SELL_LIKE_ACTIONS.include?(action) ? -qty : qty
    end

    def investment_activity_label_for(action)
      return nil if action.blank?
      QifParser::BUY_LIKE_ACTIONS.include?(action) ? "Buy" : "Sell"
    end

    def trade_row_name(trn)
      type   = QifParser::BUY_LIKE_ACTIONS.include?(trn.action) ? "buy" : "sell"
      ticker = trn.security_ticker.presence || trn.security_name || "Unknown"
      Trade.build_name(type, trn.qty.to_d.abs, ticker)
    end

    def transaction_row_name(trn)
      security = trn.security_name.presence
      payee    = trn.payee.presence

      case trn.action
      when "Div"     then payee || (security ? "Dividend: #{security}" : "Dividend")
      when "IntInc"  then payee || (security ? "Interest: #{security}" : "Interest")
      when "XIn"     then payee || "Cash Transfer In"
      when "XOut"    then payee || "Cash Transfer Out"
      when "CGLong"  then payee || (security ? "Capital Gain (Long): #{security}" : "Capital Gain (Long)")
      when "CGShort" then payee || (security ? "Capital Gain (Short): #{security}" : "Capital Gain (Short)")
      when "MiscInc" then payee || trn.memo.presence || "Miscellaneous Income"
      when "MiscExp" then payee || trn.memo.presence || "Miscellaneous Expense"
      else                payee || trn.action
      end
    end

    def find_or_create_security(ticker: nil, exchange_operating_mic: nil)
      return nil unless ticker.present?

      @security_cache ||= {}

      cache_key = [ ticker, exchange_operating_mic ].compact.join(":")
      security  = @security_cache[cache_key]
      return security if security.present?

      security = Security::Resolver.new(
        ticker,
        exchange_operating_mic: exchange_operating_mic.presence
      ).resolve

      @security_cache[cache_key] = security
      security
    end
end
