# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

require 'singleton'
require 'new_relic/agent/database/explain_plan_helpers'
require 'new_relic/agent/database/obfuscator'

module NewRelic
  module Agent
    module Database
      MAX_QUERY_LENGTH = 16384
      ELLIPSIS = '...'.freeze

      extend self

      # Properly encode, truncate, and dup the incoming query.
      # Take care not to the dup the query more than once as
      # correctly encoded may also dup the query.
      def capture_query(query)
        return unless query

        id = query.object_id
        query = Helper.correctly_encoded(truncate_query(query))
        if query.object_id == id
          query.dup
        else
          query
        end
      end

      def truncate_query(query)
        return unless query

        if query.length > (MAX_QUERY_LENGTH - 4)
          query[0..MAX_QUERY_LENGTH - 4] << ELLIPSIS
        else
          query
        end
      end

      def obfuscate_sql(sql)
        Obfuscator.instance.obfuscator.call(sql)
      end

      def set_sql_obfuscator(type, &block)
        Obfuscator.instance.set_sql_obfuscator(type, &block)
      end

      def record_sql_method(config_section = :transaction_tracer)
        key = record_sql_method_key(config_section)

        case Agent.config[key].to_s
        when 'off'
          :off
        when 'none'
          :off
        when 'false'
          :off
        when 'raw'
          :raw
        else
          :obfuscated
        end
      end

      def record_sql_method_key(config_section)
        case config_section
        when :transaction_tracer
          :'transaction_tracer.record_sql'
        when :slow_sql
          :'slow_sql.record_sql'
        else
          "#{config_section}.record_sql".to_sym
        end
      end

      RECORD_FOR = [:raw, :obfuscated].freeze

      def should_record_sql?(config_section = :transaction_tracer)
        RECORD_FOR.include?(record_sql_method(config_section))
      end

      def should_collect_explain_plans?(config_section = :transaction_tracer)
        should_record_sql?(config_section) &&
          Agent.config["#{config_section}.explain_enabled".to_sym]
      end

      def get_connection(config, &connector)
        ConnectionManager.instance.get_connection(config, &connector)
      end

      def explain_this(statement, use_execute = false)
        if supports_with_connection?
          explain_this_using_with_connection(statement)
        else
          explain_this_using_adapter_connection(statement, use_execute)
        end
      rescue => e
        NewRelic::Agent.logger.error("Couldn't fetch the explain plan for statement: #{e}")
      end

      def explain_this_using_with_connection(statement)
        ::ActiveRecord::Base.with_connection do |conn|
          conn.exec_query("EXPLAIN #{statement.sql}", "Explain #{statement.name}", statement.binds)
        end
      end

      def explain_this_using_adapter_connection(statement, use_execute)
        connection = get_connection(statement.config) do
          ::ActiveRecord::Base.send(:"#{statement.config[:adapter]}_connection", statement.config)
        end

        if use_execute
          connection.execute("EXPLAIN #{statement.sql}")
        else
          connection.exec_query("EXPLAIN #{statement.sql}", "Explain #{statement.name}", statement.binds)
        end
      end

      # ActiveRecord v7.2.0 introduced with_connection
      def supports_with_connection?
        return @supports_with_connection if defined?(@supports_with_connection)

        @supports_with_connection = defined?(::ActiveRecord::VERSION::STRING) &&
          NewRelic::Helper.version_satisfied?(ActiveRecord::VERSION::STRING, '>=', '7.2.0')
      end

      def close_connections
        ConnectionManager.instance.close_connections
      end

      # Perform this in the runtime environment of a managed
      # application, to explain the sql statement executed within a
      # node of a transaction sample. Returns an array of two arrays.
      # The first array contains the headers, while the second consists of
      # arrays of strings for each column returned by the explain query.
      # Note this happens only for statements whose execution time exceeds
      # a threshold (e.g. 500ms) and only within the slowest transaction
      # in a report period, selected for shipment to New Relic
      def explain_sql(statement)
        return nil unless statement.sql && statement.explainer && statement.config

        statement.sql = statement.sql.split(";\n")[0] # only explain the first
        return statement.explain || []
      end

      KNOWN_OPERATIONS = %w[
        alter
        select
        update
        delete
        insert
        create
        show
        set
        exec
        execute
        call
      ]
      OTHER_OPERATION = 'other'.freeze
      SQL_COMMENT_REGEX = Regexp.new('/\*.*?\*/', Regexp::MULTILINE).freeze

      def parse_operation_from_query(sql)
        sql = Helper.correctly_encoded(sql).gsub(SQL_COMMENT_REGEX, NewRelic::EMPTY_STR)
        return unless sql =~ /(\w+)/

        op = Regexp.last_match(1).downcase
        KNOWN_OPERATIONS.include?(op) ? op : OTHER_OPERATION
      end

      class ConnectionManager
        include Singleton

        # Returns a cached connection for a given ActiveRecord
        # configuration - these are stored or reopened as needed, and if
        # we cannot get one, we ignore it and move on without explaining
        # the sql
        def get_connection(config, &connector)
          @connections ||= {}

          connection = @connections[config]

          return connection if connection

          begin
            @connections[config] = yield(config)
          rescue => e
            ::NewRelic::Agent.logger.error('Caught exception trying to get connection to DB for explain.', e)
            nil
          end
        end

        # Closes all the connections in the internal connection cache
        def close_connections
          @connections ||= {}
          @connections.values.each do |connection|
            begin
              connection.disconnect!
            rescue
            end
          end

          @connections = {}
        end
      end

      class Statement
        include ExplainPlanHelpers

        attr_accessor :sql, :config, :explainer, :binds, :name, :host, :port_path_or_id, :database_name

        DEFAULT_QUERY_NAME = 'SQL'.freeze

        def initialize(sql, config = {}, explainer = nil, binds = nil, name = DEFAULT_QUERY_NAME, host = nil, port_path_or_id = nil, database_name = nil)
          @sql = Database.capture_query(sql)
          @config = config
          @explainer = explainer
          @binds = binds
          @name = name
          @host = host
          @port_path_or_id = port_path_or_id
          @database_name = database_name
          @safe_sql = nil
        end

        # Returns an sql statement that will be in the form most permissable by
        # the config. The format will be safe for transmission to New Relic.
        def safe_sql
          @safe_sql ||= case Database.record_sql_method
            when :obfuscated
              Database.obfuscate_sql(self)
            when :raw
              sql.to_s
          end
        end

        # This takes a connection config hash from ActiveRecord or Sequel and
        # returns a symbol describing the associated database adapter
        def adapter
          return unless @config

          @adapter ||= if @config[:adapter]
            symbolized_adapter(@config[:adapter].to_s.downcase)
          elsif @config[:uri] && @config[:uri].to_s =~ /^jdbc:([^:]+):/
            # This case is for Sequel with the jdbc-mysql, jdbc-postgres, or jdbc-sqlite3 gems.
            symbolized_adapter($1)
          end
        end

        def explain
          return unless explainable?

          handle_exception_in_explain do
            start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
            plan = @explainer.call(self)
            ::NewRelic::Agent.record_metric(
              'Supportability/Database/execute_explain_plan',
              Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
            )
            return process_resultset(plan, adapter) if plan
          end
        end

        NEWLINE = "\n".freeze

        def append_sql(new_sql)
          return if new_sql.empty?

          @sql = Database.truncate_query(@sql << NEWLINE << new_sql)
        end

        private

        POSTGIS_PREFIX = 'postgis'.freeze
        POSTGRES_PREFIX = 'postgres'.freeze
        MYSQL_PREFIX = 'mysql'.freeze
        MYSQL2_PREFIX = 'mysql2'.freeze
        SQLITE_PREFIX = 'sqlite'.freeze
        TRILOGY_PREFIX = 'trilogy'.freeze
        REDSHIFT_PREFIX = 'redshift'.freeze

        def symbolized_adapter(adapter)
          if adapter.start_with?(POSTGRES_PREFIX) || adapter == POSTGIS_PREFIX || adapter == REDSHIFT_PREFIX
            :postgres
          elsif adapter == MYSQL_PREFIX
            :mysql
          # For the purpose of fetching explain plans, we need to maintain the distinction
          # between usage of mysql and mysql2. Obfuscation is the same, though.
          elsif adapter == MYSQL2_PREFIX
            :mysql2
          elsif adapter.start_with?(SQLITE_PREFIX)
            :sqlite
          elsif adapter == TRILOGY_PREFIX
            :trilogy
          else
            adapter.to_sym
          end
        end

        def explainable?
          return false unless @explainer && is_select?(@sql)

          if @sql.end_with?(ELLIPSIS)
            NewRelic::Agent.logger.debug('Unable to collect explain plan for truncated query.')
            return false
          end

          if parameterized?(@sql) && (!@binds || @binds.empty?)
            NewRelic::Agent.logger.debug('Unable to collect explain plan for parameter-less parameterized query.')
            return false
          end

          if !SUPPORTED_ADAPTERS_FOR_EXPLAIN.include?(adapter)
            NewRelic::Agent.logger.debug("Not collecting explain plan because an unknown connection adapter ('#{adapter}') was used.")
            return false
          end

          if multiple_queries?(@sql)
            NewRelic::Agent.logger.debug('Unable to collect explain plan for multiple queries.')
            return false
          end

          true
        end
      end
    end
  end
end
