class GenericObjectDefinition < ApplicationRecord
  include YamlImportExportMixin
  include ImportExport

  TYPE_MAP = {
    :boolean  => ActiveModel::Type::Boolean.new,
    :datetime => ActiveModel::Type::DateTime.new,
    :float    => ActiveModel::Type::Float.new,
    :integer  => ActiveModel::Type::Integer.new,
    :string   => ActiveModel::Type::String.new,
    :time     => ActiveModel::Type::Time.new
  }.freeze

  TYPE_NAMES = {
    :boolean  => N_('Boolean'),
    :datetime => N_('Date/Time'),
    :float    => N_('Float'),
    :integer  => N_('Integer'),
    :string   => N_('String'),
    :time     => N_('Time')
  }.freeze

  FEATURES = %w[attribute association method].freeze
  REG_ATTRIBUTE_NAME = /\A[a-z][a-zA-Z_0-9]*\z/
  REG_METHOD_NAME    = /\A[a-z][a-zA-Z_0-9]*[!?]?\z/
  ALLOWED_ASSOCIATION_TYPES = (MiqReport.reportable_models + %w[GenericObject]).freeze

  serialize :properties, :type => Hash

  include CustomActionsMixin

  has_one   :picture, :dependent => :destroy, :as => :resource
  has_many  :generic_objects

  validates :name, :presence => true, :uniqueness_when_changed => true
  validate  :validate_property_attributes,
            :validate_property_associations,
            :validate_property_methods,
            :validate_property_name_unique,
            :validate_supported_property_features

  before_validation :set_default_properties
  before_validation :normalize_property_attributes,
                    :normalize_property_associations,
                    :normalize_property_methods

  before_destroy    :check_not_in_use

  virtual_total :generic_objects_count, :generic_objects

  FEATURES.each do |feature|
    define_method(:"property_#{feature}s") do
      return errors[:properties] if properties_changed? && !valid?

      properties["#{feature}s".to_sym]
    end

    define_method(:"property_#{feature}_defined?") do |attr|
      attr = attr.to_s
      return property_methods.include?(attr) if feature == 'method'

      send(:"property_#{feature}s").key?(attr)
    end
  end

  def property_defined?(attr)
    property_attribute_defined?(attr) || property_association_defined?(attr) || property_method_defined?(attr)
  end

  def create_object(options)
    GenericObject.create!({:generic_object_definition => self}.merge(options))
  end

  # To query based on GenericObject AR attributes and property attributes
  #   find_objects(:name => "TestLoadBalancer", :uid => '0001', :prop_attr_1 => 10, :prop_attr_2 => true)
  #
  # To query based on property associations. The array can be a partial list, but must contain only AR ids.
  #   find_objects(:vms => [23, 26])
  #
  def find_objects(options)
    dup = options.stringify_keys
    ar_options   = dup.extract!(*(GenericObject.column_names - ["properties"]))
    json_options = dup.extract!(*(property_attributes.keys + property_associations.keys))

    unless dup.empty?
      err_msg = _("[%{attrs}]: not searchable for Generic Object of %{name}") % {:attrs => dup.keys.join(", "),
                                                                                 :name  => name}
      _log.error(err_msg)
      raise err_msg
    end

    generic_objects.where(ar_options).where("properties @> ?", json_options.to_json)
  end

  def property_getter(attr, val)
    return type_cast(attr, val) if property_attribute_defined?(attr)

    get_objects_of_association(attr, val) if property_association_defined?(attr)
  end

  def type_cast(attr, value)
    TYPE_MAP.fetch(property_attributes[attr.to_s]).cast(value)
  end

  def properties=(props)
    props.reverse_merge!(:attributes => {}, :associations => {}, :methods => [])
    super
  end

  def add_property_attribute(name, type)
    properties[:attributes][name.to_s] = type.to_sym
    save!
  end

  def delete_property_attribute(name)
    transaction do
      generic_objects.find_each { |o| o.delete_property(name) }

      properties[:attributes].delete(name.to_s)
      save!
    end
  end

  def add_property_association(name, type)
    type = type.to_s.classify
    raise "invalid model for association: [#{type}]" unless type.in?(ALLOWED_ASSOCIATION_TYPES)

    properties[:associations][name.to_s] = type
    save!
  end

  def delete_property_association(name)
    transaction do
      generic_objects.find_each { |o| o.delete_property(name) }

      properties[:associations].delete(name.to_s)
      save!
    end
  end

  def add_property_method(name)
    return if properties[:methods].include?(name.to_s)

    properties[:methods] << name.to_s
    save!
  end

  def delete_property_method(name)
    properties[:methods].delete(name.to_s)
    save!
  end

  def generic_custom_buttons
    CustomButton.buttons_for("GenericObject")
  end

  def self.display_name(number = 1)
    n_('Generic Object Class', 'Generic Object Classes', number)
  end

  private

  def get_objects_of_association(attr, values)
    property_associations[attr.to_s].constantize.where(:id => values).to_a
  end

  def normalize_property_attributes
    props = properties.symbolize_keys

    properties[:attributes] = props[:attributes].each_with_object({}) do |(name, type), hash|
      hash[name.to_s] = type.to_sym
    end
  end

  def normalize_property_associations
    props = properties.symbolize_keys

    properties[:associations] = props[:associations].each_with_object({}) do |(name, type), hash|
      hash[name.to_s] = type.to_s.classify
    end
  end

  def normalize_property_methods
    props = properties.symbolize_keys
    properties[:methods] = props[:methods].collect(&:to_s)
  end

  def validate_property_attributes
    properties[:attributes].each do |name, type|
      errors.add(:properties, "attribute [#{name}] is not of a recognized type: [#{type}]") unless TYPE_MAP.key?(type.to_sym)
      errors.add(:properties, "invalid attribute name: [#{name}]") unless REG_ATTRIBUTE_NAME.match?(name)
    end
  end

  def validate_property_associations
    invalid_models = properties[:associations].values - ALLOWED_ASSOCIATION_TYPES
    errors.add(:properties, "invalid models for association: [#{invalid_models.join(",")}]") unless invalid_models.empty?

    properties[:associations].each do |name, _klass|
      errors.add(:properties, "invalid association name: [#{name}]") unless REG_ATTRIBUTE_NAME.match?(name)
    end
  end

  def validate_property_methods
    properties[:methods].each do |name|
      errors.add(:properties, "invalid method name: [#{name}]") unless REG_METHOD_NAME.match?(name)
    end
  end

  def validate_property_name_unique
    common = property_keywords.group_by(&:to_s).select { |_k, v| v.size > 1 }.collect(&:first)
    errors.add(:properties, "property name has to be unique: [#{common.join(",")}]") if common.present?
  end

  def validate_supported_property_features
    if properties.keys.any? { |f| !f.to_s.singularize.in?(FEATURES) }
      errors.add(:properties, "only these features are supported: [#{FEATURES.join(", ")}]")
    end
  end

  def property_keywords
    properties[:attributes].keys + properties[:associations].keys + properties[:methods]
  end

  def check_not_in_use
    return true if generic_objects.empty?

    errors.add(:base, "Cannot delete the definition while it is referenced by some generic objects")
    throw :abort
  end

  def set_default_properties
    self.properties = {:attributes => {}, :associations => {}, :methods => []} unless properties.present?
  end
end
