class Security::Resolver
  def initialize(symbol, exchange_operating_mic: nil, country_code: nil, price_provider: nil)
    @symbol = validate_symbol!(symbol)
    @exchange_operating_mic = exchange_operating_mic
    @country_code = country_code
    @price_provider = validated_price_provider(price_provider)
  end

  # Attempts several paths to resolve a security:
  # 1. Exact match in DB
  # 2. Search provider for an exact match
  # 3. Search provider for close match, ranked by relevance
  # 4. Create offline security if no match is found in either DB or provider
  def resolve
    return nil if symbol.blank?

    exact_match_from_db ||
      exact_match_from_provider ||
      close_match_from_provider ||
      offline_security
  end

  private
    attr_reader :symbol, :exchange_operating_mic, :country_code, :price_provider

    def validate_symbol!(symbol)
      raise ArgumentError, "Symbol is required and cannot be blank" if symbol.blank?
      symbol.strip.upcase
    end

    # Only accept price_provider values that are known and currently enabled.
    # Prevents tampered combobox values from persisting invalid provider names.
    def validated_price_provider(value)
      return nil if value.blank?
      return nil unless Security.valid_price_providers.include?(value.to_s)
      return nil unless Setting.enabled_securities_providers.include?(value.to_s)
      value.to_s
    end

    def offline_security
      security = Security.find_or_initialize_by(
        ticker: symbol,
        exchange_operating_mic: exchange_operating_mic,
      )

      security.assign_attributes(
        country_code: country_code,
        offline: true # This tells us that we shouldn't try to fetch prices later
      )

      security.save!

      security
    end

    def exact_match_from_db
      security = Security.find_by(
        {
          ticker: symbol,
          exchange_operating_mic: exchange_operating_mic,
          country_code: country_code.presence
        }.compact
      )

      return nil unless security

      # When the caller provides an explicit provider (e.g. user selected from
      # search results), honor that choice. Automated syncs (Plaid, SimpleFIN)
      # pass price_provider: nil and will not overwrite.
      if price_provider.present? && security.price_provider != price_provider
        security.update!(price_provider: price_provider)
      end

      reactivate_if_provider_available!(security)

      security
    end

    # If provided a ticker + exchange (and optionally, a country code), we can find exact matches
    def exact_match_from_provider
      # Without an exchange, we can never know if we have an exact match
      return nil unless exchange_operating_mic.present?

      match = provider_search_result.find do |s|
        ticker_matches = s.ticker&.upcase.to_s == symbol.upcase.to_s
        exchange_matches = s.exchange_operating_mic&.upcase.to_s == exchange_operating_mic.upcase.to_s

        if country_code && exchange_operating_mic
          ticker_matches && exchange_matches && country_matches?(s.country_code)
        else
          ticker_matches && exchange_matches
        end
      end

      return nil unless match

      find_or_create_provider_match!(match)
    end

    def close_match_from_provider
      filtered_candidates = provider_search_result

      # If a country code is specified, we MUST find a match with the same code
      # — but nil candidate country is treated as a wildcard (e.g. crypto from
      # Binance, which isn't tied to a jurisdiction).
      if country_code.present?
        filtered_candidates = filtered_candidates.select { |s| country_matches?(s.country_code) }
      end

      # 1. Prefer exact ticker matches (MSTR before MSTRX when searching for "MSTR")
      # 2. Prefer exact exchange_operating_mic matches (if one was provided)
      # 3. Rank by country relevance (lower index in the list is more relevant)
      # 4. Rank by exchange_operating_mic relevance (lower index in the list is more relevant)
      sorted_candidates = filtered_candidates.sort_by do |s|
        [
          s.ticker&.upcase.to_s == symbol.upcase.to_s ? 0 : 1,
          exchange_operating_mic.present? && s.exchange_operating_mic&.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1,
          sorted_country_codes_by_relevance.index(s.country_code&.upcase.to_s) || sorted_country_codes_by_relevance.length,
          sorted_exchange_operating_mics_by_relevance.index(s.exchange_operating_mic&.upcase.to_s) || sorted_exchange_operating_mics_by_relevance.length
        ]
      end

      match = sorted_candidates.first

      return nil unless match

      find_or_create_provider_match!(match)
    end

    def find_or_create_provider_match!(match)
      security = Security.find_or_initialize_by(
        ticker: match.ticker,
        exchange_operating_mic: match.exchange_operating_mic,
      )

      security.country_code = match.country_code

      # Set provider when explicitly provided (user selection) or when the
      # record is new / has no provider yet. Automated syncs pass nil and
      # will not overwrite an existing choice.
      effective_provider = price_provider.presence ||
        (match.respond_to?(:price_provider) ? match.price_provider.presence : nil)

      if effective_provider.present?
        security.price_provider = effective_provider
      end

      security.save!

      reactivate_if_provider_available!(security)

      security
    end

    # If a security was marked offline (e.g. its provider was temporarily
    # removed in settings) but now has a valid, enabled provider, bring it
    # back online so the MarketDataImporter picks it up again.
    def reactivate_if_provider_available!(security)
      return unless security.offline?
      return unless security.offline_reason == "provider_disabled"
      return unless security.price_data_provider.present?

      security.update!(offline: false, offline_reason: nil, failed_fetch_count: 0, failed_fetch_at: nil)
    end

    # Candidate country matches when it equals the resolver's country OR when
    # the provider didn't report a country at all (e.g. crypto from Binance).
    # A nil candidate country is a legitimate "no jurisdiction" signal, not a
    # missing field, so we trust the user's provider + exchange pick.
    def country_matches?(candidate_country)
      return true if candidate_country.blank?
      candidate_country.upcase == country_code.upcase
    end

    def provider_search_result
      params = {
        exchange_operating_mic: exchange_operating_mic,
        country_code: country_code
      }.compact_blank

      @provider_search_result ||= Security.search_provider(symbol, **params)
    end

    # Non-exhaustive list of common country codes for help in choosing "close" matches
    # User's country (if provided) is prioritized first, then sorted by market cap.
    def sorted_country_codes_by_relevance
      base_order = %w[US CN JP IN GB CA FR DE CH SA TW AU NL SE KR IE ES AE IT HK BR DK SG MX RU IL ID BE TH NO]

      # Prioritize user's country if provided
      if country_code.present?
        user_country = country_code.upcase
        [ user_country ] + (base_order - [ user_country ])
      else
        base_order
      end
    end

    # Non-exhaustive list of common exchange operating MICs for help in choosing "close" matches
    # This is very US-centric since our prices provider and user base is a majority US-based
    def sorted_exchange_operating_mics_by_relevance
      [
        "XNYS",  # New York Stock Exchange
        "XNAS",  # NASDAQ Stock Market
        "XOTC",  # OTC Markets Group (OTC Link)
        "OTCM",  # OTC Markets Group
        "OTCN",  # OTC Bulletin Board
        "OTCI",  # OTC International
        "OPRA",  # Options Price Reporting Authority
        "MEMX",  # Members Exchange
        "IEXA",  # IEX All-Market
        "IEXG",  # IEX Growth Market
        "EDXM",  # Cboe EDGX Exchange (Equities)
        "XCME",  # CME Group (Derivatives)
        "XCBT",  # Chicago Board of Trade
        "XPUS",  # Nasdaq PSX (U.S.)
        "XPSE",  # Nasdaq PHLX (U.S.)
        "XTRD",  # Nasdaq TRF (Trade Reporting Facility)
        "XTXD",  # FINRA TRACE (Trade Reporting)
        "XARC",  # NYSE Arca
        "XBOX",  # BOX Options Exchange
        "XBXO"  # BZX Options (Cboe)
      ]
    end
end
