# frozen_string_literal: true

require "delegate"

begin
  require "oci8"
rescue LoadError => e
  # OCI8 driver is unavailable or failed to load a required library.
  raise LoadError, "ERROR: '#{e.message}'. "\
    "ActiveRecord oracle_enhanced adapter could not load ruby-oci8 library. "\
    "You may need install ruby-oci8 gem."
end

# check ruby-oci8 version
required_oci8_version = [2, 2, 4]
oci8_version_ints = OCI8::VERSION.scan(/\d+/).map { |s| s.to_i }
if (oci8_version_ints <=> required_oci8_version) < 0
  $stderr.puts <<-EOS.strip_heredoc
    "ERROR: ruby-oci8 version #{OCI8::VERSION} is too old. Please install ruby-oci8 version #{required_oci8_version.join('.')} or later."
  EOS

  exit!
end

module ActiveRecord
  module ConnectionAdapters
    # OCI database interface for MRI
    module OracleEnhanced
      class OCIConnection < OracleEnhanced::Connection #:nodoc:
        def initialize(config)
          @raw_connection = OCI8EnhancedAutoRecover.new(config, OracleEnhancedOCIFactory)
          # default schema owner
          @owner = config[:schema]
          @owner ||= config[:username]
          @owner = @owner.to_s.upcase
        end

        def raw_oci_connection
          if @raw_connection.is_a? OCI8
            @raw_connection
          # ActiveRecord Oracle enhanced adapter puts OCI8EnhancedAutoRecover wrapper around OCI8
          # in this case we need to pass original OCI8 connection
          else
            @raw_connection.instance_variable_get(:@connection)
          end
        end

        def auto_retry
          @raw_connection.auto_retry if @raw_connection
        end

        def auto_retry=(value)
          @raw_connection.auto_retry = value if @raw_connection
        end

        def logoff
          @raw_connection.logoff
          @raw_connection.active = false
        end

        def commit
          @raw_connection.commit
        end

        def rollback
          @raw_connection.rollback
        end

        def autocommit?
          @raw_connection.autocommit?
        end

        def autocommit=(value)
          @raw_connection.autocommit = value
        end

        # Checks connection, returns true if active. Note that ping actively
        # checks the connection, while #active? simply returns the last
        # known state.
        def ping
          @raw_connection.ping
        rescue OCIException => e
          raise OracleEnhanced::ConnectionException, e.message
        end

        def active?
          @raw_connection.active?
        end

        def reset!
          @raw_connection.reset!
        rescue OCIException => e
          raise OracleEnhanced::ConnectionException, e.message
        end

        def exec(sql, *bindvars, &block)
          @raw_connection.exec(sql, *bindvars, &block)
        end

        def prepare(sql)
          Cursor.new(self, @raw_connection.parse(sql))
        end

        class Cursor
          def initialize(connection, raw_cursor)
            @connection = connection
            @raw_cursor = raw_cursor
          end

          def bind_params(*bind_vars)
            index = 1
            bind_vars.flatten.each do |var|
              if Hash === var
                var.each { |key, val| bind_param key, val }
              else
                bind_param index, var
                index += 1
              end
            end
          end

          def bind_param(position, value)
            case value
            when Type::OracleEnhanced::Raw
              @raw_cursor.bind_param(position, OracleEnhanced::Quoting.encode_raw(value))
            when ActiveModel::Type::Decimal
              @raw_cursor.bind_param(position, BigDecimal(value.to_s))
            when NilClass
              @raw_cursor.bind_param(position, nil, String)
            else
              @raw_cursor.bind_param(position, value)
            end
          end

          def bind_returning_param(position, bind_type)
            @raw_cursor.bind_param(position, nil, bind_type)
          end

          def exec
            @raw_cursor.exec
          end

          def exec_update
            @raw_cursor.exec
          end

          def get_col_names
            @raw_cursor.get_col_names
          end

          def fetch(options = {})
            if row = @raw_cursor.fetch
              get_lob_value = options[:get_lob_value]
              row.map do |col|
                @connection.typecast_result_value(col, get_lob_value)
              end
            end
          end

          def get_returning_param(position, type)
            @raw_cursor[position]
          end

          def close
            @raw_cursor.close
          end
        end

        def select(sql, name = nil, return_column_names = false)
          cursor = @raw_connection.exec(sql)
          cols = []
          # Ignore raw_rnum_ which is used to simulate LIMIT and OFFSET
          cursor.get_col_names.each do |col_name|
            col_name = _oracle_downcase(col_name)
            cols << col_name unless col_name == "raw_rnum_"
          end
          # Reuse the same hash for all rows
          column_hash = {}
          cols.each { |c| column_hash[c] = nil }
          rows = []
          get_lob_value = !(name == "Writable Large Object")

          while row = cursor.fetch
            hash = column_hash.dup

            cols.each_with_index do |col, i|
              hash[col] = typecast_result_value(row[i], get_lob_value)
            end

            rows << hash
          end

          return_column_names ? [rows, cols] : rows
        ensure
          cursor.close if cursor
        end

        def write_lob(lob, value, is_binary = false)
          lob.write value
        end

        def describe(name)
          # fall back to SELECT based describe if using database link
          return super if name.to_s.include?("@")
          quoted_name = OracleEnhanced::Quoting.valid_table_name?(name) ? name : "\"#{name}\""
          @raw_connection.describe(quoted_name)
        rescue OCIException => e
          if e.code == 4043
            raise OracleEnhanced::ConnectionException, %Q{"DESC #{name}" failed; does it exist?}
          else
            # fall back to SELECT which can handle synonyms to database links
            super
          end
        end

        # Return OCIError error code
        def error_code(exception)
          case exception
          when OCIError
            exception.code
          else
            nil
          end
        end

        def typecast_result_value(value, get_lob_value)
          case value
          when Integer
            value
          when String
            value
          when Float, BigDecimal
            # return Integer if value is integer (to avoid issues with _before_type_cast values for id attributes)
            value == (v_to_i = value.to_i) ? v_to_i : value
          when OCI8::LOB
            if get_lob_value
              data = value.read || ""     # if value.read returns nil, then we have an empty_clob() i.e. an empty string
              # In Ruby 1.9.1 always change encoding to ASCII-8BIT for binaries
              data.force_encoding("ASCII-8BIT") if data.respond_to?(:force_encoding) && value.is_a?(OCI8::BLOB)
              data
            else
              value
            end
          when Time, DateTime
            create_time_with_default_timezone(value)
          else
            value
          end
        end

        def database_version
          @database_version ||= (version = raw_connection.oracle_server_version) && [version.major, version.minor]
        end

      private

        def date_without_time?(value)
          case value
          when OraDate
            value.hour == 0 && value.minute == 0 && value.second == 0
          else
            value.hour == 0 && value.min == 0 && value.sec == 0
          end
        end

        def create_time_with_default_timezone(value)
          year, month, day, hour, min, sec, usec = case value
                                                   when Time
                                                     [value.year, value.month, value.day, value.hour, value.min, value.sec, value.usec]
                                                   when OraDate
                                                     [value.year, value.month, value.day, value.hour, value.minute, value.second, 0]
                                                   else
                                                     [value.year, value.month, value.day, value.hour, value.min, value.sec, 0]
          end
          # code from Time.time_with_datetime_fallback
          begin
            Time.send(Base.default_timezone, year, month, day, hour, min, sec, usec)
          rescue
            offset = Base.default_timezone.to_sym == :local ? ::DateTime.local_offset : 0
            ::DateTime.civil(year, month, day, hour, min, sec, offset)
          end
        end
      end

      # The OracleEnhancedOCIFactory factors out the code necessary to connect and
      # configure an Oracle/OCI connection.
      class OracleEnhancedOCIFactory #:nodoc:
        def self.new_connection(config)
          # to_s needed if username, password or database is specified as number in database.yml file
          username = config[:username] && config[:username].to_s
          password = config[:password] && config[:password].to_s
          database = config[:database] && config[:database].to_s
          schema = config[:schema] && config[:schema].to_s
          host, port = config[:host], config[:port]
          privilege = config[:privilege] && config[:privilege].to_sym
          async = config[:allow_concurrency]
          prefetch_rows = config[:prefetch_rows] || 100
          cursor_sharing = config[:cursor_sharing] || "force"
          # get session time_zone from configuration or from TZ environment variable
          time_zone = config[:time_zone] || ENV["TZ"]

          # using a connection string via DATABASE_URL
          connection_string = if host == "connection-string"
            database
          # connection using host, port and database name
          elsif host || port
            host ||= "localhost"
            host = "[#{host}]" if host =~ /^[^\[].*:/  # IPv6
            port ||= 1521
            database = "/#{database}" unless database.match(/^\//)
            "//#{host}:#{port}#{database}"
          # if no host is specified then assume that
          # database parameter is TNS alias or TNS connection string
          else
            database
          end
          OCI8.properties[:tcp_keepalive] = true
          begin
            OCI8.properties[:tcp_keepalive_time] = 600
          rescue NotImplementedError
          end
          conn = OCI8.new username, password, connection_string, privilege
          conn.autocommit = true
          conn.non_blocking = true if async
          conn.prefetch_rows = prefetch_rows
          conn.exec "alter session set cursor_sharing = #{cursor_sharing}" rescue nil if cursor_sharing
          if ActiveRecord::Base.default_timezone == :local
            conn.exec "alter session set time_zone = '#{time_zone}'" unless time_zone.blank?
          elsif ActiveRecord::Base.default_timezone == :utc
            conn.exec "alter session set time_zone = '+00:00'"
          end
          conn.exec "alter session set current_schema = #{schema}" unless schema.blank?

          # Initialize NLS parameters
          OracleEnhancedAdapter::DEFAULT_NLS_PARAMETERS.each do |key, default_value|
            value = config[key] || ENV[key.to_s.upcase] || default_value
            if value
              conn.exec "alter session set #{key} = '#{value}'"
            end
          end
          conn
        end
      end
    end
  end
end

class OCI8 #:nodoc:
  def describe(name)
    info = describe_table(name.to_s)
    raise %Q{"DESC #{name}" failed} if info.nil?
    if info.respond_to?(:obj_link) && info.obj_link
      [info.obj_schema, info.obj_name, "@" + info.obj_link]
    else
      [info.obj_schema, info.obj_name]
    end
  end
end

# The OCI8AutoRecover class enhances the OCI8 driver with auto-recover and
# reset functionality. If a call to #exec fails, and autocommit is turned on
# (ie., we're not in the middle of a longer transaction), it will
# automatically reconnect and try again. If autocommit is turned off,
# this would be dangerous (as the earlier part of the implied transaction
# may have failed silently if the connection died) -- so instead the
# connection is marked as dead, to be reconnected on it's next use.
#:stopdoc:
class OCI8EnhancedAutoRecover < DelegateClass(OCI8) #:nodoc:
  attr_accessor :active #:nodoc:
  alias :active? :active #:nodoc:

  cattr_accessor :auto_retry
  class << self
    alias :auto_retry? :auto_retry #:nodoc:
  end
  @@auto_retry = false

  def initialize(config, factory) #:nodoc:
    @active = true
    @config = config
    @factory = factory
    @connection = @factory.new_connection @config
    super @connection
  end

  # Checks connection, returns true if active. Note that ping actively
  # checks the connection, while #active? simply returns the last
  # known state.
  def ping #:nodoc:
    @connection.exec("select 1 from dual") { |r| nil }
    @active = true
  rescue
    @active = false
    raise
  end

  # Resets connection, by logging off and creating a new connection.
  def reset! #:nodoc:
    logoff rescue nil
    begin
      @connection = @factory.new_connection @config
      __setobj__ @connection
      @active = true
    rescue
      @active = false
      raise
    end
  end

  # ORA-00028: your session has been killed
  # ORA-01012: not logged on
  # ORA-03113: end-of-file on communication channel
  # ORA-03114: not connected to ORACLE
  # ORA-03135: connection lost contact
  LOST_CONNECTION_ERROR_CODES = [ 28, 1012, 3113, 3114, 3135 ] #:nodoc:

  # Adds auto-recovery functionality.
  #
  # See: http://www.jiubao.org/ruby-oci8/api.en.html#label-11
  def exec(sql, *bindvars, &block) #:nodoc:
    should_retry = self.class.auto_retry? && autocommit?

    begin
      @connection.exec(sql, *bindvars, &block)
    rescue OCIException => e
      raise unless e.is_a?(OCIError) && LOST_CONNECTION_ERROR_CODES.include?(e.code)
      @active = false
      raise unless should_retry
      should_retry = false
      reset! rescue nil
      retry
    end
  end
end
#:startdoc: