# frozen_string_literal: true

# supports concatenation in default functions: https://github.com/rails/rails/pull/49287
# supports configuring busy_handler: https://github.com/rails/rails/pull/49352

require "active_record/connection_adapters/sqlite3_adapter"
require "enhanced_sqlite3/supports_virtual_columns"
require "enhanced_sqlite3/supports_deferrable_constraints"
require "enhanced_sqlite3/supports_insert_returning"

module EnhancedSQLite3
  module Adapter
    # Setup the Rails SQLite3 adapter instance.
    #
    # extends  https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L90
    def initialize(...)
      super
      # Ensure that all connections default to immediate transaction mode.
      # This is necessary to prevent SQLite from deadlocking when concurrent processes open write transactions.
      # By default, SQLite opens transactions in deferred mode, which means that a transactions acquire
      # a shared lock on the database, but will attempt to upgrade that lock to an exclusive lock if/when
      # a write is attempted. Because SQLite is in the middle of a transaction, it cannot retry the transaction
      # if a BUSY exception is raised, and so it will immediately raise a SQLITE_BUSY exception without calling
      # the `busy_handler`. Because Rails only wraps writes in transactions, this means that all transactions
      # will attempt to acquire an exclusive lock on the database. Thus, under any concurrent load, you are very
      # likely to encounter a SQLITE_BUSY exception.
      # By setting the default transaction mode to immediate, SQLite will instead attempt to acquire
      # an exclusive lock as soon as the transaction is opened. If the lock cannot be acquired, it will
      # immediately call the `busy_handler` to retry the transaction. This allows concurrent processes to
      # coordinate and linearize their transactions, avoiding deadlocks.
      @connection_parameters.merge!(default_transaction_mode: :immediate)
    end

    # Perform any necessary initialization upon the newly-established
    # @raw_connection -- this is the place to modify the adapter's
    # connection settings, run queries to configure any application-global
    # "session" variables, etc.
    #
    # Implementations may assume this method will only be called while
    # holding @lock (or from #initialize).
    #
    # overrides https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L691
    def configure_connection
      configure_busy_handler_timeout
      check_version
      configure_pragmas
      configure_extensions

      EnhancedSQLite3::SupportsVirtualColumns.apply! unless try(:supports_virtual_columns?)
      EnhancedSQLite3::SupportsDeferrableConstraints.apply! unless try(:supports_deferrable_constraints?)
      EnhancedSQLite3::SupportsInsertReturning.apply! unless try(:supports_insert_returning?)
    end

    # Patch the #transaction method to ensure that all transactions are sent to the writing role database connection pool.
    def transaction(...)
      ActiveRecord::Base.connected_to(role: ActiveRecord.writing_role, prevent_writes: false) do
        super(...)
      end
    end

    # Patch the #log method to ensure that all log messages are tagged with the database connection name.
    def log(...)
      db_connection_name = ActiveRecord::Base.connection_db_config.name
      if Rails.logger.formatter.current_tags.include? db_connection_name
        super
      else
        Rails.logger.tagged(db_connection_name) { super }
      end
    end

    private

    def configure_busy_handler_timeout
      return unless @config.key?(:timeout)

      timeout_milliseconds = self.class.type_cast_config_to_integer(@config[:timeout])
      timeout_seconds = timeout_milliseconds.fdiv(1000)

      @raw_connection.busy_handler do |count|
        now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
        if count.zero?
          @timeout_deadline = now + timeout_seconds
        elsif now > @timeout_deadline
          next false
        else
          sleep(0.001)
        end
      end
    end

    def configure_pragmas
      defaults = {
        # Enforce foreign key constraints
        # https://www.sqlite.org/pragma.html#pragma_foreign_keys
        # https://www.sqlite.org/foreignkeys.html
        "foreign_keys" => "ON",
        # Impose a limit on the WAL file to prevent unlimited growth
        # https://www.sqlite.org/pragma.html#pragma_journal_size_limit
        "journal_size_limit" => 64.megabytes,
        # Set the local connection cache to 2000 pages
        # https://www.sqlite.org/pragma.html#pragma_cache_size
        "cache_size" => 2000
      }
      unless @memory_database
        defaults.merge!(
          # Journal mode WAL allows for greater concurrency (many readers + one writer)
          # https://www.sqlite.org/pragma.html#pragma_journal_mode
          "journal_mode" => "WAL",
          # Set more relaxed level of database durability
          # 2 = "FULL" (sync on every write), 1 = "NORMAL" (sync every 1000 written pages) and 0 = "NONE"
          # https://www.sqlite.org/pragma.html#pragma_synchronous
          "synchronous" => "NORMAL",
          # Set the global memory map so all processes can share some data
          # https://www.sqlite.org/pragma.html#pragma_mmap_size
          # https://www.sqlite.org/mmap.html
          "mmap_size" => 128.megabytes
        )
      end
      pragmas = defaults.merge(@config.fetch(:pragmas, {}))

      pragmas.each do |key, value|
        execute("PRAGMA #{key} = #{value}", "SCHEMA")
      end
    end

    def configure_extensions
      @raw_connection.enable_load_extension(true)
      @config.fetch(:extensions, []).each do |extension_name|
        if extension_name.include?("/")
          raise LoadError unless Dir.exist?(".sqlpkg/#{extension_name}")
          Dir.glob(".sqlpkg/#{extension_name}/*.{dll,so,dylib}") do |extension_path|
            @raw_connection.load_extension(extension_path)
          end
        else
          require extension_name
          extension_classname = extension_name.camelize
          extension_class = extension_classname.constantize
          extension_class.load(@raw_connection)
        end
      rescue LoadError
        Dir.glob(".sqlpkg/**/*.{dll,so,dylib}") do |extension_path|
          @raw_connection.load_extension(extension_path)
        end
        Rails.logger.error("Failed to find the SQLite extension gem: #{extension_name}. Skipping...")
      rescue NameError
        Rails.logger.error("Failed to find the SQLite extension class: #{extension_classname}. Skipping...")
      end
      @raw_connection.enable_load_extension(false)
    end
  end
end
