# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.  The
# ASF licenses this file to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance with the
# License.  You may obtain a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
# License for the specific language governing permissions and limitations
# under the License.
#

require_relative "../../deltacloud/core_ext"

module CIMI
  module Model
  end
end

# The smarts of converting from XML and JSON into internal objects
class CIMI::Model::Schema

  #
  # Attributes describe how we extract values from XML/JSON
  #
  class Attribute
    attr_reader :name, :xml_name, :json_name
    attr_reader :required

    def initialize(name, opts = {})
      @name = name
      @xml_name = opts[:xml_name] || name.to_s.camelize(true)
      @json_name = opts[:json_name] || name.to_s.camelize(true)
      @required = opts[:required] || false
    end

    def from_xml(xml, model)
      model[@name] = xml[@xml_name].first if xml.has_key?(@xml_name)
    end

    def from_json(json, model)
      model[@name] = json[@json_name]
    end

    def to_xml(model, xml)
      xml[@xml_name] = [model[@name]] if model[@name]
    end

    def to_json(model, json)
      json[@json_name] = model[@name] if model and model[@name]
    end

    def convert(value)
      value
    end

    def required?
      @required
    end

    def valid?(value)
      !value.nil? and !value.to_s.empty?
    end
  end

  class Scalar < Attribute
    def initialize(name, opts)
      @text = opts[:text]
      if ! [nil, :nested, :direct].include?(@text)
        raise "text option for scalar must be :nested or :direct"
      end
      super(name, opts)
    end

    def text?; @text; end

    def nested_text?; @text == :nested; end

    def from_xml(xml, model)
      case @text
        when :nested then model[@name] = xml[@xml_name].first["content"] if xml[@xml_name]
        when :direct then model[@name] = xml["content"]
        else model[@name] = xml[@xml_name]
      end
    end

    def to_xml(model, xml)
      return unless model
      return unless model[@name]
      case @text
        when :nested then xml[@xml_name] = [{ "content" => model[@name] }]
        when :direct then xml["content"] = model[@name]
        else xml[@xml_name] = model[@name]
      end
    end
  end

  class Struct < Attribute

    attr_accessor :schema

    def initialize(name, opts={}, &block)
      content = opts[:content]
      super(name, opts)
      if opts[:schema]
        if block_given?
          raise "Cannot provide :schema option and a block"
        end
        @schema = opts[:schema]
      else
        @schema = CIMI::Model::Schema.new
        @schema.instance_eval(&block) if block_given?
        @schema.scalar(content, :text => :direct, :required => opts[:required]) if content
      end
    end

    def from_xml(xml, model)
      xml = xml.has_key?(xml_name) ? xml[xml_name].first : {}
      model[name] = convert_from_xml(xml)
    end

    def from_json(json, model)
      json = json.has_key?(json_name) ? json[json_name] : {}
      model[name] = convert_from_json(json)
    end

    def to_xml(model, xml)
      conv = convert_to_xml(extract(model))
      xml[xml_name] = [conv] unless conv.empty?
    end

    def to_json(model, json)
      conv = convert_to_json(extract(model))
      json[json_name] = conv unless conv.empty?
    end

    def convert_from_xml(xml)
      sub = struct.new
      @schema.from_xml(xml, sub)
      sub
    end

    def convert_from_json(json)
      sub = struct.new
      @schema.from_json(json, sub)
      sub
    end

    def convert_to_xml(model)
      xml = ::Hash.new
      @schema.to_xml(model, xml)
      xml
    end

    def convert_to_json(model)
      json = {}
      @schema.to_json(model, json)
      json
    end

    def valid?(value)
      @schema.required_attributes.all? { |a|
        a.valid?(value.send(a.name))
      }
    end

    def convert(value)
      if @klass
        @klass.new(value || {})
      else
        super(value)
      end
    end

    private
    def struct
      if @klass
        @klass
      else
        @struct_class ||= ::Struct.new(nil, *@schema.attribute_names)
      end
    end
    def extract(model)
      if model.respond_to?("[]")
        model[name] || {}
      else
        {}
      end
    end

  end

  class Ref < CIMI::Model::Schema::Struct

    def initialize(name, opts={}, &block)
      raise 'The :class attribute must be set' unless opts[:class]
      refname = "#{opts[:class].name.split("::").last}Ref"
      if CIMI::Model::const_defined?(refname)
        @klass = CIMI::Model::const_get(refname)
      else
        @klass = Class.new(opts[:class]) do |m|
          scalar :href
        end
        CIMI::Model::const_set(refname, @klass)
      end
      @klass.class_eval { def href?; !href.nil?; end }
      opts[:schema] = @klass.schema
      super(name, opts, &block)
    end

    # The 'ref' could be the reference to a CIMI entity or the full CIMI
    # entity representation.
    #
    def valid?(value)
      !value.href.nil? || @klass.schema.required_attributes.all? { |a|
        a.valid?(value.send(a.name))
      }
    end
  end

  class Array < Attribute

    attr_accessor :struct

    # For an array :funThings, we collect all <funThing/> elements (XmlSimple
    # actually does the collecting)
    def initialize(name, opts = {}, &block)
      unless opts[:xml_name]
        opts[:xml_name] = name.to_s.singularize.camelize.uncapitalize
      end
      if opts[:ref] && block_given?
        raise "Provide only one of :ref or a block"
      end
      super(name, opts)
      if opts[:ref]
        @struct = Ref.new(name, :class=> opts[:ref])
      else
        @struct = Struct.new(name, opts, &block)
      end
    end

    def from_xml(xml, model)
      model[name] = (xml[xml_name] || []).map { |elt| @struct.convert_from_xml(elt) }
    end

    def from_json(json, model)
      model[name] = (json[json_name] || []).map { |elt| @struct.convert_from_json(elt) }
    end

    def to_xml(model, xml)
      ary = (model[name] || []).map { |elt| @struct.convert_to_xml(elt) }
      xml[xml_name] = ary unless ary.empty?
    end

    def to_json(model, json)
      ary = (model[name] || []).map { |elt| @struct.convert_to_json(elt) }
      json[json_name] = ary unless ary.empty?
    end
  end

  class Hash < Attribute

    def initialize(name, opts = {}, &block)
      opts[:json_name] = name.to_s.pluralize unless opts[:json_name]
      super(name, opts)
    end

    def from_xml(xml, model)
      model[name] = (xml[xml_name] || []).inject({}) do |result, item|
        result[item["key"]] = item["content"]
        result
      end
    end

    def from_json(json, model)
      model[name] = json[json_name] || {}
    end

    def to_xml(model, xml)
      mapped = extract(model).map { |k, v| { "key" => k, "content" => v } }
      xml[xml_name] = mapped unless mapped.empty?
    end

    def to_json(model, json)
      h = extract(model)
      json[json_name] = h unless h.empty?
    end

    private
    def extract(model)
      if model.respond_to?("[]")
        model[name] || {}
      else
        {}
      end
    end
  end

  class Collection < Attribute
    def initialize(name, opts = {})
      params = {}
      params[:scope] = opts.delete(:scope)
      super(name, opts)
      unless opts[:class]
        raise "Specify the class of collection entries using :class"
      end
      params[:embedded] = true
      unless opts[:class].collection_class
        opts[:class].collection_class = CIMI::Model::Collection.generate(opts[:class], params)
      end
      @collection_class = opts[:class].collection_class
    end

    def from_xml(xml, model)
      if xml[xml_name]
        model[name] = @collection_class.schema.from_xml(xml[xml_name].first, {})
      end
    end

    def from_json(json, model)
      if json[json_name]
        model[name] = @collection_class.schema.from_json(json[json_name], {})
      end
    end

    def to_xml(model, xml)
      return if model[name].nil?
      model[name].prepare
      if model[name].entries.empty?
        xml[xml_name] = { "href" => model[name].href }
      else
        xml[xml_name] = @collection_class.schema.to_xml(model[name])
      end
    end

    def to_json(model, json)
      return if model[name].nil?
      model[name].prepare
      if model[name].entries.empty?
        json[json_name] = { "href" => model[name].href }
      else
        json[json_name] = @collection_class.schema.to_json(model[name])
      end
    end

    # Convert a Hash or Array to an instance of the collection class
    def convert(value)
      if value.is_a?(::Array)
        @collection_class.new(:entries => value)
      else
        @collection_class.new(value || {})
      end
    end
  end

  #
  # The actual Schema class
  #

  attr_accessor :attributes

  def initialize
    @attributes = []
  end

  def deep_clone
    Marshal::load(Marshal.dump(self))
  end

  def collections
    @attributes.select { |a| a.is_a?(Collection) }
  end

  def convert(name, value)
    attr = @attributes.find { |a| a.name == name }
    raise "Unknown attribute #{name}" unless attr
    attr.convert(value)
  end

  def from_xml(xml, model = {})
    @attributes.freeze
    @attributes.each { |attr| attr.from_xml(xml, model) }
    model
  end

  def from_json(json, model = {})
    @attributes.freeze
    @attributes.each { |attr| attr.from_json(json, model) }
    model
  end

  def to_xml(model, xml = nil)
    xml ||= ::Hash.new
    @attributes.freeze
    model.prepare if model.respond_to?(:prepare)
    @attributes.each { |attr| attr.to_xml(model, xml) }
    xml
  end

  #For MachineCollection, copy over the schema of Machine to hold
  #each member of the collection - avoid duplicating the schemas
  def add_collection_member_array(model)
    member_symbol = model.name.split("::").last.underscore.pluralize.to_sym
    members = CIMI::Model::Schema::Array.new(member_symbol)
    members.struct.schema.attributes = model.schema.attributes
    self.attributes << members
  end

  def to_json(model, json = {})
    @attributes.freeze
    model.prepare if model.respond_to?(:prepare)
    @attributes.each { |attr| attr.to_json(model, json) }
    json
  end

  def attribute_names
    @attributes.map { |a| a.name }
  end

  def required_attributes
    @attributes.select { |a| a.required? }
  end

  #
  # The DSL
  #
  # Requires that the class into which this is included has a
  # +add_attributes!+ method
  module DSL

    def href(*args)
      opts = args.extract_opts!
      args.each { |arg| struct(arg, opts) { scalar :href, :required => opts[:required] } }
    end

    def text(*args)
      args.expand_opts!(:text => :nested)
      scalar(*args)
    end

    def scalar(*args)
      add_attributes!(args, Scalar)
    end

    def array(name, opts={}, &block)
      add_attributes!([name, opts], Array, &block)
    end

    def struct(name, opts={}, &block)
      add_attributes!([name, opts], Struct, &block)
    end

    def ref(name, opts={})
      unless opts[:class]
        s = name.to_s.camelize.gsub(/Config$/, "Configuration")
        opts[:class] = CIMI::Model::const_get(s)
      end
      add_attributes!([name, opts], Ref)
    end

    def hash_map(name)
      add_attributes!([name, {}], Hash)
    end

    def collection(name, opts={})
      opts[:scope] = self
      add_attributes!([name, opts], Collection)
    end
  end

  include DSL

  def add_attributes!(args, attr_klass, &block)
    raise "The schema has already been used to convert objects" if @attributes.frozen?
    opts = args.extract_opts!
    args.each { |arg| @attributes << attr_klass.new(arg, opts, &block) }
  end
end
