require 'roxml/hash_definition'

class Module
  def bool_attr_reader(*attrs)
    attrs.each do |attr|
      define_method :"#{attr}?" do
        instance_variable_get(:"@#{attr}") || false
      end
    end
  end
end

module ROXML
  class ContradictoryNamespaces < StandardError
  end

  class Definition # :nodoc:
    attr_reader :name, :sought_type, :wrapper, :hash, :blocks, :accessor, :to_xml, :attr_name, :namespace
    bool_attr_reader :name_explicit, :array, :cdata, :required, :frozen

    def initialize(sym, opts = {}, &block)
      opts.assert_valid_keys(:from, :in, :as, :namespace,
                             :else, :required, :frozen, :cdata, :to_xml)
      @default = opts.delete(:else)
      @to_xml = opts.delete(:to_xml)
      @name_explicit = opts.has_key?(:from) && opts[:from].is_a?(String)
      @cdata = opts.delete(:cdata)
      @required = opts.delete(:required)
      @frozen = opts.delete(:frozen)
      @wrapper = opts.delete(:in)
      @namespace = opts.delete(:namespace)

      @accessor = sym.to_s
      opts[:as] ||=
        if @accessor.ends_with?('?')
          :bool
        elsif @accessor.ends_with?('_on')
          Date
        elsif @accessor.ends_with?('_at')
          DateTime
        end

      @array = opts[:as].is_a?(Array)
      @blocks = collect_blocks(block, opts[:as])

      @sought_type = extract_type(opts[:as])
      if @sought_type.respond_to?(:roxml_tag_name)
        opts[:from] ||= @sought_type.roxml_tag_name
      end

      if opts[:from] == :content
        opts[:from] = '.'
      elsif opts[:from] == :name
        opts[:from] = '*'
      elsif opts[:from] == :attr
        @sought_type = :attr
        opts[:from] = nil
      elsif opts[:from] == :namespace
        opts[:from] = '*'
        @sought_type = :namespace
      elsif opts[:from].to_s.starts_with?('@')
        @sought_type = :attr
        opts[:from] = opts[:from].sub('@', '')
      end

      @name = @attr_name = accessor.to_s.chomp('?')
      @name = @name.singularize if hash? || array?
      @name = (opts[:from] || @name).to_s
      if hash? && (hash.key.name? || hash.value.name?)
        @name = '*'
      end
      raise ContradictoryNamespaces if @name.include?(':') && (@namespace.present? || @namespace == false)

      raise ArgumentError, "Can't specify both :else default and :required" if required? && @default
    end

    def instance_variable_name
      :"@#{attr_name}"
    end

    def setter
      :"#{attr_name}="
    end

    def hash
      if hash?
        @sought_type.wrapper ||= name
        @sought_type
      end
    end

    def hash?
      @sought_type.is_a?(HashDefinition)
    end

    def name?
      @name == '*'
    end

    def content?
      @name == '.'
    end

    def default
      if @default.nil?
        @default = [] if array?
        @default = {} if hash?
      end
      @default.duplicable? ? @default.dup : @default
    end

    def to_ref(inst)
      case sought_type
      when :attr          then XMLAttributeRef
      when :text          then XMLTextRef
      when :namespace     then XMLNameSpaceRef
      when HashDefinition then XMLHashRef
      when Symbol         then raise ArgumentError, "Invalid type argument #{sought_type}"
      else                     XMLObjectRef
      end.new(self, inst)
    end

  private
    def self.all(items, &block)
      array = items.is_a?(Array)
      results = (array ? items : [items]).map do |item|
        yield item
      end

      array ? results : results.first
    end

    def self.fetch_bool(value, default)
      value = value.to_s.downcase
      if %w{true yes 1 t}.include? value
        true
      elsif %w{false no 0 f}.include? value
        false
      else
        default
      end
    end

    CORE_BLOCK_SHORTHANDS = {
      # Core Shorthands
      Integer  => lambda do |val|
        all(val) do |v|
          v.to_i unless v.blank?
        end
      end,
      Float    => lambda do |val|
        all(val) do |v|
          Float(v) unless v.blank?
        end
      end,
      Time     => lambda do |val|
        all(val) {|v| Time.parse(v) unless v.blank? }
      end,

      :bool    => nil,
      :bool_standalone => lambda do |val|
        all(val) do |v|
          fetch_bool(v, nil)
        end
      end,
      :bool_combined => lambda do |val|
        all(val) do |v|
          fetch_bool(v, v)
        end
      end
    }

    def self.block_shorthands
      # dynamically load these shorthands at class definition time, but
      # only if they're already availbable
      CORE_BLOCK_SHORTHANDS.tap do |blocks|
        blocks.reverse_merge!(BigDecimal => lambda do |val|
          all(val) do |v|
            BigDecimal(v) unless v.blank?
          end
        end) if defined?(BigDecimal)

        blocks.reverse_merge!(DateTime => lambda do |val|
          if defined?(DateTime)
            all(val) {|v| DateTime.parse(v) unless v.blank? }
          end
        end) if defined?(DateTime)

        blocks.reverse_merge!(Date => lambda do |val|
          if defined?(Date)
            all(val) {|v| Date.parse(v) unless v.blank? }
          end
        end) if defined?(Date)
      end
    end

    def collect_blocks(block, as)
      if as.is_a?(Array)
        if as.size > 1
          raise ArgumentError, "multiple :as types (#{as.map(&:inspect).join(', ')}) is not supported.  Use a block if you want more complicated behavior."
        end

        as = as.first
      end

      if as == :bool
        # if a second block is present, and we can't coerce the xml value
        # to bool, we need to be able to pass it to the user-provided block
        as = (block ? :bool_combined : :bool_standalone)
      end
      as = self.class.block_shorthands.fetch(as) do
        unless (as == :text) || as.respond_to?(:from_xml) || (as.respond_to?(:first) && as.first.respond_to?(:from_xml)) || (as.is_a?(Hash) && !(as.keys & [:key, :value]).empty?)
          raise ArgumentError, "Invalid :as argument #{as}" unless as.nil?
        end
        nil
      end
      [as, block].compact
    end

    def extract_type(as)
      if as.is_a?(Hash)
        return HashDefinition.new(as)
      elsif as.respond_to?(:from_xml)
        return as
      elsif as.is_a?(Array) && as.first.respond_to?(:from_xml)
        @array = true
        return as.first
      else
        :text
      end
    end
  end
end
