# frozen-string-literal: true

require 'tiny_tds'
Sequel.require 'adapters/shared/mssql'

module Sequel
  module TinyTDS
    class Database < Sequel::Database
      include Sequel::MSSQL::DatabaseMethods
      set_adapter_scheme :tinytds

      # Transfer the :user option to the :username option.
      def connect(server)
        opts = server_opts(server)
        opts[:username] = opts[:user]
        c = TinyTds::Client.new(opts)
        c.query_options.merge!(:cache_rows=>false)

        if (ts = opts[:textsize])
          sql = "SET TEXTSIZE #{typecast_value_integer(ts)}"
          log_connection_yield(sql, c){c.execute(sql)}
        end
      
        c
      end
      
      # Execute the given +sql+ on the server.  If the :return option
      # is present, its value should be a method symbol that is called
      # on the TinyTds::Result object returned from executing the
      # +sql+.  The value of such a method is returned to the caller.
      # Otherwise, if a block is given, it is yielded the result object.
      # If no block is given and a :return is not present, +nil+ is returned.
      def execute(sql, opts=OPTS)
        synchronize(opts[:server]) do |c|
          begin
            m = opts[:return]
            r = nil
            if (args = opts[:arguments]) && !args.empty?
              types = []
              values = []
              args.each_with_index do |(k, v), i|
                v, type = ps_arg_type(v)
                types << "@#{k} #{type}"
                values << "@#{k} = #{v}"
              end
              case m
              when :do
                sql = "#{sql}; SELECT @@ROWCOUNT AS AffectedRows"
                single_value = true
              when :insert
                sql = "#{sql}; SELECT CAST(SCOPE_IDENTITY() AS bigint) AS Ident"
                single_value = true
              end
              sql = "EXEC sp_executesql N'#{c.escape(sql)}', N'#{c.escape(types.join(', '))}', #{values.join(', ')}"
              log_connection_yield(sql, c) do
                r = c.execute(sql)
                r.each{|row| return row.values.first} if single_value
              end
            else
              log_connection_yield(sql, c) do
                r = c.execute(sql)
                return r.send(m) if m
              end
            end
            yield(r) if block_given?
          rescue TinyTds::Error => e
            raise_error(e, :disconnect=>!c.active?)
          ensure
            r.cancel if r && c.sqlsent? && c.active?
          end
        end
      end

      # Return the number of rows modified by the given +sql+.
      def execute_dui(sql, opts=OPTS)
        opts = Hash[opts]
        opts[:return] = :do
        execute(sql, opts)
      end

      # Return the value of the autogenerated primary key (if any)
      # for the row inserted by the given +sql+.
      def execute_insert(sql, opts=OPTS)
        opts = Hash[opts]
        opts[:return] = :insert
        execute(sql, opts)
      end

      # Execute the DDL +sql+ on the database and return nil.
      def execute_ddl(sql, opts=OPTS)
        opts = Hash[opts]
        opts[:return] = :each
        execute(sql, opts)
        nil
      end

      private

      # Choose whether to use unicode strings on initialization
      def adapter_initialize
        set_mssql_unicode_strings
      end
      
      # For some reason, unless you specify a column can be
      # NULL, it assumes NOT NULL, so turn NULL on by default unless
      # the column is a primary key column.
      def column_list_sql(g)
        pks = []
        g.constraints.each{|c| pks = c[:columns] if c[:type] == :primary_key} 
        g.columns.each{|c| c[:null] = true if !pks.include?(c[:name]) && !c[:primary_key] && !c.has_key?(:null) && !c.has_key?(:allow_null)}
        super
      end

      # tiny_tds uses TinyTds::Error as the base error class.
      def database_error_classes
        [TinyTds::Error]
      end

      # Stupid MSSQL maps foreign key and check constraint violations
      # to the same error code, and doesn't expose the sqlstate.  Use
      # database error numbers if present and unambiguous, otherwise
      # fallback to the regexp mapping.
      def database_specific_error_class(exception, opts)
        case exception.db_error_number
        when 515
          NotNullConstraintViolation
        when 2627
          UniqueConstraintViolation
        else
          super
        end
      end

      def dataset_class_default
        Dataset
      end

      TINYTDS_DISCONNECT_ERRORS = /\A(Attempt to initiate a new Adaptive Server operation with results pending|The request failed to run because the batch is aborted, this can be caused by abort signal sent from client|Adaptive Server connection timed out)/
      Sequel::Deprecation.deprecate_constant(self, :TINYTDS_DISCONNECT_ERRORS)

      # Return true if the :conn argument is present and not active.
      def disconnect_error?(e, opts)
        super || (opts[:conn] && !opts[:conn].active?) || ((e.is_a?(::TinyTds::Error) && /\A(Attempt to initiate a new Adaptive Server operation with results pending|The request failed to run because the batch is aborted, this can be caused by abort signal sent from client|Adaptive Server connection timed out)/.match(e.message)))
      end

      # Dispose of any possible results of execution.
      def log_connection_execute(conn, sql)
        log_connection_yield(sql, conn){conn.execute(sql).each}
      end

      # Return a 2 element array with the literal value and type to use
      # in the prepared statement call for the given value and connection.
      def ps_arg_type(v)
        case v
        when Integer
          [v, 'bigint']
        when Float
          [v, 'double precision']
        when Numeric
          [v, 'numeric']
        when Time
          if v.is_a?(SQLTime)
            [literal(v), 'time']
          else
            [literal(v), 'datetime']
          end
        when DateTime
          [literal(v), 'datetime']
        when Date
          [literal(v), 'date']
        when nil
          ['NULL', 'nvarchar(max)']
        when true
          ['1', 'int']
        when false
          ['0', 'int']
        when SQL::Blob
          [literal(v), 'varbinary(max)']
        else
          [literal(v), 'nvarchar(max)']
        end
      end
    end
    
    class Dataset < Sequel::Dataset
      include Sequel::MSSQL::DatasetMethods

      Database::DatasetClass = self
      Sequel::Deprecation.deprecate_constant(Database, :DatasetClass)
      
      # SQLite already supports named bind arguments, so use directly.
      module ArgumentMapper
        include Sequel::Dataset::ArgumentMapper
        
        protected
        
        # Return a hash with the same values as the given hash,
        # but with the keys converted to strings.
        def map_to_prepared_args(hash)
          args = {}
          hash.each{|k,v| args[k.to_s.gsub('.', '__')] = v}
          args
        end
        
        private
        
        # SQLite uses a : before the name of the argument for named
        # arguments.
        def prepared_arg(k)
          LiteralString.new("@#{k.to_s.gsub('.', '__')}")
        end

        # Always assume a prepared argument.
        def prepared_arg?(k)
          true
        end
      end
      
      PreparedStatementMethods = prepared_statements_module("sql = prepared_sql; opts = Hash[opts]; opts[:arguments] = bind_arguments", ArgumentMapper)
    
      # Yield hashes with symbol keys, attempting to optimize for
      # various cases.
      def fetch_rows(sql)
        execute(sql) do |result|
          columns = result.fields.map!{|c| output_identifier(c)}
          if columns.empty?
            args = []
            args << {:timezone=>:utc} if db.timezone == :utc
            cols = nil
            result.each(*args) do |r|
              unless cols
                cols = result.fields.map{|c| [c, output_identifier(c)]}
                self.columns = columns = cols.map(&:last)
              end
              h = {}
              cols.each do |s, sym|
                h[sym] = r[s]
              end
              yield h
            end
          else
            self.columns = columns
            if db.timezone == :utc
              result.each(:timezone=>:utc){|r| yield r}
            else
              result.each{|r| yield r}
            end
          end
        end
        self
      end
      
      private
      
      # Properly escape the given string +v+.
      def literal_string_append(sql, v)
        sql << (mssql_unicode_strings ? "N'" : "'")
        sql << db.synchronize(@opts[:server]){|c| c.escape(v)}.gsub(/\\((?:\r\n)|\n)/, '\\\\\\\\\\1\\1') << "'"
      end

      def prepared_statement_modules
        [PreparedStatementMethods]
      end
    end
  end
end
