lib/diaspora_federation/entity.rb
# frozen_string_literal: true

module DiasporaFederation
  # +Entity+ is the base class for all other objects used to encapsulate data
  # for federation messages in the diaspora* network.
  # Entity fields are specified using a simple {PropertiesDSL DSL} as part of
  # the class definition.
  #
  # Any entity also provides the means to serialize itself and all nested
  # entities to XML (for deserialization from XML to +Entity+ instances, see
  # {Salmon::XmlPayload}).
  #
  # @abstract Subclass and specify properties to implement various entities.
  #
  # @example Entity subclass definition
  #   class MyEntity < Entity
  #     property :prop
  #     property :optional, default: false
  #     property :dynamic_default, default: -> { Time.now }
  #     entity :nested, NestedEntity
  #     entity :multiple, [OtherEntity]
  #   end
  #
  # @example Entity instantiation
  #   nentity = NestedEntity.new
  #   oe1 = OtherEntity.new
  #   oe2 = OtherEntity.new
  #
  #   entity = MyEntity.new(prop: 'some property',
  #                         nested: nentity,
  #                         multiple: [oe1, oe2])
  #
  # @note Entity properties can only be set during initialization, after that the
  #   entity instance becomes frozen and must not be modified anymore. Instances
  #   are intended to be immutable data containers, only.
  class Entity
    extend PropertiesDSL
    include Logging

    # Invalid XML characters
    # @see https://www.w3.org/TR/REC-xml/#charsets "Extensible Markup Language (XML) 1.0"
    INVALID_XML_REGEX = /[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/.freeze

    # Regex to validate and find entity names
    ENTITY_NAME_REGEX = "[a-z]*(?:_[a-z]*)*"

    # Initializes the Entity with the given attribute hash and freezes the created
    # instance it returns.
    #
    # After creation, the entity is validated against a Validator, if one is defined.
    # The Validator needs to be in the {DiasporaFederation::Validators} namespace and
    # named like "<EntityName>Validator". Only valid entities can be created.
    #
    # @see DiasporaFederation::Validators
    #
    # @note Attributes not defined as part of the class definition ({PropertiesDSL#property},
    #       {PropertiesDSL#entity}) get discarded silently.
    #
    # @param [Hash] data entity data
    # @return [Entity] new instance
    def initialize(data)
      logger.debug "create entity #{self.class} with data: #{data}"
      raise ArgumentError, "expected a Hash" unless data.is_a?(Hash)

      entity_data = self.class.resolv_aliases(data)
      validate_missing_props(entity_data)

      self.class.default_values.merge(entity_data).each do |name, value|
        instance_variable_set("@#{name}", instantiate_nested(name, nilify(value))) if setable?(name, value)
      end

      freeze
      validate
    end

    # Returns a Hash representing this Entity (attributes => values).
    # Nested entities are also converted to a Hash.
    # @return [Hash] entity data (mostly equal to the hash used for initialization).
    def to_h
      enriched_properties.to_h {|key, value|
        type = self.class.class_props[key]

        if type.instance_of?(Symbol) || value.nil?
          [key, value]
        elsif type.instance_of?(Class)
          [key, value.to_h]
        elsif type.instance_of?(Array)
          [key, value.map(&:to_h)]
        end
      }
    end

    # Returns the XML representation for this entity constructed out of
    # {http://www.rubydoc.info/gems/nokogiri/Nokogiri/XML/Element Nokogiri::XML::Element}s
    #
    # @see Nokogiri::XML::Node.to_xml
    #
    # @return [Nokogiri::XML::Element] root element containing properties as child elements
    def to_xml
      doc = Nokogiri::XML::Document.new
      Nokogiri::XML::Element.new(self.class.entity_name, doc).tap do |root_element|
        xml_elements.each do |name, value|
          add_property_to_xml(doc, root_element, name, value)
        end
      end
    end

    # Construct a new instance of the given Entity and populate the properties
    # with the attributes found in the XML.
    # Works recursively on nested Entities and Arrays thereof.
    #
    # @param [Nokogiri::XML::Element] root_node xml nodes
    # @return [Entity] instance
    def self.from_xml(root_node)
      from_hash(*xml_parser_class.new(self).parse(root_node))
    end

    private_class_method def self.xml_parser_class
      DiasporaFederation::Parsers::XmlParser
    end

    # Creates an instance of self by parsing a hash in the format of JSON serialized object (which usually means
    # data from a parsed JSON input).
    def self.from_json(json_hash)
      from_hash(*json_parser_class.new(self).parse(json_hash))
    end

    private_class_method def self.json_parser_class
      DiasporaFederation::Parsers::JsonParser
    end

    # Makes an underscored, lowercase form of the class name
    #
    # @see .entity_class
    #
    # @return [String] entity name
    def self.entity_name
      class_name.tap do |word|
        word.gsub!(/(.)([A-Z])/, '\1_\2')
        word.downcase!
      end
    end

    # Transform the given String from the lowercase underscored version to a
    # camelized variant and returns the Class constant.
    #
    # @see .entity_name
    #
    # @param [String] entity_name "snake_case" class name
    # @return [Class] entity class
    def self.entity_class(entity_name)
      raise InvalidEntityName, "'#{entity_name}' is invalid" unless entity_name =~ /\A#{ENTITY_NAME_REGEX}\z/

      class_name = entity_name.sub(/\A[a-z]/, &:upcase)
      class_name.gsub!(/_([a-z])/) { Regexp.last_match[1].upcase }

      raise UnknownEntity, "'#{class_name}' not found" unless Entities.const_defined?(class_name)

      Entities.const_get(class_name)
    end

    # @return [String] class name as string
    def self.class_name
      name.rpartition("::").last
    end

    # @return [String] string representation of this object
    def to_s
      "#{self.class.class_name}#{":#{guid}" if respond_to?(:guid)}"
    end

    # Renders entity to a hash representation of the entity JSON format
    # @return [Hash] Returns a hash that is equal by structure to the entity in JSON format
    def to_json(*_args)
      {
        entity_type: self.class.entity_name,
        entity_data: json_data
      }
    end

    # Creates an instance of self, filling it with data from a provided hash of properties.
    #
    # The hash format is described as following:<br>
    # 1) Properties of the hash are representation of the entity's class properties<br>
    # 2) Keys of the hash must be of Symbol type<br>
    # 3) Possible values of the hash properties depend on the types of the entity's class properties<br>
    # 4) Basic properties, such as booleans, strings, integers and timestamps are represented by values of respective
    # formats<br>
    # 5) Nested hashes and arrays of hashes are allowed to represent nested entities. Nested hashes follow the same
    # format as the parent hash.<br>
    # 6) Besides, the nested entities can be passed in the hash as already instantiated objects of the respective type.
    #
    # @param [Hash] properties_hash A hash of the expected format
    # @return [Entity] an instance
    def self.from_hash(properties_hash)
      new(properties_hash)
    end

    private

    def validate_missing_props(entity_data)
      missing_props = self.class.missing_props(entity_data)
      return if missing_props.empty?

      obj_str = "#{self.class.class_name}#{":#{entity_data[:guid]}" if entity_data.has_key?(:guid)}" \
                "#{" from #{entity_data[:author]}" if entity_data.has_key?(:author)}"
      raise ValidationError, "#{obj_str}: Missing required properties: #{missing_props.join(', ')}"
    end

    def setable?(name, val)
      type = self.class.class_props[name]
      return false if type.nil? # property undefined

      setable_property?(type, val) || setable_nested?(type, val) || setable_multi?(type, val)
    end

    def setable_property?(type, val)
      setable_string?(type, val) || (type == :timestamp && val.is_a?(Time))
    end

    def setable_string?(type, val)
      %i[string integer boolean].include?(type) && val.respond_to?(:to_s)
    end

    def setable_nested?(type, val)
      type.instance_of?(Class) && type.ancestors.include?(Entity) && (val.is_a?(Entity) || val.is_a?(Hash))
    end

    def setable_multi?(type, val)
      type.instance_of?(Array) && val.instance_of?(Array) &&
        (val.all? {|v| v.instance_of?(type.first) } || val.all? {|v| v.instance_of?(Hash) })
    end

    def nilify(value)
      return nil if value.respond_to?(:empty?) && value.empty? && !value.instance_of?(Array)

      value
    end

    def instantiate_nested(name, value)
      if value.instance_of?(Array)
        return value unless value.first.instance_of?(Hash)

        value.map {|hash| self.class.class_props[name].first.new(hash) }
      elsif value.instance_of?(Hash)
        self.class.class_props[name].new(value)
      else
        value
      end
    end

    def validate
      validator_name = "#{self.class.name.split('::').last}Validator"
      return unless Validators.const_defined? validator_name

      validator_class = Validators.const_get validator_name
      validator = validator_class.new self
      raise ValidationError, error_message(validator) unless validator.valid?
    end

    def error_message(validator)
      errors = validator.errors.map do |prop, rule|
        "property: #{prop}, value: #{public_send(prop).inspect}, rule: #{rule[:rule]}, with params: #{rule[:params]}"
      end
      "Failed validation for #{self}#{" from #{author}" if respond_to?(:author)} for properties: #{errors.join(' | ')}"
    end

    # @return [Hash] hash with all properties
    def properties
      self.class.class_props.keys.each_with_object({}) do |prop, hash|
        hash[prop] = public_send(prop)
      end
    end

    def normalized_properties
      properties.to_h {|name, value| [name, normalize_property(name, value)] }
    end

    def normalize_property(name, value)
      return nil if optional_nil_value?(name, value)

      case self.class.class_props[name]
      when :string
        value.to_s.gsub(INVALID_XML_REGEX, "\uFFFD")
      when :timestamp
        value.nil? ? "" : value.utc.iso8601
      else
        value
      end
    end

    # default: nothing to enrich
    def enriched_properties
      normalized_properties
    end

    # default: no special order
    def xml_elements
      enriched_properties
    end

    def add_property_to_xml(doc, root_element, name, value)
      if [String, TrueClass, FalseClass, Integer].any? {|c| value.is_a? c }
        root_element << simple_node(doc, name, value.to_s)
      else
        # call #to_xml for each item and append to root
        [*value].compact.each do |item|
          child = item.to_xml
          root_element << child if child
        end
      end
    end

    # Create simple node, fill it with text and append to root
    def simple_node(doc, name, value)
      Nokogiri::XML::Element.new(name.to_s, doc).tap do |node|
        node.content = value unless value.empty?
      end
    end

    # Generates a hash with entity properties which is put to the "entity_data"
    # field of a JSON serialized object.
    # @return [Hash] object properties in JSON format
    def json_data # rubocop:disable Metrics/PerceivedComplexity
      enriched_properties.map {|key, value|
        type = self.class.class_props[key]
        next if optional_nil_value?(key, value)

        if !value.nil? && type.instance_of?(Class)
          entity_data = value.to_json
          [key, entity_data] unless entity_data.nil?
        elsif type.instance_of?(Array)
          entity_data = value&.map(&:to_json)
          [key, entity_data] unless entity_data.nil?
        else
          [key, value]
        end
      }.compact.to_h
    end

    def optional_nil_value?(name, value)
      value.nil? && self.class.optional_props.include?(name)
    end

    # Raised, if entity is not valid
    class ValidationError < RuntimeError
    end

    # Raised, if the entity name in the XML is invalid
    class InvalidEntityName < RuntimeError
    end

    # Raised, if the entity contained within the XML cannot be mapped to a
    # defined {Entity} subclass.
    class UnknownEntity < RuntimeError
    end
  end
end
