# frozen_string_literal: true

require "securerandom"

require "pakyow/support/core_refinements/array/ensurable"
require "pakyow/support/inflector"
require "pakyow/support/safe_string"

module Pakyow
  module Presenter
    module Presenters
      class Form < DelegateClass(Presenter)
        class << self
          # @api private
          def pluralize_field_name(field)
            unless field.attributes[:name].to_s.end_with?("[]") || field.attributes[:name].to_s.empty?
              field.attributes[:name] = "#{field.attributes[:name]}[]"
            end
          end

          # @api private
          def connect_input_to_label(input, label)
            if input.attributes[:id].to_s.empty?
              id = SecureRandom.hex(4)
              input.attributes[:id] = id
            else
              id = input.attributes[:id]
            end

            label.attributes[:for] = id
          end
        end

        using Support::Refinements::Array::Ensurable

        include Support::SafeStringHelpers

        SUPPORTED_ACTIONS = %i[create update replace delete].freeze
        ACTION_METHODS = {create: "post", update: "patch", replace: "put", delete: "delete"}.freeze

        # @api private
        ID_LABEL = :__form_id

        def object_for_form
          if labeled?(:binding)
            presentables[channeled_binding_name]
          end
        end

        def id
          label(ID_LABEL)
        end

        # Sets the form action (where it submits to).
        #
        def action=(action)
          if action.is_a?(Symbol)
            if (endpoint = app.endpoints.find(name: action))
              view.object.set_label(:endpoint, action)
              view.object.set_label(:endpoint_object, endpoint)
              view.object.set_label(:endpoint_params, {})
              Endpoint.new(__getobj__).setup
            end
          else
            attrs[:action] = action
          end
        end

        # Sets the form method. Automatically handles method overrides by prepending a hidden field
        # named `pw-http-method` when +method+ is not get or post, setting the form method to +post+.
        #
        def method=(method)
          method = method.to_s.downcase
          if method_override_required?(method)
            attrs[:method] = "post"

            find_or_create_method_override_input.attributes[:value] = method
          else
            attrs[:method] = method
          end
        end

        # Populates a select field with options.
        #
        def options_for(field, options = nil)
          unless (field_presenter = find(Support.inflector.singularize(field)) || find(Support.inflector.pluralize(field)))
            raise ArgumentError.new("could not find field named `#{field}'")
          end

          unless options_for_allowed?(field_presenter)
            raise ArgumentError.new("expected `#{field}' to be a select field, checkbox, radio button, or binding")
          end

          options = if block_given?
            yield(field_presenter)
          else
            options
          end

          case field_presenter.object.tagname
          when "select"
            create_select_options(options, field_presenter)
          when "input"
            create_input_options(options, field_presenter)
          else
            create_options(options, field_presenter)
          end
        end

        # Populates a select field with grouped options.
        #
        def grouped_options_for(field, options = nil)
          unless (field_presenter = find(field))
            raise ArgumentError.new("could not find field named `#{field}'")
          end

          unless grouped_options_for_allowed?(field_presenter)
            raise ArgumentError.new("expected `#{field}' to be a select field")
          end

          options ||= yield
          case field_presenter.object.tagname
          when "select"
            create_grouped_select_options(options, field_presenter)
          end
        end

        def setup(object = {})
          use_binding_nodes
          use_global_options

          if block_given?
            yield self
          end

          bind(object)
          setup!
          self
        end

        def setup?
          view.object.labeled?(:__form_setup)
        end

        private def setup!
          view.object.set_label(:__form_setup, true)
        end

        # Setup the form for creating an object.
        #
        def create(object = {})
          yield self if block_given?
          setup_form_for_binding(:create, object)
        end

        # Setup the form for updating an object.
        #
        def update(object)
          yield self if block_given?
          setup_form_for_binding(:update, object)
        end

        # Setup the form for replacing an object.
        #
        def replace(object)
          yield self if block_given?
          setup_form_for_binding(:replace, object)
        end

        # Setup the form for removing an object.
        #
        def delete(object)
          yield self if block_given?
          setup_form_for_binding(:delete, object)
        end

        # Fixes an issue using pp inside a delegator.
        #
        def pp(*args)
          Kernel.pp(*args)
        end

        # Delegate private methods.
        #
        def method_missing(method_name, *args, &block)
          __getobj__.send(method_name, *args, &block)
        end

        def respond_to_missing?(method_name, include_private = false)
          super || __getobj__.respond_to?(method_name, true)
        end

        private

        def setup_form_for_binding(action, object)
          setup(object) do
            if SUPPORTED_ACTIONS.include?(action)
              unless labeled?(:endpoint)
                if (self.action = form_action_for_binding(action, object))
                  self.method = method_for_action(action)
                end
              end
            else
              raise ArgumentError.new("expected action to be one of: #{SUPPORTED_ACTIONS.join(", ")}")
            end
          end
        end

        def use_binding_nodes
          view.object.set_label(:bound, true)

          view.object.each_significant_node(:binding, descend: true) do |object|
            if Pakyow::Presenter::Views::Form::FIELD_TAGS.include?(object.tagname)
              object.set_label(:bound, true)
            end
          end
        end

        def use_global_options
          __getobj__.class.__global_options.each do |form_binding, options|
            form = if view.object.tagname == "form" && view.binding_name == form_binding
              self
            else
              form(form_binding)
            end

            if form
              options.each do |field_binding, metadata|
                if metadata[:block]
                  form.options_for(field_binding) do |context|
                    instance_exec(context, &metadata[:block])
                  end
                else
                  form.options_for(field_binding, metadata[:options])
                end
              end
            end
          end
        end

        def form_action_for_binding(action, object)
          [
            Support.inflector.singularize(label(:binding)).to_sym,
            Support.inflector.pluralize(label(:binding)).to_sym
          ].map { |possible_endpoint_name|
            params = Hash[label(:binding_info).to_h.map { |key, value|
              [:"#{key}_id", value]
            }].merge(object.to_h)

            endpoint_path = label(:binding_info).to_h.keys.map { |key|
              Support.inflector.pluralize(key).to_sym
            }

            endpoint_path << possible_endpoint_name
            endpoint_path.uniq!

            app.endpoints.path_to(*endpoint_path, action, **params)
          }.compact.first
        end

        def method_for_action(action)
          ACTION_METHODS[action]
        end

        def method_override_required?(method)
          method != "get" && method != "post"
        end

        def method_override_input
          html_safe("<input type=\"hidden\" name=\"pw-http-method\">")
        end

        def find_or_create_method_override_input
          unless (input = view.object.find_first_significant_node(:method_override))
            prepend(method_override_input)
            input = view.object.find_first_significant_node(:method_override)
          end

          input
        end

        def create_select_options(values, field_presenter)
          options = Oga::XML::Document.new

          Array.ensure(values).compact.each do |value|
            options.children << create_select_option(value, field_presenter)
          end

          add_options_to_select(options, field_presenter)
        end

        def create_grouped_select_options(values, field_presenter)
          options = Oga::XML::Document.new

          values.each do |group_name, grouped_values|
            group = Oga::XML::Element.new(name: "optgroup")
            group.set("label", ensure_html_safety(group_name.to_s))
            options.children << group

            grouped_values.each do |value|
              group.children << create_select_option(value, field_presenter)
            end
          end

          add_options_to_select(options, field_presenter)
        end

        def create_select_option(value, view)
          option_binding = if (option = view.object.find_first_significant_node(:option))
            option.label(:binding)
          end

          option_node = Oga::XML::Element.new(name: "option")
          option_node.set("value", ensure_html_safety(option_value(value, view).to_s))

          display_value = if value.is_a?(Array)
            value[1]
          elsif option_binding && value.respond_to?(:[])
            value[option_binding.to_sym]
          end

          option_node.inner_text = ensure_html_safety(display_value.to_s)
          option_node
        end

        def create_input_options(values, field_presenter)
          if values.is_a?(Array) && field_presenter.attributes[:type] != "radio"
            self.class.pluralize_field_name(field_presenter.object)
          end

          values = Array.ensure(values).compact

          if values.any?
            field_view = field_presenter.view
            field_template = field_view.soft_copy
            insertable_field = field_view
            current_field = field_view

            values.each do |value|
              current_field.attributes[:value] = option_value(value, field_presenter.view).to_s

              unless current_field.equal?(field_view)
                insertable_field.after(current_field)
                insertable_field = current_field
              end

              current_field = field_template.soft_copy
            end
          else
            field_presenter.remove
          end
        end

        def create_options(original_values, field_presenter)
          values = Array.ensure(original_values).compact

          if values.any?
            field_view = field_presenter.view
            template = field_view.soft_copy
            insertable = field_view
            current = field_view

            values.each do |value|
              if treat_as_nested?(current, value)
                # We bind to a view here to avoid checking the value when setting up the option.
                #
                View.from_object(current.object).bind(value)

                # Set the field names appropriately.
                #
                current.object.each_significant_node(:field) do |field|
                  name = "#{view.object.label(:binding)}[#{current.label(:binding)}]"
                  name = if original_values.is_a?(Array)
                    "#{name}[][#{field.label(:binding)}]"
                  else
                    "#{name}[#{field.label(:binding)}]"
                  end

                  field.attributes[:name] = name
                end

                # Insert a hidden field to identify the data on submission.
                #
                if (key = option_value_keys(current, value).find { |k| value.include?(k) })
                  id_input = Oga::XML::Element.new(name: "input")
                  id_input.set(:type, "hidden")

                  name = "#{view.object.label(:binding)}[#{current.label(:binding)}]"
                  name = if original_values.is_a?(Array)
                    "#{name}[][#{key}]"
                  else
                    "#{name}[#{key}]"
                  end

                  id_input.set(:name, name)
                  id_input.set(:value, presentables[:__verifier].sign(
                    ensure_html_safety(value[key].to_s)
                  ))

                  current.prepend(html_safe(id_input.to_xml))
                end
              else
                if (input = current.object.find_first_significant_node(:field))
                  input.attributes[:name] = "#{view.object.label(:binding)}[#{current.label(:binding)}]"

                  if original_values.is_a?(Array) && input.attributes[:type] != "radio"
                    self.class.pluralize_field_name(input)
                  end

                  input.attributes[:value] = ensure_html_safety(option_value(value, current).to_s)
                end

                if (label = current.object.find_first_significant_node(:label))
                  label.html = ensure_html_safety(label_value(value, label).to_s)
                  label.set_label(:bound, true)
                end

                if input && label
                  self.class.connect_input_to_label(input, label)
                end
              end

              unless current.equal?(field_view)
                insertable.after(current)
                insertable = current
              end

              current.object.set_label(:bound, true)
              current = template.soft_copy
            end
          else
            field_presenter.remove
          end
        end

        def treat_as_nested?(view, value)
          unless value.is_a?(Array)
            keys = option_value_keys(view, value, false)
            view.object.each_significant_node(:field) do |field|
              return true if field.labeled?(:binding) && !keys.include?(field.label(:binding))
            end
          end

          false
        end

        def option_value(value, view)
          if value.is_a?(Array)
            value[0]
          elsif value.is_a?(String)
            value
          elsif value.respond_to?(:[])
            option_value_keys(view, value).each do |key|
              if value.include?(key)
                return value[key]
              end
            end

            nil
          else
            value.to_s
          end
        end

        def label_value(value, view)
          if value.is_a?(Array)
            value[1]
          elsif value.is_a?(String)
            value
          elsif view.labeled?(:binding) && value.respond_to?(:[])
            value[view.label(:binding)]
          end
        end

        def option_value_keys(view, value, include_binding_prop = true)
          keys = []

          if include_binding_prop
            keys << view.object.label(:binding_prop)
          end

          if value.class.respond_to?(:primary_key_field)
            keys << value.class.primary_key_field
          end

          keys << :id
          keys.compact
        end

        def options_for_allowed?(field_presenter)
          field_presenter.object.tagname == "select" || (
            field_presenter.object.tagname == "input" && (
              field_presenter.object.attributes[:type] == "checkbox" || field_presenter.object.attributes[:type] == "radio"
            ) ||
            field_presenter.object.significant?(:binding)
          )
        end

        def grouped_options_for_allowed?(field_presenter)
          field_presenter.object.tagname == "select"
        end

        def add_options_to_select(options, field_presenter)
          # remove existing options
          field_presenter.clear

          # add generated options
          field_presenter.append(html_safe(options.to_xml))
        end
      end
    end
  end
end
