lib/lutaml/model/attribute.rb



module Lutaml
  module Model
    class Attribute
      attr_reader :name, :type, :options

      ALLOWED_OPTIONS = %i[
        raw
        default
        delegate
        collection
        values
        pattern
        transform
        choice
        sequence
        method_name
        polymorphic
        polymorphic_class
        initialize_empty
      ].freeze

      def initialize(name, type, options = {})
        @name = name
        @options = options

        validate_presence!(type, options[:method_name])
        process_type!(type) if type
        process_options!
      end

      def polymorphic?
        @options[:polymorphic_class]
      end

      def derived?
        type.nil?
      end

      def delegate
        @options[:delegate]
      end

      def transform
        @options[:transform] || {}
      end

      def method_name
        @options[:method_name]
      end

      def initialize_empty?
        @options[:initialize_empty]
      end

      def cast_type!(type)
        case type
        when Symbol
          begin
            Type.lookup(type)
          rescue UnknownTypeError
            raise ArgumentError, "Unknown Lutaml::Model::Type: #{type}"
          end
        when String
          begin
            Type.const_get(type)
          rescue NameError
            raise ArgumentError, "Unknown Lutaml::Model::Type: #{type}"
          end
        when Class
          type
        else
          raise ArgumentError, "Unknown Lutaml::Model::Type: #{type}"
        end
      end

      def cast_value(value)
        return type.cast(value) unless value.is_a?(Array)

        value.map { |v| type.cast(v) }
      end

      def setter
        :"#{@name}="
      end

      def collection?
        options[:collection] || false
      end

      def singular?
        !collection?
      end

      def raw?
        @raw
      end

      def enum?
        !enum_values.empty?
      end

      def default
        cast_value(default_value)
      end

      def default_value
        if delegate
          type.attributes[to].default
        elsif options[:default].is_a?(Proc)
          options[:default].call
        elsif options.key?(:default)
          options[:default]
        else
          Lutaml::Model::UninitializedClass.instance
        end
      end

      def default_set?
        !Utils.uninitialized?(default_value)
      end

      def pattern
        options[:pattern]
      end

      def enum_values
        @options.key?(:values) ? @options[:values] : []
      end

      def transform_import_method
        transform[:import]
      end

      def transform_export_method
        transform[:export]
      end

      def valid_value!(value)
        return true if value.nil? && singular?
        return true unless enum?
        return true if Utils.uninitialized?(value)

        unless valid_value?(value)
          raise Lutaml::Model::InvalidValueError.new(name, value, enum_values)
        end

        true
      end

      def valid_value?(value)
        return true unless options[:values]

        options[:values].include?(value)
      end

      def valid_pattern!(value)
        return true unless type == Lutaml::Model::Type::String
        return true unless pattern

        unless pattern.match?(value)
          raise Lutaml::Model::PatternNotMatchedError.new(name, pattern, value)
        end

        true
      end

      # Check if the value to be assigned is valid for the attribute
      #
      # Currently there are 2 validations
      #   1. Value should be from the values list if they are defined
      #      e.g values: ["foo", "bar"] is set then any other value for this
      #          attribute will raise `Lutaml::Model::InvalidValueError`
      #
      #   2. Value count should be between the collection range if defined
      #      e.g if collection: 0..5 is set then the value greater then 5
      #          will raise `Lutaml::Model::CollectionCountOutOfRangeError`
      def validate_value!(value)
        # Use the default value if the value is nil
        value = default if value.nil?

        valid_value!(value) &&
          valid_collection!(value, self) &&
          valid_pattern!(value) &&
          validate_polymorphic!(value)
      end

      def validate_polymorphic(value)
        return value.all? { |v| validate_polymorphic!(v) } if value.is_a?(Array)
        return true unless options[:polymorphic]

        valid_polymorphic_type?(value)
      end

      def validate_polymorphic!(value)
        return true if validate_polymorphic(value)

        raise Lutaml::Model::PolymorphicError.new(value, options, type)
      end

      def validate_collection_range
        range = @options[:collection]
        return if range == true

        unless range.is_a?(Range)
          raise ArgumentError, "Invalid collection range: #{range}"
        end

        validate_range!(range)
      end

      def validate_range!(range)
        if range.begin.nil?
          raise ArgumentError,
                "Invalid collection range: #{range}. Begin must be specified."
        end

        if range.begin.negative?
          raise ArgumentError,
                "Invalid collection range: #{range}. " \
                "Begin must be non-negative."
        end

        if range.end && range.end < range.begin
          raise ArgumentError,
                "Invalid collection range: #{range}. " \
                "End must be greater than or equal to begin."
        end
      end

      def valid_collection!(value, caller)
        raise Lutaml::Model::CollectionTrueMissingError.new(name, caller) if value.is_a?(Array) && !collection?

        return true unless collection?

        # Allow any value for unbounded collections
        return true if options[:collection] == true

        unless value.is_a?(Array)
          raise Lutaml::Model::CollectionCountOutOfRangeError.new(
            name,
            value,
            options[:collection],
          )
        end

        range = options[:collection]
        return true unless range.is_a?(Range)

        if range.end.nil?
          if value.size < range.begin
            raise Lutaml::Model::CollectionCountOutOfRangeError.new(
              name,
              value,
              range,
            )
          end
        elsif !range.cover?(value.size)
          raise Lutaml::Model::CollectionCountOutOfRangeError.new(
            name,
            value,
            range,
          )
        end
      end

      def serialize(value, format, options = {})
        value ||= [] if collection? && initialize_empty?
        return value if value.nil? || Utils.uninitialized?(value)
        return value if derived?
        return serialize_array(value, format, options) if value.is_a?(Array)
        return serialize_model(value, format, options) if type <= Serialize

        serialize_value(value, format)
      end

      def cast(value, format, options = {})
        value ||= [] if collection? && !value.nil?
        return value.map { |v| cast(v, format, options) } if value.is_a?(Array)

        return value if already_serialized?(type, value)

        klass = resolve_polymorphic_class(type, value, options)

        if can_serialize?(klass, value, format)
          klass.apply_mappings(value, format, options)
        elsif needs_conversion?(klass, value)
          klass.send(:"from_#{format}", value)
        else
          klass.cast(value)
        end
      end

      def serializable?
        type <= Serialize
      end

      def deep_dup
        self.class.new(name, type, Utils.deep_dup(options))
      end

      private

      def resolve_polymorphic_class(type, value, options)
        return type unless polymorphic_map_defined?(options, value)

        val = value[options[:polymorphic][:attribute]]
        klass_name = options[:polymorphic][:class_map][val]
        Object.const_get(klass_name)
      end

      def polymorphic_map_defined?(polymorphic_options, value)
        !value.nil? &&
          polymorphic_options[:polymorphic] &&
          !polymorphic_options[:polymorphic].empty? &&
          value[polymorphic_options[:polymorphic][:attribute]]
      end

      def castable?(value, format)
        value.is_a?(Hash) ||
          (format == :xml && value.is_a?(Lutaml::Model::Xml::XmlElement))
      end

      def castable_serialized_type?(value)
        type <= Serialize && value.is_a?(type.model)
      end

      def can_serialize?(klass, value, format)
        klass <= Serialize && castable?(value, format)
      end

      def needs_conversion?(klass, value)
        !value.nil? && !value.is_a?(klass)
      end

      def already_serialized?(klass, value)
        klass <= Serialize && value.is_a?(klass.model)
      end

      def serialize_array(value, format, options)
        value.map { |v| serialize(v, format, options) }
      end

      def serialize_model(value, format, options)
        return unless Utils.present?(value)
        return value.class.as(format, value, options) if value.is_a?(type)

        type.as(format, value, options)
      end

      def serialize_value(value, format)
        value = type.new(value) unless value.is_a?(Type::Value)
        value.send(:"to_#{format}")
      end

      def validate_presence!(type, method_name)
        return if type || method_name

        raise ArgumentError, "method or type must be set for an attribute"
      end

      def process_type!(type)
        validate_type!(type)
        @type = cast_type!(type)
      end

      def process_options!
        validate_options!(@options)
        @raw = !!@options[:raw]
        set_default_for_collection if collection?
      end

      def set_default_for_collection
        validate_collection_range
        @options[:default] ||= -> { [] } if initialize_empty?
      end

      def validate_options!(options)
        if (invalid_opts = options.keys - ALLOWED_OPTIONS).any?
          raise StandardError,
                "Invalid options given for `#{name}` #{invalid_opts}"
        end

        if options.key?(:pattern) && type != Lutaml::Model::Type::String
          raise StandardError,
                "Invalid option `pattern` given for `#{name}`, " \
                "`pattern` is only allowed for :string type"
        end

        if initialize_empty? && !collection?
          raise StandardError,
                "Invalid option `initialize_empty` given without `collection: true` option"
        end
        true
      end

      def validate_type!(type)
        return true if type.is_a?(Class)
        return true if [Symbol, String].include?(type.class) && cast_type!(type)

        raise ArgumentError,
              "Invalid type: #{type}, must be a Symbol, String or a Class"
      end

      def valid_polymorphic_type?(value)
        return value.is_a?(type) unless has_polymorphic_list?

        options[:polymorphic].include?(value.class) && value.is_a?(type)
      end

      def has_polymorphic_list?
        options[:polymorphic]&.is_a?(Array)
      end
    end
  end
end