# frozen_string_literal: true

require "dry/configurable"
require "dry/core"
require "uri"
require_relative "../constants"

module Hanami
  module Providers
    # @api private
    # @since 2.2.0
    class DB < Hanami::Provider::Source
      extend Dry::Core::Cache

      include Dry::Configurable(config_class: Providers::DB::Config)

      setting :adapters, mutable: true, default: Adapters.new
      setting :gateways, default: {}

      def initialize(...)
        super(...)

        @config_finalized = false
      end

      def finalize_config
        return if @config_finalized

        apply_parent_config if apply_parent_config?

        configure_gateways

        @config_finalized = true

        self
      end

      def prepare
        prepare_and_import_parent_db and return if import_from_parent?

        override_rom_inflector

        finalize_config

        require "hanami-db"

        gateways = prepare_gateways

        if gateways[:default]
          register "gateway", gateways[:default]
        elsif gateways.length == 1
          register "gateway", gateways.values.first
        end
        gateways.each do |key, gateway|
          register "gateways.#{key}", gateway
        end

        @rom_config = ROM::Configuration.new(gateways)

        config.each_plugin do |adapter_name, plugin_spec, config_block|
          if config_block
            @rom_config.plugin(adapter_name, plugin_spec) do |plugin_config|
              instance_exec(plugin_config, &config_block)
            end
          else
            @rom_config.plugin(adapter_name, plugin_spec)
          end
        end

        register "config", @rom_config
      end

      def start
        start_and_import_parent_db and return if import_from_parent?

        # Set up DB logging for the whole app. We share the app's notifications bus across all
        # slices, so we only need to configure the subsciprtion for DB logging just once.
        slice.app.start :db_logging

        # Register ROM components
        register_rom_components :relation, "relations"
        register_rom_components :command, File.join("db", "commands")
        register_rom_components :mapper, File.join("db", "mappers")

        rom = ROM.container(@rom_config)

        register "rom", rom
      end

      def stop
        slice["db.rom"].disconnect
      end

      # @api private
      # @since 2.2.0
      def database_urls
        finalize_config
        config.gateways.transform_values { _1.database_url }
      end

      private

      def parent_db_provider
        return @parent_db_provider if instance_variable_defined?(:@parent_db_provider)

        @parent_db_provider = slice.parent && slice.parent.container.providers[:db]
      end

      def apply_parent_config
        parent_db_provider.source.finalize_config

        self.class.settings.keys.each do |key|
          # Preserve settings already configured locally
          next if config.configured?(key)

          # Skip gateway config, we handle this in #configure_gateways
          next if key == :gateways

          # Skip adapter config, we handle this below
          next if key == :adapters

          config[key] = parent_db_provider.source.config[key]
        end

        parent_db_provider.source.config.adapters.each do |adapter_name, parent_adapter|
          adapter = config.adapter(adapter_name)

          adapter.class.settings.keys.each do |key|
            next if adapter.config.configured?(key)

            adapter.config[key] = parent_adapter.config[key].dup
          end
        end
      end

      def apply_parent_config?
        slice.config.db.configure_from_parent && parent_db_provider
      end

      def import_from_parent?
        slice.config.db.import_from_parent && slice.parent
      end

      def prepare_and_import_parent_db
        return unless parent_db_provider

        slice.parent.prepare :db
        @rom_config = slice.parent["db.config"]

        register "config", (@rom_config = slice.parent["db.config"])
        register "gateway", slice.parent["db.gateway"]
      end

      def start_and_import_parent_db
        return unless parent_db_provider

        slice.parent.start :db

        register "rom", slice.parent["db.rom"]
      end

      # ROM 5.3 doesn't have a configurable inflector.
      #
      # This is a problem in Hanami because using different
      # inflection rules for ROM will lead to constant loading
      # errors.
      def override_rom_inflector
        return if ROM::Inflector == Hanami.app["inflector"]

        ROM.instance_eval {
          remove_const :Inflector
          const_set :Inflector, Hanami.app["inflector"]
        }
      end

      def configure_gateways
        # Create gateway configs for gateways detected from database_url ENV vars
        database_urls_from_env = detect_database_urls_from_env
        database_urls_from_env.keys.each do |key|
          config.gateways[key] ||= Gateway.new
        end

        # Create a single default gateway if none is configured or detected from database URLs
        config.gateways[:default] = Gateway.new if config.gateways.empty?

        # Leave gateways in a stable order: :default first, followed by others in sort order
        if config.gateways.length > 1
          gateways = config.gateways
          config.gateways = {default: gateways[:default], **gateways.sort.to_h}.compact
        end

        config.gateways.each do |key, gw_config|
          gw_config.database_url ||= database_urls_from_env.fetch(key) {
            raise Hanami::ComponentLoadError, "A database_url for gateway #{key} is required to start :db."
          }

          ensure_database_gem(gw_config.database_url)

          apply_parent_gateway_config(key, gw_config) if apply_parent_config?

          gw_config.configure_adapter(config.adapters)
        end
      end

      def apply_parent_gateway_config(key, gw_config)
        parent_gw_config = parent_db_provider.source.config.gateways[key]

        # Only copy config from a parent gateway with the same name _and_ database URL
        return unless parent_gw_config&.database_url == gw_config.database_url

        # Copy config from matching parent gateway
        (gw_config.class.settings.keys - [:adapter]).each do |key|
          next if gw_config.config.configured?(key)

          gw_config.config[key] = parent_gw_config.config[key].dup
        end

        # If there is an adapter configured within this slice, prefer that, and do not copy the
        # adapter from the parent gateway
        unless config.adapters[gw_config.adapter_name] || gw_config.configured?(:adapter)
          gw_config.adapter = parent_gw_config.adapter.dup
        end
      end

      def prepare_gateways
        config.gateways.transform_values { |gw_config|
          # Avoid spurious connections by reusing identically configured gateways across slices
          fetch_or_store(gw_config.cache_keys) {
            ROM::Gateway.setup(
              gw_config.adapter_name,
              gw_config.database_url,
              **gw_config.options
            )
          }
        }
      end

      def detect_database_urls_from_env
        database_urls = {}

        env_var_prefix = slice.slice_name.name.gsub("/", "__").upcase + "__" unless slice.app?

        # Build gateway URLs from ENV vars with specific gateway named suffixes
        gateway_prefix = "#{env_var_prefix}DATABASE_URL__"
        ENV.select { |(k, _)| k.start_with?(gateway_prefix) }
          .each do |(var, _)|
            gateway_name = var.split(gateway_prefix).last.downcase

            database_urls[gateway_name.to_sym] = ENV[var]
          end

        # Set the default gateway from ENV var without suffix
        if !database_urls.key?(:default)
          fallback_vars = ["#{env_var_prefix}DATABASE_URL", "DATABASE_URL"].uniq

          fallback_vars.each do |var|
            url = ENV[var]
            database_urls[:default] = url and break if url
          end
        end

        if Hanami.env?(:test)
          database_urls.transform_values! { Hanami::DB::Testing.database_url(_1) }
        end

        database_urls
      end

      # @api private
      # @since 2.2.0
      DATABASE_GEMS = {
        "mysql2" => "mysql2",
        "postgres" => "pg",
        "sqlite" => "sqlite3"
      }.freeze
      private_constant :DATABASE_GEMS

      # Raises an error if the relevant database gem for the configured database_url is not
      # installed.
      #
      # Takes a conservative approach to raising errors. It only does so for the database_url
      # schemes generated by the `hanami new` CLI command. Uknown schemes are ignored and no errors
      # are raised.
      def ensure_database_gem(database_url)
        scheme = URI(database_url).scheme
        return unless scheme

        database_gem = DATABASE_GEMS[scheme]
        return unless database_gem

        return if Hanami.bundled?(database_gem)

        raise Hanami::ComponentLoadError, %(The "#{database_gem}" gem is required to connect to #{database_url}. Please add it to your Gemfile.)
      end

      def register_rom_components(component_type, path)
        components_path = target.source_path.join(path)
        components_path.glob("**/*.rb").each do |component_file|
          component_name = component_file
            .relative_path_from(components_path)
            .sub(RB_EXT_REGEXP, "")
            .to_s

          component_class = target.inflector.camelize(
            "#{target.slice_name.name}/#{path}/#{component_name}"
          ).then { target.inflector.constantize(_1) }

          @rom_config.public_send(:"register_#{component_type}", component_class)
        end
      end
    end

    Dry::System.register_provider_source(
      :db,
      source: DB,
      group: :hanami,
      provider_options: {namespace: true}
    )
  end
end
