# frozen_string_literal: true

require 'active_support'
require 'active_support/concern'
require 'digest/sha1'

module FastJsonapi
  MandatoryField = Class.new(StandardError)

  module SerializationCore
    extend ActiveSupport::Concern

    included do
      class << self
        attr_accessor :attributes_to_serialize,
                      :relationships_to_serialize,
                      :cachable_relationships_to_serialize,
                      :uncachable_relationships_to_serialize,
                      :transform_method,
                      :record_type,
                      :record_id,
                      :cache_store_instance,
                      :cache_store_options,
                      :data_links,
                      :meta_to_serialize
      end
    end

    class_methods do
      def id_hash(id, record_type, default_return = false)
        if id.present?
          { id: id.to_s, type: record_type }
        else
          default_return ? { id: nil, type: record_type } : nil
        end
      end

      def links_hash(record, params = {})
        data_links.each_with_object({}) do |(_k, link), hash|
          link.serialize(record, params, hash)
        end
      end

      def attributes_hash(record, fieldset = nil, params = {})
        attributes = attributes_to_serialize
        attributes = attributes.slice(*fieldset) if fieldset.present?
        attributes = {} if fieldset == []

        attributes.each_with_object({}) do |(_k, attribute), hash|
          attribute.serialize(record, params, hash)
        end
      end

      def relationships_hash(record, relationships = nil, fieldset = nil, includes_list = nil, params = {})
        relationships = relationships_to_serialize if relationships.nil?
        relationships = relationships.slice(*fieldset) if fieldset.present?
        relationships = {} if fieldset == []

        relationships.each_with_object({}) do |(key, relationship), hash|
          included = includes_list.present? && includes_list.include?(key)
          relationship.serialize(record, included, params, hash)
        end
      end

      def meta_hash(record, params = {})
        FastJsonapi.call_proc(meta_to_serialize, record, params)
      end

      def record_hash(record, fieldset, includes_list, params = {})
        if cache_store_instance
          cache_opts = record_cache_options(cache_store_options, fieldset, includes_list, params)
          record_hash = cache_store_instance.fetch(record, **cache_opts) do
            temp_hash = id_hash(id_from_record(record, params), record_type, true)
            temp_hash[:attributes] = attributes_hash(record, fieldset, params) if attributes_to_serialize.present?
            temp_hash[:relationships] = relationships_hash(record, cachable_relationships_to_serialize, fieldset, includes_list, params) if cachable_relationships_to_serialize.present?
            temp_hash[:links] = links_hash(record, params) if data_links.present?
            temp_hash
          end
          record_hash[:relationships] = (record_hash[:relationships] || {}).merge(relationships_hash(record, uncachable_relationships_to_serialize, fieldset, includes_list, params)) if uncachable_relationships_to_serialize.present?
        else
          record_hash = id_hash(id_from_record(record, params), record_type, true)
          record_hash[:attributes] = attributes_hash(record, fieldset, params) if attributes_to_serialize.present?
          record_hash[:relationships] = relationships_hash(record, nil, fieldset, includes_list, params) if relationships_to_serialize.present?
          record_hash[:links] = links_hash(record, params) if data_links.present?
        end

        record_hash[:meta] = meta_hash(record, params) if meta_to_serialize.present?
        record_hash
      end

      # Cache options helper. Use it to adapt cache keys/rules.
      #
      # If a fieldset is specified, it modifies the namespace to include the
      # fields from the fieldset.
      #
      # @param options [Hash] default cache options
      # @param fieldset [Array, nil] passed fieldset values
      # @param includes_list [Array, nil] passed included values
      # @param params [Hash] the serializer params
      #
      # @return [Hash] processed options hash
      # rubocop:disable Lint/UnusedMethodArgument
      def record_cache_options(options, fieldset, includes_list, params)
        return options unless fieldset

        options = options ? options.dup : {}
        options[:namespace] ||= 'jsonapi-serializer'

        fieldset_key = fieldset.join('_')

        # Use a fixed-length fieldset key if the current length is more than
        # the length of a SHA1 digest
        if fieldset_key.length > 40
          fieldset_key = Digest::SHA1.hexdigest(fieldset_key)
        end

        options[:namespace] = "#{options[:namespace]}-fieldset:#{fieldset_key}"
        options
      end
      # rubocop:enable Lint/UnusedMethodArgument

      def id_from_record(record, params)
        return FastJsonapi.call_proc(record_id, record, params) if record_id.is_a?(Proc)
        return record.send(record_id) if record_id
        raise MandatoryField, 'id is a mandatory field in the jsonapi spec' unless record.respond_to?(:id)

        record.id
      end

      # It chops out the root association (first part) from each include.
      #
      # It keeps an unique list and collects all of the rest of the include
      # value to hand it off to the next related to include serializer.
      #
      # This method will turn that include array into a Hash that looks like:
      #
      #   {
      #       authors: Set.new([
      #         'books',
      #         'books.genre',
      #         'books.genre.books',
      #         'books.genre.books.authors',
      #         'books.genre.books.genre'
      #       ]),
      #       genre: Set.new(['books'])
      #   }
      #
      # Because the serializer only cares about the root associations
      # included, it only needs the first segment of each include
      # (for books, it's the "authors" and "genre") and it doesn't need to
      # waste cycles parsing the rest of the include value. That will be done
      # by the next serializer in line.
      #
      # @param includes_list [List] to be parsed
      # @return [Hash]
      def parse_includes_list(includes_list)
        includes_list.each_with_object({}) do |include_item, include_sets|
          include_base, include_remainder = include_item.to_s.split('.', 2)
          include_sets[include_base.to_sym] ||= Set.new
          include_sets[include_base.to_sym] << include_remainder if include_remainder
        end
      end

      # includes handler
      def get_included_records(record, includes_list, known_included_objects, fieldsets, params = {})
        return unless includes_list.present?
        return [] unless relationships_to_serialize

        includes_list = parse_includes_list(includes_list)

        includes_list.each_with_object([]) do |include_item, included_records|
          relationship_item = relationships_to_serialize[include_item.first]

          next unless relationship_item&.include_relationship?(record, params)

          included_objects = Array(relationship_item.fetch_associated_object(record, params))
          next if included_objects.empty?

          static_serializer = relationship_item.static_serializer
          static_record_type = relationship_item.static_record_type

          included_objects.each do |inc_obj|
            serializer = static_serializer || relationship_item.serializer_for(inc_obj, params)
            record_type = static_record_type || serializer.record_type

            if include_item.last.any?
              serializer_records = serializer.get_included_records(inc_obj, include_item.last, known_included_objects, fieldsets, params)
              included_records.concat(serializer_records) unless serializer_records.empty?
            end

            code = "#{record_type}_#{serializer.id_from_record(inc_obj, params)}"
            next if known_included_objects.include?(code)

            known_included_objects << code

            included_records << serializer.record_hash(inc_obj, fieldsets[record_type], includes_list, params)
          end
        end
      end
    end
  end
end
