# frozen_string_literal: true

module ActiveDelivery
  class Delivery # :nodoc:
    attr_reader :params, :options, :metadata, :notification, :owner

    def initialize(owner, notification:, params:, options:, metadata:)
      @owner = owner
      @notification = notification
      @params = params.freeze
      @options = options.freeze
      @metadata = metadata.freeze
    end

    def deliver_later(**opts) = owner.perform_notify(self, enqueue_options: opts)

    def deliver_now(**opts) = owner.perform_notify(self, sync: true)

    def delivery_class = owner.class
  end

  class << self
    # Whether to memoize resolved handler classes or not.
    # Set to false if you're using a code reloader (e.g., Zeitwerk).
    #
    # Defaults to true (i.e. memoization is enabled
    attr_accessor :cache_classes
    # Whether to enforce specifying available delivery actions via .delivers in the
    # delivery classes
    attr_accessor :deliver_actions_required
  end

  self.cache_classes = true
  self.deliver_actions_required = false

  # Base class for deliveries.
  #
  # Delivery object describes how to notify a user about
  # an event (e.g. via email or via push notification or both).
  #
  # Delivery class acts like a proxy in front of the different delivery channels
  # (i.e. mailers, notifiers). That means that calling a method on delivery class invokes the
  # same method on the corresponding class, e.g.:
  #
  #   EventsDelivery.one_hour_before(profile, event).deliver_later
  #   # or
  #   EventsDelivery.notify(:one_hour_before, profile, event)
  #
  #   # under the hood it calls
  #   EventsMailer.one_hour_before(profile, event).deliver_later
  #
  #   # and
  #   EventsNotifier.one_hour_before(profile, event).notify_later
  #
  # Delivery also supports _parameterized_ calling:
  #
  #   EventsDelivery.with(profile: profile).canceled(event).deliver_later
  #
  # The parameters could be accessed through `params` instance method (e.g.
  # to implement guard-like logic).
  #
  # When params are presents the parametrized mailer is used, i.e.:
  #
  #   EventsMailer.with(profile: profile).canceled(event).deliver_later
  #
  # See https://api.rubyonrails.org/classes/ActionMailer/Parameterized.html
  class Base
    class << self
      attr_accessor :abstract_class

      alias_method :with, :new

      # Enqueues delivery (i.e. uses #deliver_later for mailers)
      def notify(...)
        new.notify(...)
      end

      # The same as .notify but delivers synchronously
      # (i.e. #deliver_now for mailers)
      def notify!(mid, *, **hargs)
        notify(mid, *, **hargs, sync: true)
      end

      alias_method :notify_now, :notify!

      def delivery_lines
        @lines ||= if superclass.respond_to?(:delivery_lines)
          superclass.delivery_lines.each_with_object({}) do |(key, val), acc|
            acc[key] = val.dup_for(self)
          end
        else
          {}
        end
      end

      def register_line(line_id, line_class = nil, notifier: nil, **)
        raise ArgumentError, "A line class or notifier configuration must be provided" if line_class.nil? && notifier.nil?

        # Configure Notifier
        if line_class.nil?
          line_class = ActiveDelivery::Lines::Notifier
        end

        delivery_lines[line_id] = line_class.new(id: line_id, owner: self, **)

        instance_eval <<~CODE, __FILE__, __LINE__ + 1
          def #{line_id}(val)
            delivery_lines[:#{line_id}].handler_class_name = val
          end

          def #{line_id}_class
            delivery_lines[:#{line_id}].handler_class
          end
        CODE
      end

      def unregister_line(line_id)
        removed_line = delivery_lines.delete(line_id)

        return if removed_line.nil?

        singleton_class.undef_method line_id
        singleton_class.undef_method "#{line_id}_class"
      end

      def abstract_class? = abstract_class == true

      # Specify explicitly which actions are supported by the delivery.
      def delivers(*actions)
        actions.each do |mid|
          class_eval <<~CODE, __FILE__, __LINE__ + 1
            def self.#{mid}(...)
              new.#{mid}(...)
            end

            def #{mid}(*args, **kwargs)
              delivery(
                notification: :#{mid},
                params: args,
                options: kwargs
              )
            end
          CODE
        end
      end

      def respond_to_missing?(mid, include_private = false)
        unless ActiveDelivery.deliver_actions_required
          return true if delivery_lines.any? { |_, line| line.notify?(mid) }
        end

        super
      end

      def method_missing(mid, *, **)
        return super unless respond_to_missing?(mid)

        # Lazily define a class method to avoid lookups
        delivers(mid)

        public_send(mid, *, **)
      end

      attr_writer :raise_on_line_error

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

        @raise_on_line_error = superclass.respond_to?(:raise_on_line_error) ? superclass.raise_on_line_error : true
      end
    end

    self.abstract_class = true
    self.raise_on_line_error = true

    attr_reader :params, :notification_name

    def initialize(**params)
      @params = params
      @params.freeze
    end

    # Enqueues delivery (i.e. uses #deliver_later for mailers)
    def notify(mid, *args, **kwargs)
      perform_notify(
        delivery(notification: mid, params: args, options: kwargs)
      )
    end

    # The same as .notify but delivers synchronously
    # (i.e. #deliver_now for mailers)
    def notify!(mid, *args, **kwargs)
      perform_notify(
        delivery(notification: mid, params: args, options: kwargs),
        sync: true
      )
    end

    alias_method :notify_now, :notify!

    def respond_to_missing?(mid, include_private = false)
      unless ActiveDelivery.deliver_actions_required
        return true if delivery_lines.any? { |_, line| line.notify?(mid) }
      end

      super
    end

    def method_missing(mid, *, **)
      return super unless respond_to_missing?(mid)

      # Lazily define a method to avoid future lookups
      self.class.class_eval <<~CODE, __FILE__, __LINE__ + 1
        def #{mid}(*args, **kwargs)
          delivery(
            notification: :#{mid},
            params: args,
            options: kwargs
          )
        end
      CODE

      public_send(mid, *, **)
    end

    protected

    def perform_notify(delivery, sync: false, enqueue_options: {})
      delivery_lines.each do |type, line|
        next unless line.notify?(delivery.notification)

        notify_line(type, line, delivery, sync:, enqueue_options:)
      rescue => line_error
        if self.class.raise_on_line_error
          raise
        else
          capture_line_error(type, line_error)
        end
      end
    end

    private

    def notify_line(type, line, delivery, sync:, enqueue_options:)
      line.notify(
        delivery.notification,
        *delivery.params,
        params:,
        sync:,
        enqueue_options:,
        **delivery.options
      )
      true
    end

    def delivery(notification:, params: nil, options: nil, metadata: nil)
      Delivery.new(self, notification:, params:, options:, metadata:)
    end

    def delivery_lines
      self.class.delivery_lines
    end

    def capture_line_error(type, line_error)
      ::Kernel.warn(<<~MESSAGE)
        Delivery line #{type} raised an error on #notify: #{line_error.message}
        #{line_error.backtrace&.join("\n")}
      MESSAGE
    end
  end
end
