# frozen_string_literal: true
require 'mechanize/element_matcher'

# This class encapsulates a form parsed out of an HTML page.  Each type of
# input field available in a form can be accessed through this object.
#
# == Examples
#
# Find a form and print out its fields
#
#   form = page.forms.first # => Mechanize::Form
#   form.fields.each { |f| puts f.name }
#
# Set the input field 'name' to "Aaron"
#
#   form['name'] = 'Aaron'
#   puts form['name']

class Mechanize::Form
  extend Forwardable
  extend Mechanize::ElementMatcher

  attr_accessor :method, :action, :name

  attr_reader :fields, :buttons, :file_uploads, :radiobuttons, :checkboxes

  # Content-Type for form data (i.e. application/x-www-form-urlencoded)
  attr_accessor :enctype

  # Character encoding of form data (i.e. UTF-8)
  attr_accessor :encoding

  # When true, character encoding errors will never be never raised on form
  # submission.  Default is false
  attr_accessor :ignore_encoding_error

  alias :elements :fields

  attr_reader :node
  alias form_node node  # for backward compatibility
  attr_reader :page

  def initialize(node, mech = nil, page = nil)
    @enctype = node['enctype'] || 'application/x-www-form-urlencoded'
    @node             = node
    @action           = Mechanize::Util.html_unescape(node['action'])
    @method           = (node['method'] || 'GET').upcase
    @name             = node['name']
    @clicked_buttons  = []
    @page             = page
    @mech             = mech

    @encoding = node['accept-charset'] || (page && page.encoding) || nil
    @ignore_encoding_error = false
    parse
  end

  # Returns whether or not the form contains a field with +field_name+
  def has_field?(field_name)
    fields.any? { |f| f.name == field_name }
  end

  alias :has_key? :has_field?

  # Returns whether or not the form contains a field with +value+
  def has_value?(value)
    fields.any? { |f| f.value == value }
  end

  # Returns all field names (keys) for this form
  def keys
    fields.map(&:name)
  end

  # Returns all field values for this form
  def values
    fields.map(&:value)
  end

  # Returns all buttons of type Submit
  def submits
    @submits ||= buttons.select { |f| f.class == Submit }
  end

  # Returns all buttons of type Reset
  def resets
    @resets ||= buttons.select { |f| f.class == Reset }
  end

  # Returns all fields of type Text
  def texts
    @texts ||= fields.select { |f| f.class == Text }
  end

  # Returns all fields of type Hidden
  def hiddens
    @hiddens ||= fields.select { |f| f.class == Hidden }
  end

  # Returns all fields of type Textarea
  def textareas
    @textareas ||= fields.select { |f| f.class == Textarea }
  end

  # Returns all fields of type Keygen
  def keygens
    @keygens ||= fields.select { |f| f.class == Keygen }
  end

  # Returns whether or not the form contains a Submit button named +button_name+
  def submit_button?(button_name)
    submits.find { |f| f.name == button_name }
  end

  # Returns whether or not the form contains a Reset button named +button_name+
  def reset_button?(button_name)
    resets.find { |f| f.name == button_name }
  end

  # Returns whether or not the form contains a Text field named +field_name+
  def text_field?(field_name)
    texts.find { |f| f.name == field_name }
  end

  # Returns whether or not the form contains a Hidden field named +field_name+
  def hidden_field?(field_name)
    hiddens.find { |f| f.name == field_name }
  end

  # Returns whether or not the form contains a Textarea named +field_name+
  def textarea_field?(field_name)
    textareas.find { |f| f.name == field_name }
  end

  # This method is a shortcut to get form's DOM id.
  # Common usage:
  #   page.form_with(:dom_id => "foorm")
  # Note that you can also use +:id+ to get to this method:
  #   page.form_with(:id => "foorm")
  def dom_id
    @node['id']
  end

  # This method is a shortcut to get form's DOM class.
  # Common usage:
  #   page.form_with(:dom_class => "foorm")
  # Note that you can also use +:class+ to get to this method:
  #   page.form_with(:class => "foorm")
  # However, attribute values are compared literally as string, so
  # form_with(class: "a") does not match a form with class="a b".
  # Use form_with(css: "form.a") instead.
  def dom_class
    @node['class']
  end

  ##
  # :method: search
  #
  # Shorthand for +node.search+.
  #
  # See Nokogiri::XML::Node#search for details.

  ##
  # :method: css
  #
  # Shorthand for +node.css+.
  #
  # See also Nokogiri::XML::Node#css for details.

  ##
  # :method: xpath
  #
  # Shorthand for +node.xpath+.
  #
  # See also Nokogiri::XML::Node#xpath for details.

  ##
  # :method: at
  #
  # Shorthand for +node.at+.
  #
  # See also Nokogiri::XML::Node#at for details.

  ##
  # :method: at_css
  #
  # Shorthand for +node.at_css+.
  #
  # See also Nokogiri::XML::Node#at_css for details.

  ##
  # :method: at_xpath
  #
  # Shorthand for +node.at_xpath+.
  #
  # See also Nokogiri::XML::Node#at_xpath for details.

  def_delegators :node, :search, :css, :xpath, :at, :at_css, :at_xpath

  # Add a field with +field_name+ and +value+
  def add_field!(field_name, value = nil)
    fields << Field.new({'name' => field_name}, value)
  end

  ##
  # This method sets multiple fields on the form.  It takes a list of +fields+
  # which are name, value pairs.
  #
  # If there is more than one field found with the same name, this method will
  # set the first one found.  If you want to set the value of a duplicate
  # field, use a value which is a Hash with the key as the index in to the
  # form.  The index is zero based.
  #
  # For example, to set the second field named 'foo', you could do the
  # following:
  #
  #   form.set_fields :foo => { 1 => 'bar' }
  def set_fields fields = {}
    fields.each do |name, v|
      case v
      when Hash
        v.each do |index, value|
          self.fields_with(:name => name.to_s)[index].value = value
        end
      else
        value = nil
        index = 0

        [v].flatten.each do |val|
          index = val.to_i if value
          value = val unless value
        end

        self.fields_with(:name => name.to_s)[index].value = value
      end
    end
  end

  # Fetch the value of the first input field with the name passed in. Example:
  #  puts form['name']
  def [](field_name)
    f = field(field_name)
    f && f.value
  end

  # Set the value of the first input field with the name passed in. Example:
  #  form['name'] = 'Aaron'
  def []=(field_name, value)
    f = field(field_name)
    if f
      f.value = value
    else
      add_field!(field_name, value)
    end
  end

  # Treat form fields like accessors.
  def method_missing(meth, *args)
    method = meth.to_s.chomp('=')

    if field(method)
      return field(method).value if args.empty?
      return field(method).value = args[0]
    end

    super
  end

  # Submit the form. Does not include the +button+ as a form parameter.
  # Use +click_button+ or provide button as a parameter.
  def submit button = nil, headers = {}
    @mech.submit(self, button, headers)
  end

  # Submit form using +button+. Defaults
  # to the first button.
  def click_button(button = buttons.first)
    submit(button)
  end

  # This method is sub-method of build_query.
  # It converts charset of query value of fields into expected one.
  def proc_query(field)
    return unless field.query_value
    field.query_value.map{|(name, val)|
      [from_native_charset(name), from_native_charset(val.to_s)]
    }
  end
  private :proc_query

  def from_native_charset str
    Mechanize::Util.from_native_charset(str, encoding, @ignore_encoding_error,
                                        @mech && @mech.log)
  end
  private :from_native_charset

  # This method builds an array of arrays that represent the query
  # parameters to be used with this form.  The return value can then
  # be used to create a query string for this form.
  def build_query(buttons = [])
    query = []
    @mech.log.info("form encoding: #{encoding}") if @mech && @mech.log

    save_hash_field_order

    successful_controls = []

    (fields + checkboxes).reject do |f|
      f.node["disabled"] || f.node["name"] == ""
    end.sort.each do |f|
      case f
      when Mechanize::Form::CheckBox
        if f.checked
          successful_controls << f
        end
      when Mechanize::Form::Field
        successful_controls << f
      end
    end

    radio_groups = {}
    radiobuttons.each do |f|
      fname = from_native_charset(f.name)
      radio_groups[fname] ||= []
      radio_groups[fname] << f
    end

    # take one radio button from each group
    radio_groups.each_value do |g|
      checked = g.select(&:checked)

      if checked.uniq.size > 1 then
        values = checked.map(&:value).join(', ').inspect
        name = checked.first.name.inspect
        raise Mechanize::Error,
              "radiobuttons #{values} are checked in the #{name} group, " \
              "only one is allowed"
      else
        successful_controls << checked.first unless checked.empty?
      end
    end

    @clicked_buttons.each { |b|
      successful_controls << b
    }

    successful_controls.sort.each do |ctrl| # DOM order
      qval = proc_query(ctrl)
      query.push(*qval)
    end

    query
  end

  # This method adds an index to all fields that have Hash nodes. This
  # enables field sorting to maintain order.
  def save_hash_field_order
    index = 0

    fields.each do |field|
      if Hash === field.node
        field.index = index
        index += 1
      end
    end
  end

  # This method adds a button to the query.  If the form needs to be
  # submitted with multiple buttons, pass each button to this method.
  def add_button_to_query(button)
    unless button.node.document == @node.document then
      message =
        "#{button.inspect} does not belong to the same page as " \
        "the form #{@name.inspect} in #{@page.uri}"

      raise ArgumentError, message
    end

    @clicked_buttons << button
  end

  # This method allows the same form to be submitted second time
  # with the different submit button being clicked.
  def reset
    # In the future, should add more functionality here to reset the form values to their defaults.
    @clicked_buttons = []
  end

  CRLF = "\r\n".freeze

  # This method calculates the request data to be sent back to the server
  # for this form, depending on if this is a regular post, get, or a
  # multi-part post,
  def request_data
    query_params = build_query()

    case @enctype.downcase
    when /^multipart\/form-data/
      boundary = rand_string(20)
      @enctype = "multipart/form-data; boundary=#{boundary}"

      delimiter = "--#{boundary}\r\n"

      data = ::String.new

      query_params.each do |k,v|
        if k
          data << delimiter
          param_to_multipart(k, v, data)
        end
      end

      @file_uploads.each do |f|
        data << delimiter
        file_to_multipart(f, data)
      end

      data << "--#{boundary}--\r\n"
    else
      Mechanize::Util.build_query_string(query_params)
    end
  end

  # Removes all fields with name +field_name+.
  def delete_field!(field_name)
    @fields.delete_if{ |f| f.name == field_name}
  end

  ##
  # :method: field_with(criteria)
  #
  # Find one field that matches +criteria+
  # Example:
  #   form.field_with(:id => "exact_field_id").value = 'hello'

  ##
  # :method: field_with!(criteria)
  #
  # Same as +field_with+ but raises an ElementNotFoundError if no field matches
  # +criteria+

  ##
  # :method: fields_with(criteria)
  #
  # Find all fields that match +criteria+
  # Example:
  #   form.fields_with(:value => /foo/).each do |field|
  #     field.value = 'hello!'
  #   end

  elements_with :field

  ##
  # :method: button_with(criteria)
  #
  # Find one button that matches +criteria+
  # Example:
  #   form.button_with(:value => /submit/).value = 'hello'

  ##
  # :method: button_with!(criteria)
  #
  # Same as +button_with+ but raises an ElementNotFoundError if no button
  # matches +criteria+

  ##
  # :method: buttons_with(criteria)
  #
  # Find all buttons that match +criteria+
  # Example:
  #   form.buttons_with(:value => /submit/).each do |button|
  #     button.value = 'hello!'
  #   end

  elements_with :button

  ##
  # :method: file_upload_with(criteria)
  #
  # Find one file upload field that matches +criteria+
  # Example:
  #   form.file_upload_with(:file_name => /picture/).value = 'foo'

  ##
  # :method: file_upload_with!(criteria)
  #
  # Same as +file_upload_with+ but raises an ElementNotFoundError if no button
  # matches +criteria+

  ##
  # :method: file_uploads_with(criteria)
  #
  # Find all file upload fields that match +criteria+
  # Example:
  #   form.file_uploads_with(:file_name => /picutre/).each do |field|
  #     field.value = 'foo!'
  #   end

  elements_with :file_upload

  ##
  # :method: radiobutton_with(criteria)
  #
  # Find one radio button that matches +criteria+
  # Example:
  #   form.radiobutton_with(:name => /woo/).check

  ##
  # :method: radiobutton_with!(criteria)
  #
  # Same as +radiobutton_with+ but raises an ElementNotFoundError if no button
  # matches +criteria+

  ##
  # :method: radiobuttons_with(criteria)
  #
  # Find all radio buttons that match +criteria+
  # Example:
  #   form.radiobuttons_with(:name => /woo/).each do |field|
  #     field.check
  #   end

  elements_with :radiobutton

  ##
  # :method: checkbox_with(criteria)
  #
  # Find one checkbox that matches +criteria+
  # Example:
  #   form.checkbox_with(:name => /woo/).check

  ##
  # :method: checkbox_with!(criteria)
  #
  # Same as +checkbox_with+ but raises an ElementNotFoundError if no button
  # matches +criteria+

  ##
  # :method: checkboxes_with(criteria)
  #
  # Find all checkboxes that match +criteria+
  # Example:
  #   form.checkboxes_with(:name => /woo/).each do |field|
  #     field.check
  #   end

  elements_with :checkbox,   :checkboxes

  def pretty_print(q) # :nodoc:
    q.object_group(self) {
      q.breakable; q.group(1, '{name', '}') { q.breakable; q.pp name }
      q.breakable; q.group(1, '{method', '}') { q.breakable; q.pp method }
      q.breakable; q.group(1, '{action', '}') { q.breakable; q.pp action }
      q.breakable; q.group(1, '{fields', '}') {
        fields.each do |field|
          q.breakable
          q.pp field
        end
      }
      q.breakable; q.group(1, '{radiobuttons', '}') {
        radiobuttons.each { |b| q.breakable; q.pp b }
      }
      q.breakable; q.group(1, '{checkboxes', '}') {
        checkboxes.each { |b| q.breakable; q.pp b }
      }
      q.breakable; q.group(1, '{file_uploads', '}') {
        file_uploads.each { |b| q.breakable; q.pp b }
      }
      q.breakable; q.group(1, '{buttons', '}') {
        buttons.each { |b| q.breakable; q.pp b }
      }
    }
  end

  alias inspect pretty_inspect # :nodoc:

  private

  def parse
    @fields       = []
    @buttons      = []
    @file_uploads = []
    @radiobuttons = []
    @checkboxes   = []

    # Find all input tags
    @node.search('input').each do |node|
      type = (node['type'] || 'text').downcase
      name = node['name']
      next if name.nil? && !%w[submit button image].include?(type)
      case type
      when 'radio'
        @radiobuttons << RadioButton.new(node, self)
      when 'checkbox'
        @checkboxes << CheckBox.new(node, self)
      when 'file'
        @file_uploads << FileUpload.new(node, nil)
      when 'submit'
        @buttons << Submit.new(node)
      when 'button'
        @buttons << Button.new(node)
      when 'reset'
        @buttons << Reset.new(node)
      when 'image'
        @buttons << ImageButton.new(node)
      when 'hidden'
        @fields << Hidden.new(node, node['value'] || '')
      when 'text'
        @fields << Text.new(node, node['value'] || '')
      when 'textarea'
        @fields << Textarea.new(node, node['value'] || '')
      else
        @fields << Field.new(node, node['value'] || '')
      end
    end

    # Find all textarea tags
    @node.search('textarea').each do |node|
      next unless node['name']
      @fields << Textarea.new(node, node.inner_text)
    end

    # Find all select tags
    @node.search('select').each do |node|
      next unless node['name']
      if node.has_attribute? 'multiple'
        @fields << MultiSelectList.new(node)
      else
        @fields << SelectList.new(node)
      end
    end

    # Find all submit button tags
    # FIXME: what can I do with the reset buttons?
    @node.search('button').each do |node|
      type = (node['type'] || 'submit').downcase
      next if type == 'reset'
      @buttons << Button.new(node)
    end

    # Find all keygen tags
    @node.search('keygen').each do |node|
      @fields << Keygen.new(node, node['value'] || '')
    end
  end

  def rand_string(len = 10)
    chars = ("a".."z").to_a + ("A".."Z").to_a
    string = ::String.new
    1.upto(len) { |i| string << chars[rand(chars.size-1)] }
    string
  end

  def mime_value_quote(str)
    str.b.gsub(/(["\r\\])/, '\\\\\1')
  end

  def param_to_multipart(name, value, buf = ::String.new)
    buf <<
      "Content-Disposition: form-data; name=\"".freeze <<
      mime_value_quote(name) <<
      "\"\r\n\r\n".freeze <<
      value.b <<
      CRLF
  end

  def file_to_multipart(file, buf = ::String.new)
    file_name = file.file_name ? ::File.basename(file.file_name) : ''

    body = buf <<
           "Content-Disposition: form-data; name=\"".freeze <<
           mime_value_quote(file.name) <<
           "\"; filename=\"".freeze <<
           mime_value_quote(file_name) <<
           "\"\r\nContent-Transfer-Encoding: binary\r\n".freeze

    if file.file_data.nil? and file.file_name
      file.file_data = File.binread(file.file_name)
      file.mime_type =
        WEBrick::HTTPUtils.mime_type(file.file_name,
                                     WEBrick::HTTPUtils::DefaultMimeTypes)
    end

    if file.mime_type
      body << "Content-Type: ".freeze << file.mime_type << CRLF
    end

    body << CRLF

    if file_data = file.file_data
      if file_data.respond_to? :read
        body << file_data.read.force_encoding(Encoding::ASCII_8BIT)
      else
        body << file_data.b
      end
    end

    body << CRLF
  end
end

require 'mechanize/form/field'
require 'mechanize/form/button'
require 'mechanize/form/hidden'
require 'mechanize/form/text'
require 'mechanize/form/textarea'
require 'mechanize/form/submit'
require 'mechanize/form/reset'
require 'mechanize/form/file_upload'
require 'mechanize/form/keygen'
require 'mechanize/form/image_button'
require 'mechanize/form/multi_select_list'
require 'mechanize/form/option'
require 'mechanize/form/radio_button'
require 'mechanize/form/check_box'
require 'mechanize/form/select_list'
