require 'json'
require 'chronic'

class ParamValidation

  # Given a hash of data and a validation hash, check all the validations, raising an Error on the first invalid key
  # @raise [ValidationError] if one or more of the validations fail.
  def initialize(data, validations)
    errors = []
    validations.each do |key, validators|
      val = key === :root ? data : (data[key] || data[key.to_s] || data[key.to_sym])
      next if validators[:required].nil? && val.nil?
      validators.each do |name, arg|
        validator = @@validators[name]
        msg = validations[key][:message]
        next unless validator
        is_valid = @@validators[name].call(val, arg, data)
        msg_proc = @@messages[name]
        msg ||= @@messages[name].call({key: key, data: data, val: val, arg: arg}) if msg_proc
        errors.push({msg: msg, data: {key: key, val: val, name: name, msg: msg}}) unless is_valid
      end
    end
    if errors.length == 1
      raise ValidationError.new(errors[0][:msg], errors[0][:data])
    elsif errors.length > 1
      msg = errors.collect {|e| e[:msg]}.join('\n')
      raise ValidationError.new(msg, errors.collect{|e| e[:data]})
    end
  end

  def self.messages; @@messages; end
  def self.set_message(name, &block)
    @@messages[name] = block
  end

  def self.validators; @@validators; end
  def self.add_validator(name, &block)
    @@validators[name] = block
  end
  def self.structure_validators; @@structure_validators; end
  def self.add_structure_validator(name, &block)
    @@structure_validators[name] = block
  end

  # In each Proc
  #  - val is the value we are actually validating from the data passed in
  #  - arg is the argument passed into the validator (eg for {required: true}, it is `true`)
  #  - data is the entire set of data
  @@validators = {
    required:  lambda {|val, arg, data| !val.nil?},
    absent: lambda {|val, arg, data| val.nil?},
    not_blank: lambda {|val, arg, data| val.is_a?(String) && val.length > 0},
    not_included_in: lambda {|val, arg, data| !arg.include?(val) rescue false},
    included_in: lambda {|val, arg, data| arg.include?(val) rescue false},
    format: lambda {|val, arg, data| val =~ arg rescue false},
    is_integer: lambda {|val, arg, data| val.is_a?(Integer) || val =~ /\A[+-]?\d+\Z/},
    is_float: lambda {|val, arg, data| val.is_a?(Float) || (!!Float(val) rescue false) },
    min_length: lambda {|val, arg, data| val.length >= arg rescue false},
    max_length: lambda {|val, arg, data| val.length <= arg rescue false},
    length_range: lambda {|val, arg, data| arg.cover?(val.length) rescue false},
    length_equals: lambda {|val, arg, data| val.length == arg},
    is_reference: lambda{|val, arg, data| (val.is_a?(Integer)&& val >=0) || val =~ /\A\d+\Z/ || val == ''},
    equals: lambda {|val, arg, data| val == arg},
    min: lambda {|val, arg, data| val >= arg rescue false},
    max: lambda {|val, arg, data| val <= arg rescue false},
    is_array: lambda {|val, arg, data| val.is_a?(Array)},
    is_hash: lambda {|val, arg, data| val.is_a?(Hash)},
    is_json: lambda {|val, arg, data| ParamValidation.is_valid_json?(val)},
    in_range: lambda {|val, arg, data| arg.cover?(val) rescue false},
    is_a: lambda {|val, arg, data| arg.kind_of?(Enumerable) ? arg.any? {|i| val.is_a?(i)} : val.is_a?(arg)},
    can_be_date: lambda {|val, arg, data| val.is_a?(Date) || val.is_a?(DateTime) || Chronic.parse(val)},
    array_of_hashes: lambda {|val, arg, data| data.is_a?(Array) && data.map{|pair| ParamValidation.new(pair.to_h, arg)}.all?}
  }

  @@messages = {
    required: lambda {|h| "#{h[:key]} is required"},
    absent: lambda {|h| "#{h[:key]} must not be present"},
    not_blank: lambda {|h| "#{h[:key]} must not be blank"},
    not_included_in: lambda {|h| "#{h[:key]} must not be included in #{h[:arg].join(", ")}"},
    included_in: lambda {|h|"#{h[:key]} must be one of #{h[:arg].join(", ")}"},
    format: lambda {|h|"#{h[:key]} doesn't have the right format"},
    is_integer: lambda {|h|"#{h[:key]} should be an integer"},
    is_float: lambda {|h|"#{h[:key]} should be a float"},
    min_length: lambda {|h|"#{h[:key]} has a minimum length of #{h[:arg]}"},
    max_length: lambda {|h|"#{h[:key]} has a maximum length of #{h[:arg]}"},
    length_range: lambda {|h|"#{h[:key]} should have a length within #{h[:arg]}"},
    length_equals: lambda {|h|"#{h[:key]} should have a length of #{h[:arg]}"},
    is_reference: lambda{|h| "#{h[:key]} should be an integer or blank"},
    equals: lambda {|h|"#{h[:key]} should equal #{h[:arg]}"},
    min: lambda {|h|"#{h[:key]} must be at least #{h[:arg]}"},
    max: lambda {|h|"#{h[:key]} cannot be more than #{h[:arg]}"},
    in_range: lambda {|h|"#{h[:key]} should be within #{h[:arg]}"},
    is_json: lambda {|h| "#{h[:key]} should be valid JSON"},
    is_hash: lambda {|h| "#{h[:key]} should be a hash"},
    is_a: lambda  {|h| "#{h[:key]} should be of the type(s): #{h[:arg].kind_of?(Enumerable) ? h[:arg].join(', '): h[:arg]}"},
    can_be_date: lambda  {|h| "#{h[:key]} should be a datetime or be parsable as one"},
    array_of_hashes: lambda {|h| "Please pass in an array of hashes"}
  }

  # small utility for testing json validity
  def self.is_valid_json?(str)
    begin
      JSON.parse(str)
      return true
    rescue => e
      return false
    end
  end

  # Special error class that holds all the error data for reference
  class ValidationError < TypeError
    attr_reader :data

    # @param [String] msg message for the validation error(s). Multiple error
    #   messages are split by new lines (\n)
    # @param [Hash, Array<Hash>] data information about the validation failure
    #   or failures. If one failure, a single failure hash is returned, if multiple, an array is returned.
    #   Each failure hash has the following:
    #     * :key - the [Symbol] of the key in the hash where verification failed
    #     * :val - the value of pair in the hash selected by :key
    #     * :name - the [Symbol] for the verification which failed
    #     * :msg - the [String] for the msg for the verifications which failed
    def initialize(msg, data)
      @data = data
      super(msg)
    end
  end

end