require 'json'
require 'ostruct'
require 'active_model/naming'

module Scrivito
  #
  # The abstract base class for CMS objects.
  #
  # A CMS object is a collection of properties and their values, as defined
  # by its object class. These properties can be accessed in views, either
  # directly as the object itself is rendered, or indirectly when other objects
  # are rendered. The description of an image, for example, can be retrieved
  # from within any view that requires it, e.g. a page on which the image is
  # displayed.
  #
  # @note Please do not use {Scrivito::BasicObj} directly
  #   as it is intended as an abstract class.
  #   Always use {Obj} or a subclass of {Obj}.
  # @see http://scrivito.com/objects-widgets-classes CMS objects, widgets, and classes
  # @api public
  #
  class BasicObj
    extend ::ActiveSupport::DescendantsTracker

    PublicSystemAttributeDefinition = Class.new(AttributeDefinition)

    SYSTEM_ATTRIBUTES = AttributeDefinitionCollection.new(
      '_id'           => PublicSystemAttributeDefinition.new(:_id, :string),
      '_last_changed' => PublicSystemAttributeDefinition.new(:_last_changed, :date),
      '_obj_class'    => PublicSystemAttributeDefinition.new(:_obj_class, :string),
      '_path'         => PublicSystemAttributeDefinition.new(:_path, :string),
      '_permalink'    => PublicSystemAttributeDefinition.new(:_permalink, :string),

      '_conflicts'    => AttributeDefinition.new(:_conflicts, nil),
      '_modification' => AttributeDefinition.new(:_modification, nil),
      '_widget_pool'  => AttributeDefinition.new(:_widget_pool, nil),
    )

    UNIQ_ATTRIBUTES = %w[
      _id
      _path
      _permalink
    ].freeze

    GENERATED_ATTRIBUTES = %w[
      _conflicts
      _last_changed
      _modification
      _obj_type
      _text_links
    ].freeze

    extend ActiveModel::Naming
    extend AttributeContent::ClassMethods
    extend Associations::ClassMethods

    include AttributeContent
    include ModelIdentity
    include Associations

    def self.type_computer
      @_type_computer ||= TypeComputer.new(Scrivito::BasicObj, ::Obj)
    end

    def self.reset_type_computer!
      @_type_computer = nil
    end

    #
    # Create a new {Scrivito::BasicObj Obj} in the CMS.
    #
    # @api public
    #
    # This allows you to set the different attributes of an Obj by providing a hash containing the
    # attribute names as keys and the corresponding values you want to set. The defaults set via
    # {Scrivito::AttributeContent::ClassMethods#default_for Obj.default_for} are taken into account.
    #
    # @param [Hash] attributes of the new obj
    # @param [Hash] context in which the object is created
    # @option context [Scrivito::User] :scrivito_user current visitor
    # @return [Obj] the newly created {Scrivito::BasicObj Obj}
    #
    # @raise [ArgumentError] if called on +Obj+ without +_obj_class+ attribute.
    #
    # @see Scrivito::AttributeContent::ClassMethods#default_for
    #
    # @example Provide reference lists as an Array of {Scrivito::BasicObj Obj}.
    #   Obj.create(:reference_list => [other_obj])
    #
    # @example Passing an {Scrivito::BasicObj Obj} allows you to set a reference.
    #   Obj.create(:reference => other_obj)
    #
    # @example You can upload files by passing a Ruby File object.
    #   Obj.create(:blob => File.new("image.png"))
    #
    # @example A link list can be set as an Array of {Link Links}.
    #   Obj.create(:link_list => [
    #     # external link
    #     Link.new(:url => "http://www.example.com", :title => "Example"),
    #     # internal link
    #     Link.new(:obj => other_obj, :title => "Other Obj")
    #   ])
    #
    # @example Passing a {Link Link} allows you to set a link.
    #   Obj.create(
    #     external_link: Link.new(url: 'http://www.example.com', title: 'Example'),
    #     internal_link: Link.new(obj: other_obj, title: 'Other Obj')
    #   )
    #
    # @example Date attributes accept Time, Date and their subclasses (DateTime, for example).
    #   Obj.create(:date => Time.new)
    #   Obj.create(:date => Date.now)
    #
    # @example String, html and enum attributes can be set by passing a {String} value.
    #   Obj.create(:title => "My Title")
    #
    # @example Multienum attributes can be set using an Array of {String Strings}.
    #   Obj.create(:tags => ["ruby", "rails"])
    #
    # @example Simply pass an Array of {Scrivito::BasicWidget Widgets} to populate a widget field. See {Scrivito::BasicWidget#copy Widget#copy} on how to copy a widget.
    #   # Add new widgets
    #   Obj.create(:widgets => [Widget.new(_obj_class: 'TitleWidget', title: 'My Title')])
    #
    #   # Add a copy of a widget
    #   Obj.create(:widgets => [another_obj.widgets.first.copy])
    #
    #   # Change a widget field
    #   obj.update(:widgets => [obj.widgets.first])
    #
    #   # Clear a widget field
    #   obj.update(:widgets => [])
    #
    def self.create(attributes = {}, context = {})
      attributes = with_default_id_attribute(attributes)
      if obj_class = extract_obj_class_from_attributes(attributes)
        obj_class.create(attributes, context)
      else
        attributes = build_attributes_with_defaults(attributes, context)
        attributes = prepare_attributes_for_instantiation(attributes)
        api_attributes, widget_properties = prepare_attributes_for_rest_api(attributes)

        workspace = Workspace.current
        obj_data = workspace.create_obj(obj: api_attributes)
        obj = BasicObj.instantiate(obj_data)
        obj.revision = workspace.revision

        CmsRestApi::WidgetExtractor.notify_persisted_widgets(obj, widget_properties)
        obj
      end
    end

    # Create a new {Scrivito::BasicObj Obj} instance with the given values and attributes.
    # Normally, this method should not be used.
    # Instead, CMS objects should be retrieved from the CMS database.
    def initialize(attributes = {})
      update_data(ObjDataFromHash.new(attributes))
    end

    # @api public
    def id
      read_attribute('_id')
    end

    def revision=(revision)
      raise "cannot change revision once set!" if @revision
      @revision = revision
    end

    def revision
      @revision or raise "revision not set!"
    end

    ### FINDERS ####################

    # Find an {Scrivito::BasicObj Obj} by its id.
    # If the parameter is an Array of ids, the list of corresponding objects is returned.
    # @example Find several CMS objects at once:
    #   Obj.find(['id1', 'id2'])
    # @param [String, Integer, Array<String, Integer>]id_or_list
    # @return [Obj, Array<Obj>]
    # @api public
    def self.find(id_or_list)
      Workspace.current.objs.find(id_or_list)
    end

    def self.find_by_id(id)
      Workspace.current.objs.find_by_id(id)
    end

    # Find an {Scrivito::BasicObj Obj} by its id.
    # If the parameter is an Array containing ids, return a list of corresponding objects.
    # The results include deleted objects as well.
    # @param [String, Integer, Array<String, Integer>]id_or_list
    # @return [Obj, Array<Obj>]
    # @api public
    def self.find_including_deleted(id_or_list)
      Workspace.current.objs.find_including_deleted(id_or_list)
    end

    # Returns an {Scrivito::ObjSearchEnumerator} with the given initial subquery consisting of the
    # four arguments.
    #
    # Note that +field+ and +value+ can also be arrays for searching several fields or searching for
    # several values.
    #
    # @note If invoked on a subclass of Obj, the result will be restricted to instances of this
    # subclass.
    #
    # {Scrivito::ObjSearchEnumerator}s can be chained using one of the chainable methods
    # (e.g. {Scrivito::ObjSearchEnumerator#and} and {Scrivito::ObjSearchEnumerator#and_not}).
    #
    # @example Look for objects containing "Lorem", boosting headline matches:
    #   Obj.where(:*, :contains, 'Lorem', headline: 2).to_a
    #
    # @example Look for the first 10 objects whose object class is "Pressrelease" and whose title contains "quarterly":
    #   Obj.where(:_obj_class, :equals, 'Pressrelease').and(:title, :contains, 'quarterly').take(10)
    #
    # @example Look for all objects whose class is "Item". The path should start with a defined location. Furthermore, select only items of a particular category:
    #   Obj.where(:_obj_class, :equals, 'Item').and(:_path, :starts_with, '/en/items/').select do |item|
    #     item.categories.include?(category)
    #   end
    #
    # @example Find CMS objects linking to +my_image+ through any link or reference:
    #   Obj.where(:*, :links_to, my_image)
    #
    # @example Find +BlogPostPage+ objects linking to +my_image1+ or +my_image2+ through any link or reference:
    #   BlogPostPage.where(:*, :links_to, [my_img1, my_img2])
    #
    # @example Find all objects of the +BlogPost+ class that reference +author_obj+ via the +authors+ referencelist attribute.
    #   BlogPost.where(:authors, :refers_to, author_obj)
    #
    # @param [Symbol, String, Array<Symbol, String>] field See {Scrivito::ObjSearchEnumerator#and}
    #   for details
    # @param [Symbol, String] operator See {Scrivito::ObjSearchEnumerator#and} for details
    # @param [String, Array<String>] value See {Scrivito::ObjSearchEnumerator#and} for details
    # @param [Hash] boost See {Scrivito::ObjSearchEnumerator#and} for details
    # @raise [ScrivitoError] if called directly on {Scrivito::BasicObj}. Use +Obj.where+ instead.
    # @return [Scrivito::ObjSearchEnumerator]
    # @api public
    def self.where(field, operator, value, boost = nil)
      assert_not_basic_obj('.where')
      if self == ::Obj
        Workspace.current.objs.where(field, operator, value, boost)
      else
        Workspace.current.objs.where(:_obj_class, :equals, name)
            .and(field, operator, value, boost)
      end
    end

    # Returns an {Scrivito::ObjSearchEnumerator} of all {Scrivito::BasicObj Obj}s.
    # If invoked on a subclass of Obj, the result is restricted to instances of this subclass.
    # @return [Scrivito::ObjSearchEnumerator]
    # @raise [ScrivitoError] if called directly on +Scrivito::BasicObj+. Use +Obj.all+ instead.
    # @api public
    def self.all
      assert_not_basic_obj('.all')
      if self == ::Obj
        Workspace.current.objs.all
      else
        find_all_by_obj_class(name)
      end
    end

    # Returns an {Scrivito::ObjSearchEnumerator} of all CMS objects with the given +obj_class+.
    # @param [String] obj_class name of the object class.
    # @return [Scrivito::ObjSearchEnumerator]
    # @api public
    def self.find_all_by_obj_class(obj_class)
      Workspace.current.objs.find_all_by_obj_class(obj_class)
    end

    # Find the {Scrivito::BasicObj Obj} with the given path.
    # Returns +nil+ if no matching Obj exists.
    # @param [String] path Path of the {Scrivito::BasicObj Obj}.
    # @return [Obj]
    # @api public
    def self.find_by_path(path)
      Workspace.current.objs.find_by_path(path)
    end

    # Find an {Scrivito::BasicObj Obj} with the given name.
    # If several objects with the given name exist, an arbitrary one is chosen and returned.
    # If no Obj with this name exists, +nil+ is returned.
    # @param [String] name Name of the {Scrivito::BasicObj Obj}.
    # @return [Obj]
    # @api public
    def self.find_by_name(name)
      where(:_name, :equals, name).batch_size(1).first
    end

    # Returns an {Scrivito::ObjSearchEnumerator} of all CMS objects with the given name.
    # @param [String] name Name of the {Scrivito::BasicObj Obj}.
    # @return [Scrivito::ObjSearchEnumerator]
    # @api public
    def self.find_all_by_name(name)
      where(:_name, :equals, name)
    end

    # Returns the {Scrivito::BasicObj Obj} with the given permalink, or +nil+ if no
    # matching Obj exists.
    # @param [String] permalink The permalink of the {Scrivito::BasicObj Obj}.
    # @return [Obj]
    # @api public
    def self.find_by_permalink(permalink)
      Workspace.current.objs.find_by_permalink(permalink)
    end

    # Returns the {Scrivito::BasicObj Obj} with the given permalink, or raises
    # {Scrivito::ResourceNotFound} if no matching Obj exists.
    #
    # @param [String] permalink The permalink of the {Scrivito::BasicObj Obj}.
    # @return [Obj]
    # @api public
    def self.find_by_permalink!(permalink)
      find_by_permalink(permalink) or
        raise ResourceNotFound, "Could not find Obj with permalink '#{permalink}'"
    end

    # Hook method that lets you control which page classes are made available for pages with the given path.
    # Override it to allow only specific classes or none at all.
    # Must return either +NilClass+, or +Array+.
    #
    # Be aware that the given argument is a parent path.
    # E.g., when creating a page whose path is +/products/shoes+, the argument must be +/products+.
    # The given parent path can also be +NilClass+.
    #
    # If +NilClass+ is returned, all page classes are made available.
    # By default, +NilClass+ is returned.
    #
    # If an +Array+ is returned, it is expected to contain the available classes.
    # The order of the classes is preserved.
    #
    # @param [String, NilClass] parent_path Path of the parent obj
    # @return [NilClass, Array<Class>]
    # @api public
    def self.valid_page_classes_beneath(parent_path)
    end

    def self.valid_page_ruby_classes_beneath(parent_path)
      result = assert_classes(valid_page_classes_beneath(parent_path), '.valid_page_classes_beneath')
      (result || Scrivito.models.pages.to_a).uniq
    end

    # Update the {Scrivito::BasicObj Obj} using the attributes provided.
    #
    # For an overview of the values you can set via this method, see the
    # documentation of {Scrivito::BasicObj.create Obj.create}.
    #
    # Additionally, +update+ accepts a +_widget_pool+ hash in +attributes+ to modify widgets.
    # The keys of +_widget_pool+ are widget instances, the values are the modified attributes of
    # these particular widgets.
    #
    # @api public
    # @param [Hash] attributes
    # @example Update the URL of a link:
    #   link = obj.my_link
    #   link.url = "http://www.example.com"
    #   obj.update(my_link: link)
    #
    # @example Update binary attributes:
    #   obj.update(blob: img_obj.binary)
    #   obj.update(thumbnail: File.new("/path/to/small.jpg"))
    #
    # @example Remove the first and the last widget from a widget field:
    #   obj.update(
    #     my_widget_field: obj[:my_widget_field][1..-2]
    #   )
    #
    # @example Move the +widget_to_move+ widget from the +left+ widget field of the +two_column_widget1+ widget to +left+ of +two_column_widget2+:
    #   obj.update(
    #     _widget_pool: {
    #       two_column_widget1 => {left: two_column_widget1.left - [widget_to_move]},
    #       two_column_widget2 => {left: two_column_widget2.left + [widget_to_move]}
    #     },
    #     headline: "Some widgets were moved!"
    #   )
    #
    # @example Move the +widget_to_move+ widget from the +right+ widget field of the +two_column_widget1+ widget to the top-level +main_content+ widget field:
    #   obj.update(
    #     main_content: @obj.main_content + [widget_to_move],
    #     _widget_pool: {
    #       two_column_widget1 => {
    #         right: two_column_widget1.right - [widget_to_move]
    #       }
    #     }
    #   )
    def update(attributes)
      api_attributes, widget_properties = prepare_attributes_for_rest_api(attributes)
      update_data(workspace.update_obj(id, obj: api_attributes))
      CmsRestApi::WidgetExtractor.notify_persisted_widgets(self, widget_properties)
      self
    end

    # Creates a copy of the {Scrivito::BasicObj Obj}.
    # @api public
    # @param [Hash] options
    # @option options [String,Symbol] :_path (nil) the path of the copy.
    # @option options [String,Symbol] :_id (nil) the id of the copy.
    # @option options [String,Symbol] :_permalink (nil) the permalink of the copy.
    # @raise [ArgumentError] if +options+ includes invalid keys.
    # @return [Obj] the created copy
    # @example Copy a blog post:
    #   blog_post = Obj.find_by_path('/blog/first_post')
    #   blog_post.copy(_path: '/blog/second_post')
    def copy(options={})
      options = options.stringify_keys.assert_valid_keys('_path', '_id', '_permalink')
      attributes_for_copy = self.class.with_default_id_attribute(copyable_attributes)
      attributes_for_copy = attributes_for_copy.with_indifferent_access.merge(options)
      attributes_for_copy = copy_binaries(attributes_for_copy)

      json = workspace.api_request(:post, '/objs', obj: attributes_for_copy)
      self.class.find(json['_id'])
    end

    # Destroys the {Scrivito::BasicObj Obj} in the current {Scrivito::Workspace}.
    # @api public
    def destroy
      if children.any?
        raise ClientError.new(I18n.t('scrivito.errors.models.basic_obj.has_children'))
      end
      workspace.api_request(:delete, "/objs/#{id}")
    end

    def to_param
      id
    end

    # Returns the {Scrivito::BasicObj Obj} that is the parent of this Obj.
    # Returns +nil+ for the root Obj.
    # @api public
    def parent
      if child_path?
        workspace.objs.find_by_path(parent_path)
      end
    end

    # Returns an Array of all the ancestor objects, starting at the root and ending at the parent of this object.
    # @return [Array<Obj>]
    # @api public
    def ancestors
      return [] unless child_path?

      ancestor_paths = parent_path.scan(/\/[^\/]+/).inject([""]) do |list, component|
        list << list.last + component
      end
      ancestor_paths[0] = "/"
      Workspace.current.objs.find_by_paths(ancestor_paths)
    end

    # Returns a list of all child {Scrivito::BasicObj Obj}s.
    # @return [Array<Obj>]
    # @api public
    def children
      return [] unless path

      workspace.objs.find_by_parent_path(path)
    end

    ### ATTRIBUTES #################

    # Returns the path of the {Scrivito::BasicObj Obj} as a String.
    # @api public
    def path
      read_attribute('_path')
    end

    # Returns the name of the {Scrivito::BasicObj Obj}. The name is the last component of a path.
    # @api public
    def name
      if child_path?
        path.match(/[^\/]+$/)[0]
      else
        ""
      end
    end

    # Returns the root {Scrivito::BasicObj Obj}, the object whose path is "/".
    # @return [Obj]
    # @api public
    def self.root
      BasicObj.find_by_path('/') or raise ResourceNotFound,
          '"Obj.root" not found: There is no "Obj" with path "/". '\
          'Maybe you forgot the migration when setting up your Scrivito application? '\
          'Try "bundle exec rake scrivito:migrate scrivito:migrate:publish".'
    end

    def self.generate_widget_pool_id
      SecureRandom.hex(4)
    end

    # Returns the permalink of the {Scrivito::BasicObj Obj}.
    # @api public
    def permalink
      read_attribute('_permalink')
    end

    # This method determines the controller to be invoked when the +Obj+ is requested.
    # By default, a controller matching the obj_class of the Obj is used.
    # If the controller does not exist, the CmsController is used as a fallback.
    # Override this method to force a different controller to be used.
    # @return [String]
    # @api public
    def controller_name
      obj_class
    end

    # This method determines the action to be invoked when the +Obj+ is requested.
    # The default action is 'index'.
    # Override this method to force a different action to be used.
    # @return [String]
    # @api public
    def controller_action_name
      "index"
    end

    # This method is used to calculate a part of a URL of this Obj.
    #
    # The routing schema: <code><em><obj.slug></em>-<em><obj.id></em></code>
    #
    # The default is {http://apidock.com/rails/ActiveSupport/Inflector/parameterize parameterize}
    # on +obj.title+.
    #
    # You can customize this part by overriding {#slug}.
    # @return [String]
    # @api public
    def slug
      (title || '').parameterize
    end

    #
    # This method determines the description shown in the UI.
    # It defaults to {Scrivito::BasicObj#display_title}. It can be overridden by a custom value.
    #
    # @api public
    #
    def description_for_editor
      display_title
    end

    #
    # Calculates an appropriate title for an +Obj+.
    #
    # @api public
    #
    # @return [String] {Scrivito::Binary#filename} if +Obj+ is +binary+ and has a +filename+.
    # @return [String] {Scrivito::BasicObj#title} if +Obj+ has a non-empty +title+.
    # @return [String] falls back to +Obj.description_for_editor+.
    #
    def display_title
      (binary_title || title).presence || self.class.description_for_editor
    end

    # The alt description of an +Obj+ used for {ScrivitoHelper#scrivito_image_tag}.
    #
    # By default, this method returns the +title+ of the +Obj+.
    #
    # You can customize this part by overriding {#alt_description}.
    #
    # @return [String]
    # @api public
    def alt_description
      title
    end

    # @api public
    def title
      read_attribute('title')
    end

    # @api public
    # This method indicates whether the Obj represents binary data. Binaries are
    # handled differently in that they are not rendered using the normal layout
    # but sent as a file. Examples of binary resources are Images or PDFs.
    #
    # Every Obj that has a +blob+ attribute of the +binary+ type is
    # considered a binary
    #
    # @return true if this Obj represents a binary resource.
    def binary?
      blob_attribute_definition = attribute_definitions['blob']
      blob_attribute_definition.present? && blob_attribute_definition.type == 'binary'
    end

    #
    # When delivering binary objects, this method decides whether the image transformations should
    # be applied by default.
    #
    # @api public
    #
    # By default, this method returns +false+.
    # Override it in subclasses to fit your needs.
    #
    # @note Only relevant for binary objects
    # @see Scrivito::Configuration.default_image_transformation=
    # @see Scrivito::Binary#transform
    #
    def apply_image_transformation?
      false
    end

    # Returns true if this Obj is the root Obj.
    # @api public
    def root?
      path == "/"
    end

    # Returns a list of children excluding the binary? ones unless :all is specfied.
    # This is useful for creating navigations.
    # @return [Array<Obj>]
    # @api public
    def toclist(*args)
      return [] if binary?
      toclist = children
      toclist = toclist.reject { |toc| toc.binary? } unless args.include?(:all)
      toclist
    end

    # @api public
    #
    # Returns a list of children that are sorted according to the order specifed in the
    # +child_order+ attribute. The same sort order is used when rendering navigations using
    # the {ScrivitoHelper#scrivito_tag_list scrivito_tag_list} helper.
    #
    # @return [Array<Obj>]
    def sorted_toclist
      if sortable_toclist?
        (child_order & toclist) + (toclist - child_order).sort_by(&:id)
      else
        toclist
      end
    end

    def sortable_toclist?
      has_attribute?(:child_order)
    end

    # This should be a Set, because it's faster in this particular case.
    SYSTEM_KEYS = Set.new(%w[
      body
      _id
      _last_changed
      _path
      _permalink
      _obj_class
      title
    ])

    # Returns the value of a system or custom attribute specified by its name.
    # Passing an invalid key will not raise an error but return +nil+.
    # @api public
    def [](key)
      key = key.to_s
      if SYSTEM_KEYS.include?(key)
        read_attribute(key)
      else
        super
      end
    end

    def has_attribute?(key)
      key = key.to_s

      if SYSTEM_KEYS.include?(key)
        true
      else
        super
      end
    end

    # Reloads the attributes of this Obj from the database.
    # Note that the Ruby class of this Obj instance will NOT change,
    # even if the obj_class in the database has changed.
    # @api public
    def reload
      workspace.reload
      reload_data
    end

    def reload_data
      id = self.id.to_s
      update_data -> {
        CmsBackend.find_obj_data_by(workspace.revision, :id, [id]).first.first
      }
    end

    # @api public
    def last_changed
      read_attribute('_last_changed')
    end

    def new?(revision=workspace.base_revision)
      quick_modification(revision) == "new"
    end

    def deleted?(revision=workspace.base_revision)
      quick_modification(revision) == "deleted"
    end

    def modification(revision=workspace.base_revision)
      quick_modification = quick_modification(revision)

      if ObjData === quick_modification
        if data_from_cms == quick_modification
          Modification::UNMODIFIED
        else
          Modification::EDITED
        end
      else
        quick_modification
      end
    end

    # Similar to modification but faster if you are only interested in
    # "new" and "deleted".
    # This method sometimes does not return a string, but an instance of
    # ObjData instead. This indicates that the modification is either
    # UNMODIFIED or EDITED. Which one it is can be determined by comparing
    # the returned ObjData.
    def quick_modification(revision)
      return Modification::UNMODIFIED unless revision

      modification_attr = read_attribute('_modification')

      return modification_attr if revision == workspace.base_revision

      data_for_comparison = cms_data_for_revision(revision)

      if data_for_comparison.present?
        if modification_attr == 'deleted'
          # Obj exists in comparison revision but not in current
          Modification::DELETED
        else
          # Obj exists in both revisions, leave the actual comparisons
          # up to the caller
          data_for_comparison
        end
      else
        if modification_attr == "deleted"
          # Obj does not exist in either revision
          Modification::UNMODIFIED
        else
          # Obj exists in current but not in comparison revision
          Modification::NEW
        end
      end
    end

    def widget_data_for_revision(id, revision)
      if revision_obj_data = cms_data_for_revision(revision)
        revision_obj_data.value_of('_widget_pool')[id]
      end
    end

    def in_revision(revision)
      if obj_data = cms_data_for_revision(revision)
        obj = Obj.instantiate(obj_data)
        obj.revision = revision

        obj
      end
    end

    def content_type
      raise "The method `content_type' and `mime_type' were removed. Please use `binary_content_type' instead"
    end
    alias mime_type content_type

    # Returns the name extension of the Obj (the part after the last dot).
    # Returns an empty string if the name of the Obj doesn't have an extension.
    # @return [String]
    # @api public
    def file_extension
      File.extname(name)[1..-1] || ""
    end

    # Returns the body (main content) of the Obj for non-binary objects.
    # Returns +nil+ for binary objects.
    # @return [String]
    # @api public
    def body
      if binary?
        nil
      else
        read_attribute('body')
      end
    end

    # @api public
    # This method is intended for CMS objects that represent binary resources like
    # images or PDF documents. If the Obj represents a binary file, an instance
    # of {Binary} is returned.
    #
    # This method returns the +blob+ attribute if its type is +binary+.
    #
    # @return [Binary, nil]
    def binary
      self[:blob] if self[:blob].is_a?(Binary)
    end

    # @api public
    # This method returns the byte length of the binary of the Obj.
    # @return [Fixnum] If no binary is set, 0 is returned.
    def binary_length
      binary.try(:content_length) || 0
    end

    # @api public
    # This method returns the content type of the binary of this Obj if the binary is set.
    # @return [String, nil]
    def binary_content_type
      binary.try(:content_type)
    end

    # @api public
    # This method returns the URL under which the content of this binary is
    # available to the public if the binary is set.
    #
    # See {Scrivito::Binary#url} for details
    # @return [String, nil]
    def binary_url
      binary.try(:url)
    end

    # @api public
    #
    # Allows accessing the meta data for binary +Objs+.
    #
    # @example Accessing meta data
    #   obj.meta_data['width'] # => 150
    #   obj.meta_data['content_type'] # => 'image/jpeg'
    #
    # @see Scrivito::MetaDataCollection List of available meta data attributes.
    #
    # @return [Scrivito::MetaDataCollection] The collection of available meta data
    def meta_data
      binary.try(:meta_data) || MetaDataCollection.new({})
    end

    def body_length
      raise %(
        The method `body_length' was removed. Please use
        `binary_length' or `body.length' instead
      )
    end

    def body_data_url
      raise "The method `body_data_url' was removed. Please use `binary_url' instead"
    end

    def body_content_type
      raise "The method `body_content_type' was removed. Please use `binary_content_type' instead"
    end

    def inspect
      "<#{self.class} id=\"#{id}\" path=\"#{path}\">"
    end

    def widget_from_pool(widget_id)
      widget_data = widget_data_from_pool(widget_id)
      instantiate_widget(widget_id, widget_data) if widget_data
    end

    # @api public
    # Allows accessing the {Scrivito::BasicWidget Widgets} of this Obj.
    #
    # @example Access a widget by its id
    #   obj.widgets['widget_id']
    #
    # @return [Scrivito::WidgetCollection]
    def widgets
      @widgets ||= WidgetCollection.new(self)
    end

    # for internal testing purposes only
    def blob_id
      find_blob.try(:id)
    end

    #
    # Reverts all changes made to the +Obj+ in the current workspace.
    #
    # @api public
    #
    # @note This method does not support +Obj+s that are +new+.
    #   Please use {Scrivito::BasicObj#destroy Obj#destroy} to destroy them.
    # @note This method does not support +Obj+s that are +deleted+.
    #   Please use {Scrivito::BasicObj.restore Obj.restore} to restore them.
    #
    # @raise [ScrivitoError] If the current workspace is +published+.
    # @raise [ScrivitoError] If the +Obj+ is +new+.
    # @raise [ScrivitoError] If the +Obj+ is +deleted+.
    #
    def revert
      assert_revertable

      if modification == Modification::EDITED
        base_revision_path = "revisions/#{workspace.base_revision_id}/objs/#{id}"
        previous_attributes = CmsRestApi.get(base_revision_path).except('_id')
        previous_widget_pool = previous_attributes['_widget_pool']

        ids_of_new_widgets = read_widget_pool.keys - previous_widget_pool.keys
        ids_of_new_widgets.each { |widget_id| previous_widget_pool[widget_id] = nil }

        previous_attributes = reset_blank_attributes(previous_attributes)

        workspace.api_request(:put, "/objs/#{id}", obj: previous_attributes)
        reload
      end
    end

    def restore_widget(widget_id)
      Workspace.current.assert_revertable

      return if modification == Modification::UNMODIFIED
      if modification == Modification::DELETED
        raise ScrivitoError, 'Can not restore a widget inside a deleted obj'
      end

      unless widget = in_revision(Workspace.current.base_revision).widgets[widget_id]
        raise ResourceNotFound, "Could not find widget with id #{widget_id}"
      end

      container = widget.container
      if container.kind_of?(BasicWidget) && !widgets[container.id]
        raise ScrivitoError, 'Can not restore a widget inside a deleted widget'
      end

      widget_copy = widget.copy_for_restore(read_widget_pool.keys)
      attribute_name = widget.container_attribute_name
      current_container = widgets[container.id] || self
      current_container.update(attribute_name =>
        current_container[attribute_name].insert(widget.container_attribute_index, widget_copy))
    end

    def mark_resolved
      workspace.api_request(:put, "/objs/#{id}", obj: {_conflicts: nil})
      reload
    end

    def find_container_and_attribute_name_for_widget(widget_id)
      if attribute_name = find_attribute_containing_widget(widget_id)
        return [self, attribute_name]
      end

      contained_widgets.each do |container_widget|
        if attribute_name = container_widget.find_attribute_containing_widget(widget_id)
          return [container_widget, attribute_name]
        end
      end

      [nil, nil]
    end

    def widget_data_from_pool(widget_id)
      read_widget_pool[widget_id]
    end

    def has_conflict?
      read_attribute('_conflicts') != nil
    end

    def publishable?
      !has_conflict?
    end

    def generate_widget_pool_id
      10.times do
        id = self.class.generate_widget_pool_id

        return id if widget_data_from_pool(id).nil?
      end

      raise ScrivitoError.new('Could not generate a new unused widget id')
    end

    def contained_widgets
      read_widget_pool.keys.map(&method(:widget_from_pool))
    end

    def parent_path
      ParentPath.of(path) unless root?
    end

    def outdated?
      return false if workspace.published?

      base_revision = workspace.base_revision
      published_revision = Workspace.published.revision

      return false if base_revision == published_revision
      return true if has_conflict?

      cms_data_for_revision(base_revision) != cms_data_for_revision(published_revision)
    end

    def transfer_modifications_to(target_workspace)
      return unless modification
      if has_conflict? || future_conflict?(target_workspace)
        raise TransferModificationsConflictError, "Transfer will result in a conflict. " \
          "Please update the current and target workspace and ensure they are conflict free."
      end
      if in_revision(target_workspace.revision).try(:modification)
        raise TransferModificationsModifiedError,
          "Already modified in workspace #{target_workspace.id}"
      end
      copy_modifications_to(target_workspace)
      reset_modifications
    end

    private

    def copy_modifications_to(target_workspace)
      case
      when new? then create_in(target_workspace)
      when deleted? then destroy_in(target_workspace)
      else update_in(target_workspace)
      end
    end

    def future_conflict?(target_workspace)
      base_revision = workspace.base_revision
      target_base_revision = target_workspace.base_revision

      base_revision != target_base_revision &&
        cms_data_for_revision(base_revision) != cms_data_for_revision(target_base_revision)
    end

    def create_in(target_workspace)
      target_workspace.api_request(:post, '/objs', obj: get_attributes)
    end

    def update_in(target_workspace)
      update_attributes = reset_blank_attributes(copyable_attributes)
      target_workspace.api_request(:put, "/objs/#{id}", obj: update_attributes)
    end

    def destroy_in(target_workspace)
      target_workspace.api_request(:delete, "/objs/#{id}")
    end

    def reset_modifications
      case
      when new? then workspace.api_request(:delete, "/objs/#{id}")
      when deleted? then self.class.restore(id)
      else revert
      end
    end

    def cms_data_for_revision(revision)
      return nil unless revision

      result = CmsBackend.find_obj_data_by(revision, "id", [id])
      obj_data = result.first.first

      if obj_data && obj_data.value_of("_modification") != "deleted"
        obj_data
      end
    end

    def read_widget_pool
      read_attribute('_widget_pool')
    end

    def instantiate_widget(widget_id, widget_data)
      BasicWidget.instantiate(widget_data).tap do |widget|
        widget.id = widget_id
        widget.obj = self
      end
    end

    def as_date(value)
      DateConversion.deserialize_from_backend(value) unless value.nil?
    end

    def find_blob
      read_attribute('blob')
    end

    def workspace
      if revision.workspace
        revision.workspace
      else
        raise ScrivitoError, "No workspace set for this obj"
      end
    end

    def child_path?
      !path.nil? && !root?
    end

    def prepare_attributes_for_rest_api(attributes)
      self.class.prepare_attributes_for_rest_api(attributes, self)
    end

    def copyable_attributes
      get_attributes.except(*GENERATED_ATTRIBUTES).except(*UNIQ_ATTRIBUTES)
    end

    def get_attributes
      workspace.api_request(:get, "/objs/#{id}")
    end

    def copy_binaries(attributes)
      attribute_defintions = self.class.find_attribute_definitions(obj_class)
      destination_obj_id = attributes.fetch(:_id)

      Hash[attributes.map do |name, value|
        if value && attribute_defintions[name].try(:type) == 'binary'
          binary = self[name]
          [name, ['binary', copy_binary(destination_obj_id, binary)]]
        else
          [name, value]
        end
      end]
    end

    def copy_binary(destination_obj_id, binary)
      normalized_id = CmsRestApi.normalize_path_component(binary.id)
      CmsRestApi.put("/blobs/#{normalized_id}/copy", {
        destination_obj_id: destination_obj_id,
        filename: binary.filename,
        content_type: binary.content_type,
      })
    end

    def assert_revertable
      workspace.assert_revertable
      if modification == Modification::NEW || modification == Modification::DELETED
        raise ScrivitoError, "cannot revert changes, since obj is #{modification}."
      end
    end

    def binary_title
      binary.filename if binary? && binary
    end

    def has_system_attribute?(attribute_name)
      !!SYSTEM_ATTRIBUTES[attribute_name]
    end

    def has_public_system_attribute?(attribute_name)
      SYSTEM_ATTRIBUTES[attribute_name].is_a?(PublicSystemAttributeDefinition)
    end

    def type_of_system_attribute(attribute_name)
      SYSTEM_ATTRIBUTES[attribute_name].try(:type)
    end

    def value_of_system_attribute(attribute_name)
      attribute_value = data_from_cms.value_of(attribute_name)
      if attribute_name == '_last_changed'
        DateConversion.deserialize_from_backend(attribute_value)
      else
        attribute_value
      end
    end

    def reset_blank_attributes(attributes)
      widget_pool = attributes['_widget_pool']
      widget_pool.each do |id, widget_attributes|
        if widget_attributes
          widget_pool[id] = Widget.reset_blank_attributes(widget_attributes)
        end
      end

      self.class.reset_blank_attributes(attributes)
    end

    class << self
      def assert_not_basic_obj(method_name)
        if self == Scrivito::BasicObj
          raise ScrivitoError, "Can not call #{method_name} on Scrivito::BasicObj."\
            " Only call it on Obj or subclasses of Obj"
        end
      end

      #
      # Restores a previously deleted +Obj+.
      #
      # @api public
      #
      # @raise [ScrivitoError] If the current workspace is +published+.
      def restore(obj_id)
        Workspace.current.assert_revertable
        base_revision_path = "revisions/#{Workspace.current.base_revision_id}/objs/#{obj_id}"
        obj_attributes = CmsRestApi.get(base_revision_path).merge('_id' => obj_id)
        Workspace.current.api_request(:post, '/objs', obj: obj_attributes)
      end

      def prepare_attributes_for_rest_api(attributes, obj = nil)
        widget_pool_attributes, obj_attributes = CmsRestApi::WidgetExtractor.call(attributes, obj)
        obj_id = obj ? obj.id : obj_attributes.fetch(:_id)
        workspace = obj ? obj.revision.workspace : Workspace.current

        api_attributes = serialize_attributes(
          obj_attributes, widget_pool_attributes, workspace, obj_id
        )

        [api_attributes, widget_pool_attributes]
      end

      def serialize_attributes(obj_attributes, widget_pool_attributes, workspace, obj_id)
        serializer = AttributeSerializer.new(obj_attributes['_obj_class'] || name, obj_id)
        serialized_attributes = serialize_obj_attributes(serializer, obj_attributes)
        serialized_attributes['_widget_pool'] =
          serialize_widget_pool_attributes(serializer, widget_pool_attributes)
        serialized_attributes
      end

      def serialize_obj_attributes(serializer, obj_attributes)
        serializer.serialize(obj_attributes,
          find_attribute_definitions(obj_attributes['_obj_class']) || attribute_definitions)
      end

      def serialize_widget_pool_attributes(serializer, widget_pool_attributes)
        {}.tap do |serialized_attributes|
          widget_pool_attributes.each_pair do |widget, widget_attributes|
            obj_class = widget_attributes['_obj_class']
            widget_serializer = AttributeSerializer.new(obj_class, serializer.obj_id)

            serialized_attributes[widget.id] = widget_serializer.serialize(widget_attributes,
              find_attribute_definitions(obj_class, BasicWidget) || widget.attribute_definitions)
          end
        end
      end

      def find_attribute_definitions(obj_class, basic_class = self)
        basic_class.type_computer.compute_type(obj_class).attribute_definitions if obj_class
      end

      def with_default_id_attribute(attributes)
        attributes.reverse_merge(_id: SecureRandom.hex(8))
      end
    end
  end
end