module Compony
  class Component
    # Include all functionality that was moved to default mixins for better overview
    Compony::ComponentMixins::Default.constants.each { |cst| include Compony::ComponentMixins::Default.const_get(cst) }

    class_attribute :setup_blocks

    attr_reader :parent_comp
    attr_reader :comp_opts
    attr_reader :content_blocks # needed in RequestContext for nesting

    # root comp: component that is registered to be root of the application.
    # parent comp: component that is registered to be the parent of this comp. If there is none, this is the root comp.

    # DSL method
    def self.setup(&block)
      fail("`setup` expects a block in #{inspect}.") unless block_given?
      self.setup_blocks ||= []
      self.setup_blocks = setup_blocks.dup # This is required to prevent the parent class to see children's setup blocks.
      setup_blocks << block
    end

    # Returns the name of the module constant (=family) of this component. Do not override.
    def self.family_cst
      module_parent.to_s.demodulize.to_sym
    end

    # Returns the family name
    def self.family_name
      family_cst.to_s.underscore
    end

    # Returns the name of the class constant of this component. Do not override.
    def self.comp_cst
      name.demodulize.to_sym
    end

    # Returns the component name
    def self.comp_name
      comp_cst.to_s.underscore
    end

    def initialize(parent_comp = nil, index: 0, **comp_opts)
      @parent_comp = parent_comp
      @sub_comps = []
      @index = index
      @comp_opts = comp_opts
      @before_render_blocks = NaturalOrdering.new
      @content_blocks = NaturalOrdering.new
      @actions = NaturalOrdering.new
      @skipped_actions = Set.new

      init_standalone
      init_labelling

      fail "#{inspect} is missing a call to `setup`." unless setup_blocks&.any?

      setup_blocks.each do |setup_block|
        instance_exec(&setup_block)
      end
    end

    def inspect
      "#<#{self.class.name}:#{hash}>"
    end

    # Returns the current root comp.
    # Do not overwrite.
    def root_comp
      return self unless parent_comp
      return parent_comp.root_comp
    end

    # Returns whether or not this is the root comp.
    # Do not overwrite.
    def root_comp?
      parent_comp.nil?
    end

    # Returns an identifier describing this component. Must be unique among simplings under the same parent_comp.
    # Do not override.
    def id
      "#{family_name}_#{comp_name}_#{@index}"
    end

    # Returns the id path from the root_comp.
    # Do not overwrite.
    def path
      if root_comp?
        id
      else
        "#{parent_comp.path}/#{id}"
      end
    end

    # Returns a hash for the path. Used for params prefixing.
    # Do not overwrite.
    def path_hash
      Digest::SHA1.hexdigest(path)[..4]
    end

    # Given an unprefixed name of a param, adds the path hash
    # Do not overwrite.
    def param_name(unprefixed_param_name)
      "#{path_hash}_#{unprefixed_param_name}"
    end

    # Instanciate a component with `self` as a parent
    def sub_comp(component_class, **comp_opts)
      sub = component_class.new(self, index: @sub_comps.count, **comp_opts)
      @sub_comps << sub
      return sub
    end

    # Returns the name of the module constant (=family) of this component. Do not override.
    delegate :family_cst, to: :class

    # Returns the family name
    delegate :family_name, to: :class

    # Returns the name of the class constant of this component. Do not override.
    delegate :comp_cst, to: :class

    # Returns the component name
    delegate :comp_name, to: :class

    # DSL method
    # Adds or overrides a before_render block.
    # You can use controller.redirect_to to redirect away and halt the before_render/content chain
    # @param [Symbol,String] name The name of the before_render block, defaults to `:main`
    # @param [nil,Symbol,String] before If nil, the block will be added to the bottom of the before_render chain. Otherwise, pass the name of another block.
    # @param [Proc] block The block that should be run as part of the before_render pipeline. Will run in the component's context.
    def before_render(name = :main, before: nil, **, &block)
      fail("`before_render` expects a block in #{inspect}.") unless block_given?
      @before_render_blocks.natural_push(name, block, before:, **)
    end

    # DSL method
    # Adds or overrides a content block.
    # @param [Symbol,String] name The name of the content block, defaults to `:main`
    # @param [nil,Symbol,String] before If nil, the block will be added to the bottom of the content chain. Otherwise, pass the name of another block.
    # @param [Hash] kwargs If hidden is true, the content will not be rendered by default, allowing you to nest it in another content block.
    # @param [Proc] block The block that should be run as part of the content pipeline. Will run in the component's context. You can use Dyny here.
    def content(name = :main, before: nil, **, &block)
      # A block is required here, but if this is an override (e.g. to hide another content block), we can tolerate the missing block.
      if !block_given? && @content_blocks.find { |b| b.name == name }.nil?
        fail("`content` expects a block in #{inspect}.")
      end
      @content_blocks.natural_push(name, block || :missing, before:, **)
    end

    # DSL method
    # Removes a content block. Use this in subclasses if a content block defined in the parent should be removed from the child.
    # @param [Symbol,String] name Name of the content block that should be removed
    def remove_content(name) # rubocop:disable Naming/PredicateMethod
      existing_index = @content_blocks.find_index { |el| el.name == name.to_sym }
      if existing_index.nil?
        return false
      else
        @content_blocks.delete_at(existing_index)
        return true
      end
    end

    # DSL method
    # Removes a content block and fails if the content block was not found.
    # @param [Symbol,String] name Name of the content block that should be removed
    def remove_content!(name)
      remove_content(name) || fail("Content block #{name.inspect} not found for removal in #{inspect}.")
    end

    # Renders the component using the controller passsed to it and returns it as a string.
    # @param [Boolean] standalone pass true iff `render` is called from `render_standalone`
    # Do not overwrite.
    def render(controller, standalone: false, **locals)
      # Call before_render hooks (if any) and backfire instance variables back to the component
      @before_render_blocks.each do |element|
        RequestContext.new(self, controller, locals:).evaluate_with_backfire(&element.payload)
        # Stop if a `before_render` block issued a body (e.g. through redirecting)
        break unless controller.response_body.nil?
      end

      # Render, unless before_render has already issued a body (e.g. through redirecting).
      if controller.response_body.nil?
        fail "#{self.class.inspect} must define `content` or set a response body in `before_render`" if @content_blocks.none?
        return controller.render_to_string(
          type:   :dyny,
          locals: { content_blocks: @content_blocks, standalone:, component: self, render_locals: locals },
          inline: <<~RUBY
            if Compony.content_before_root_comp_block && standalone
              Compony::RequestContext.new(component, controller, helpers: self, locals: render_locals).evaluate(&Compony.content_before_root_comp_block)
            end
            content_blocks.reject{ |el| el.hidden }.each do |element|
              # Instanciate and evaluate a fresh RequestContext in order to use the buffer allocated by the ActionView (needed for `concat` calls)
              Compony::RequestContext.new(component, controller, helpers: self, locals: render_locals).evaluate(&element.payload)
            end
            if Compony.content_after_root_comp_block && standalone
              Compony::RequestContext.new(component, controller, helpers: self, locals: render_locals).evaluate(&Compony.content_after_root_comp_block)
            end
          RUBY
        )
      else
        return nil # Prevent double render errors
      end
    end

    # DSL method
    # Adds or replaces an action (for action buttons)
    # If before: is specified, will insert the action before the named action. When replacing, an element keeps its position unless before: is specified.
    def action(action_name, before: nil, &block)
      @actions.natural_push(action_name, block, before:)
    end

    # DSL method
    # Marks an action for skip
    def skip_action(action_name)
      @skipped_actions << action_name.to_sym
    end

    # Used to render all actions of this component, each button wrapped in a div with the specified class
    def render_actions(controller, wrapper_class: '', action_class: '')
      h = controller.helpers
      h.content_tag(:div, class: wrapper_class) do
        button_htmls = @actions.map do |action|
          next if @skipped_actions.include?(action.name)
          Compony.with_button_defaults(feasibility_action: action.name.to_sym) do
            action_button = action.payload.call(controller)
            next unless action_button
            button_html = action_button.render(controller)
            next if button_html.blank?
            h.content_tag(:div, button_html, class: action_class)
          end
        end
        next h.safe_join button_htmls
      end
    end

    # Is true for resourceful components
    def resourceful?
      return false
    end
  end
end
