# frozen_string_literal: true

require 'easy/jsonapi/parser/json_parser'
require 'easy/jsonapi/exceptions/naming_exceptions'
require 'easy/jsonapi/exceptions/user_defined_exceptions'

# TODO: Review document exceptions against jsonapi spec
# TODO: PATCH -- Updating To One Relationships -- The patch request must contain a top level member
#   named data containing either a ResourceID or null
#   ^ To check, create #relationships_link? and make check based on that


module JSONAPI
  module Exceptions

    # Validates that the request or response document complies with the JSONAPI specification  
    module DocumentExceptions

      # A jsonapi document MUST contain at least one of the following top-level members
      REQUIRED_TOP_LEVEL_KEYS = %i[data errors meta].freeze
      
      # Top level links objects MAY contain the following members
      LINKS_KEYS = %i[self related first prev next last].freeze

      # Pagination member names in a links object
      PAGINATION_LINKS =  %i[first prev next last].freeze

      # Each member of a links object is a link. A link MUST be represented as either
      LINK_KEYS = %i[href meta].freeze

      # A resource object MUST contain at least id and type (unless a post resource)
      #   In addition, a resource object MAY contain these top-level members.
      RESOURCE_KEYS = %i[type id attributes relationships links meta].freeze

      # A relationships object MUST contain one of the following:
      RELATIONSHIP_KEYS = %i[data links meta].freeze

      # A relationship that is to-one or to-many must conatin at least one of the following.
      #   A to-many relationship can also contain the addition 'pagination' key.
      TO_ONE_RELATIONSHIP_LINK_KEYS = %i[self related].freeze
      
      # Every resource object MUST contain an id member and a type member.
      RESOURCE_IDENTIFIER_KEYS = %i[type id].freeze

      # A more specific standard error to raise when an exception is found
      class InvalidDocument < StandardError
        attr_accessor :status_code

        # Init w a status code, so that it can be accessed when rescuing an exception
        def initialize(status_code)
          @status_code = status_code
          super
        end
      end
      
      # Checks a request document against the JSON:API spec to see if it complies
      # @param document [String | Hash]  The jsonapi document included with the http request
      # @param opts [Hash] Includes path, http_method, sparse_fieldsets
      # @raise InvalidDocument if any part of the spec is not observed
      def self.check_compliance(document, config_manager = nil, opts = {})
        document = JSONAPI::Parser::JSONParser.parse(document) if document.is_a? String
        ensure!(!document.nil?, 'A document cannot be nil')
        
        check_essentials(document, opts[:http_method])
        check_members(document, opts[:http_method], opts[:path], opts[:sparse_fieldsets])
        check_for_matching_types(document, opts[:http_method], opts[:path])
        check_member_names(document)
        
        usr_opts = { http_method: opts[:http_method], path: opts[:path] }
        err = JSONAPI::Exceptions::UserDefinedExceptions.check_user_document_requirements(document, config_manager, usr_opts)
        raise err unless err.nil?
        
        nil
      end

      # Make helper methods private
      class << self

        # Checks the essentials of a jsonapi document. It is
        #  used by #check_compliance and JSONAPI::Document's #initialize method
        # @param (see #check_compliance)
        def check_essentials(document, http_method)
          ensure!(document.is_a?(Hash),
                  'A JSON object MUST be at the root of every JSON API request ' \
                  'and response containing data')  
          check_top_level(document, http_method)
        end

        # **********************************
        # * CHECK TOP LEVEL                *
        # **********************************

        # Checks if there are any errors in the top level hash
        # @param (see *check_compliance)
        # @raise (see check_compliance)
        def check_top_level(document, http_method)
          ensure!(!(document.keys & REQUIRED_TOP_LEVEL_KEYS).empty?, 
                  'A document MUST contain at least one of the following ' \
                  "top-level members: #{REQUIRED_TOP_LEVEL_KEYS}")

          if document.key? :data
            ensure!(!document.key?(:errors),
                    'The members data and errors MUST NOT coexist in the same document')
          else
            ensure!(!document.key?(:included),
                    'If a document does not contain a top-level data key, the included ' \
                    'member MUST NOT be present either')
            ensure!(http_method.nil?,
                    'The request MUST include a single resource object as primary data, ' \
                    'unless it is a PATCH request clearing a relationship using a relationship link')
          end
        end

        # **********************************
        # * CHECK TOP LEVEL MEMBERS        *
        # **********************************

        # Checks if any errors exist in the jsonapi document members
        # @param http_method [String] The http verb
        # @param sparse_fieldsets [TrueClass | FalseClass | Nilclass]
        # @raise (see #check_compliance)
        def check_members(document, http_method, path, sparse_fieldsets)
          check_individual_members(document, http_method, path)
          check_full_linkage(document, http_method) unless sparse_fieldsets && http_method.nil?
        end

        # Checks individual members of the jsonapi document for errors
        # @param (see #check_compliance)
        # @raise (see #check_complaince)
        def check_individual_members(document, http_method, path)
          check_data(document[:data], http_method, path) if document.key? :data
          check_included(document[:included]) if document.key? :included
          check_meta(document[:meta]) if document.key? :meta
          check_errors(document[:errors]) if document.key? :errors
          check_jsonapi(document[:jsonapi]) if document.key? :jsonapi
          check_links(document[:links]) if document.key? :links
        end

        # -- TOP LEVEL - PRIMARY DATA

        # @param data [Hash | Array<Hash>]  A resource or array or resources
        # @param (see #check_compliance)
        # @param (see #check_compliance)
        # @raise (see #check_compliance)
        def check_data(data, http_method, path)
          ensure!(data.is_a?(Hash) || http_method.nil? || clearing_relationship_link?(data, http_method, path),
                  'The request MUST include a single resource object as primary data, ' \
                  'unless it is a PATCH request clearing a relationship using a relationship link')
          case data
          when Hash
            check_resource(data, http_method)
          when Array
            data.each { |res| check_resource(res, http_method) }
          else
            ensure!(data.nil?,
                    'Primary data must be either nil, an object or an array')
          end
        end

        # @param resource [Hash]  The jsonapi resource object
        # @param (see #check_compliance)
        # @raise (see #check_compliance)
        def check_resource(resource, http_method = nil)
          if http_method == 'POST'
            ensure!(resource[:type],
                    'The resource object (for a post request) MUST contain at least a type member')
          else
            ensure!((resource[:type] && resource[:id]),
                    'Every resource object MUST contain an id member and a type member')
          end
          ensure!(resource[:type].instance_of?(String),
                  'The value of the resource type member MUST be string')
          if resource[:id]
            ensure!(resource[:id].instance_of?(String),
                    'The value of the resource id member MUST be string')
          end
          # Check for sharing a common namespace is in #check_resource_members
          ensure!(JSONAPI::Exceptions::NamingExceptions.check_member_constraints(resource[:type]).nil?,
                  'The values of type members MUST adhere to the same constraints as member names')
          
          check_resource_members(resource)      
        end

        # Checks whether the resource members conform to the spec
        # @param (see #check_resource)
        # @raise (see #check_compliance)
        def check_resource_members(resource)
          check_attributes(resource[:attributes]) if resource.key? :attributes
          check_relationships(resource[:relationships]) if resource.key? :relationships
          check_meta(resource[:meta]) if resource.key? :meta
          check_links(resource[:links]) if resource.key? :links
          ensure!(shares_common_namespace?(resource[:attributes], resource[:relationships]),
                  'Fields for a resource object MUST share a common namespace with each ' \
                  'other and with type and id')
        end

        # @param attributes [Hash]  The attributes for resource
        # @raise (see #check_compliance)
        def check_attributes(attributes)
          ensure!(attributes.is_a?(Hash),
                  'The value of the attributes key MUST be an object')
          # Attribute members can contain any json value (verified using OJ JSON parser), but
          #   must not contain any attribute or links member -- see #check_full_linkage for this check
          # Member names checked separately.
        end

        # @param rels [Hash]  The relationships obj for resource
        # @raise (see #check_compliance)
        def check_relationships(rels)
          ensure!(rels.is_a?(Hash),
                  'The value of the relationships key MUST be an object')
          rels.each_value { |rel| check_relationship(rel) }
        end

        # @param rel [Hash]  A relationship object
        # @raise (see #check_compliance)
        def check_relationship(rel)
          ensure!(rel.is_a?(Hash), 'Each relationships member MUST be a object')
          ensure!(!(rel.keys & RELATIONSHIP_KEYS).empty?,
                  'A relationship object MUST contain at least one of ' \
                  "#{RELATIONSHIP_KEYS}")
          
          # If relationship is a To-Many relationship, the links member may also have pagination links
          #   that traverse the pagination data
          check_relationship_links(rel[:links]) if rel.key? :links
          check_relationship_data(rel[:data]) if rel.key? :data
          check_meta(rel[:meta]) if rel.key? :meta
        end

        # Raise if links don't contain at least one of the TO_ONE_RELATIONSHIP_LINK_KEYS
        # @param links [Hash]  A resource's relationships' relationship-links
        # @raise (see #check_compliance)
        # TODO: If a pagination links are present, they MUST paginate the relationships not the related resource data
        def check_relationship_links(links)
          ensure!(!(links.keys & TO_ONE_RELATIONSHIP_LINK_KEYS).empty?,
                  'A relationship link MUST contain at least one of '\
                  "#{TO_ONE_RELATIONSHIP_LINK_KEYS}")
          check_links(links)
        end

        # @param data [Hash] A resources relationships relationship data
        # @raise (see #check_compliance)
        def check_relationship_data(data)
          case data
          when Hash
            check_resource_identifier(data)
          when Array
            data.each { |res_id| check_resource_identifier(res_id) }
          when nil
            # Do nothing
          else
            ensure!(false, 'Resource linkage (relationship data) MUST be either nil, an object or an array')
          end
        end

        # @param res_id [Hash] A resource identifier object
        def check_resource_identifier(res_id)
          ensure!(res_id.is_a?(Hash),
                  'A resource identifier object MUST be an object')
          ensure!((res_id.keys & RESOURCE_IDENTIFIER_KEYS) == RESOURCE_IDENTIFIER_KEYS,
                  'A resource identifier object MUST contain ' \
                  "#{RESOURCE_IDENTIFIER_KEYS} members")
          ensure!(res_id[:id].is_a?(String), 'The resource identifier id member must be a string')
          ensure!(res_id[:type].is_a?(String), 'The resource identifier type member must be a string')
          check_meta(res_id[:meta]) if res_id.key? :meta
        end

        # -- TOP LEVEL - INCLUDED

        # @param included [Array]  The array of included resources
        # @raise (see #check_compliance)
        def check_included(included)
          ensure!(included.is_a?(Array),
                  'The top level included member MUST be represented as an array of resource objects')
          
          check_included_resources(included)
          # Full linkage check is in #check_members
        end

        # Check each included resource for compliance and make sure each type/id pair is unique
        # @param (see #check_included)
        # @raise (see #check_compliance)
        def check_included_resources(included)
          no_duplicate_type_and_id_pairs = true
          set = {}
          included.each do |res|
            check_resource(res)
            unless unique_pair?(set, res)
              no_duplicate_type_and_id_pairs = false
              break
            end
          end
          ensure!(no_duplicate_type_and_id_pairs,
                  'A compound document MUST NOT include more ' \
                  'than one resource object for each type and id pair.')
        end

        # @param set [Hash] Set of unique pairs so far
        # @param res [Hash] The resource to inspect
        # @return [TrueClass | FalseClass] Whether the resource has a unique
        #   type and id pair
        def unique_pair?(set, res)
          pair = "#{res[:type]}|#{res[:id]}"
          if set.key?(pair)
            return false
          end
          set[pair] = true
          true
        end

        # -- TOP LEVEL - META

        # @param meta [Hash] The meta object
        # @raise (see check_compliance)
        def check_meta(meta)
          ensure!(meta.is_a?(Hash), 'A meta object MUST be an object')
          # Any members may be specified in a meta obj (all members will be valid json bc string is parsed by oj)
        end

        # -- TOP LEVEL - LINKS

        # FIXME:
        # Pagination Links:
        # Only checked for on response
        # Must only be included in links objects
        # Must Paginate member they are inluded in (relationship vs primary resouce vs compound doc)

        # FIXME:
        # Response Questions:
        # 

        # @param links [Hash] The links object
        # @raise (see check_compliance)
        def check_links(links)
          ensure!(links.is_a?(Hash), 'A links object MUST be an object')
          links.each_value { |link| check_link(link) }
          nil
        end

        # @param link [String | Hash] A member of the links object
        # @raise (see check_compliance)
        def check_link(link)
          # A link MUST be either a string URL or an object with href / meta
          case link
          when String
            # Do nothing
          when Hash
            ensure!((link.keys - LINK_KEYS).empty?,
                    'If the link is an object, it can contain the members href or meta')
            ensure!(link[:href].nil? || link[:href].instance_of?(String),
                    'The member href MUST be a string')
            ensure!(link[:meta].nil? || link[:meta].instance_of?(Hash),
                    'The value of each meta member MUST be an object')
          else
            ensure!(false,
                    'A link MUST be represented as either a string or an object')
          end
        end

        # -- TOP LEVEL - JSONAPI

        # @param jsonapi [Hash] The top level jsonapi object
        # @raise (see check_compliance)
        def check_jsonapi(jsonapi)
          ensure!(jsonapi.is_a?(Hash), 'A JSONAPI object MUST be an object')
          if jsonapi.key?(:version)
            ensure!(jsonapi[:version].is_a?(String),
                    "The value of JSONAPI's version member MUST be a string")
          end
          check_meta(jsonapi[:meta]) if jsonapi.key?(:meta)
        end

        # -- TOP LEVEL - ERRORS

        # @param errors [Array] The array of errors contained in the jsonapi document
        # @raise (see #check_compliance)
        def check_errors(errors)
          ensure!(errors.is_a?(Array),
                  'Top level errors member MUST be an array')
          errors.each { |error| check_error(error) }
        end

        # @param error [Hash] The individual error object
        # @raise (see check_compliance)
        def check_error(error)
          ensure!(error.is_a?(Hash),
                  'Error objects MUST be objects')
          check_links(error[:links]) if error.key? :links
          check_links(error[:meta]) if error.key? :meta
        end

        # -- TOP LEVEL - Check Full Linkage
        
        # Checking if document is fully linked
        # @param document [Hash] The jsonapi document
        # @param http_method (see #check_for_matching_types)
        def check_full_linkage(document, http_method)
          return if http_method
          
          ensure!(full_linkage?(document),
                  'Compound documents require “full linkage”, meaning that every included resource MUST be ' \
                  'identified by at least one resource identifier object in the same document.')
        end

        # **********************************
        # * CHECK MEMBER NAMES             *
        # **********************************
        
        # Checks all the member names in a document recursively and raises an error saying
        #   which member did not observe the jsonapi member name rules and which rule
        # @param obj The entire request document or part of the request document.
        # @raise (see #check_compliance)
        def check_member_names(obj)
          case obj
          when Hash
            obj.each do |k, v| 
              check_name(k)
              check_member_names(v)
            end
          when Array
            obj.each { |hsh| check_member_names(hsh) }
          end
          nil
        end

        # @param name The invidual member's name that is being checked
        # @raise (see check_compliance)
        def check_name(name)
          msg = JSONAPI::Exceptions::NamingExceptions.check_member_constraints(name)
          return if msg.nil?
          raise InvalidDocument, "The member named '#{name}' raised: #{msg}"
        end

        # **********************************
        # * CHECK FOR MATCHING TYPES       *
        # **********************************

        # Raises a 409 error if the endpoint type does not match the data type on a post request
        # @param document (see #check_compliance)
        # @param http_method [String] The request request method
        # @param path [String] The request path
        def check_for_matching_types(document, http_method, path)
          return unless http_method
          return unless path
          
          return unless JSONAPI::Utility.all_hash_path?(document, %i[data type])
          
          res_type = document[:data][:type]
          case http_method
          when 'POST'
            path_type = path.split('/')[-1]
            check_post_type(path_type, res_type)
          when 'PATCH'
            temp = path.split('/')
            path_type = temp[-2]
            path_id = temp[-1]
            res_id = document.dig(:data, :id)
            check_patch_type(path_type, res_type, path_id, res_id)
          end
        end

        # Raise 409 unless post resource type == endpoint resource type
        # @param path_type [String] The resource type taken from the request path
        # @param res_type [String] The resource type taken from the request body
        # @raise [JSONAPI::Exceptions::DocumentExceptions::InvalidDocument]
        def check_post_type(path_type, res_type)
          ensure!(path_type.to_s.downcase.gsub(/-/, '_') == res_type.to_s.downcase.gsub(/-/, '_'),
                  "When processing a POST request, the resource object's type MUST " \
                  'be amoung the type(s) that constitute the collection represented by the endpoint',
                  status_code: 409)
        end

        # Raise 409 unless path resource type and id == endpoint resource type and id
        # @param path_type [String] The resource type taken from the request path
        # @param res_type [String] The resource type taken from the request body
        # @param path_id [String] The resource id taken from the path
        # @param res_id [String] The resource id taken from the request body
        # @raise [JSONAPI::Exceptions::DocumentExceptions::InvalidDocument]
        def check_patch_type(path_type, res_type, path_id, res_id)
          check = 
            path_type.to_s.downcase.gsub(/-/, '_') == res_type.to_s.downcase.gsub(/-/, '_') &&
            path_id.to_s.downcase.gsub(/-/, '_') == res_id.to_s.downcase.gsub(/-/, '_')
          ensure!(check,
                  "When processing a PATCH request, the resource object's type and id MUST " \
                  "match the server's endpoint",
                  status_code: 409)
        end

        # ********************************
        # * GENERAL HELPER Methods       *
        # ********************************
        
        # Helper function to raise InvalidDocument errors
        # @param condition The condition to evaluate
        # @param error_message [String]  The message to raise InvalidDocument with
        # @raise InvalidDocument
        def ensure!(condition, error_message, status_code: 400)
          raise InvalidDocument.new(status_code), error_message unless condition
        end

        # Helper Method for #check_top_level ---------------------------------

        # TODO: Write tests for clearing_relationship_link
        def clearing_relationship_link?(data, http_method, path)
          http_method == 'PATCH' && data == [] && relationship_link?(path)
        end

        # Does the path length and values indicate that it is a relationsip link
        # @param path [String] The request path
        def relationship_link?(path)
          path_arr = path.split('/')
          path_arr[-2] == 'relationships' && path_arr.length >= 4
        end
        
        # Helper Method for #check_resource_members --------------------------

        # Checks whether a resource's fields share a common namespace
        # @param attributes [Hash] A resource's attributes
        # @param relationships [Hash] A resource's relationships
        def shares_common_namespace?(attributes, relationships)
          true && \
            !contains_type_or_id_member?(attributes) && \
            !contains_type_or_id_member?(relationships) && \
            keys_intersection_empty?(attributes, relationships)
        end

        # @param hash [Hash] The hash to check
        def contains_type_or_id_member?(hash)
          return false unless hash
          hash.key?(:id) || hash.key?(:type)
        end

        # Checks to see if two hashes share any key members names
        # @param arr1 [Array<Symbol>] The first hash key array
        # @param arr2 [Array<Symbol>] The second hash key array
        def keys_intersection_empty?(arr1, arr2)
          return true unless arr1 && arr2
          arr1.keys & arr2.keys == []
        end

        # Helper Methods for Full Linkage -----------------------------------

        # @param document [Hash] The jsonapi document hash
        def full_linkage?(document)
          return true unless document[:included] 
          # ^ Checked earlier to make sure included only exists w data
          
          possible_includes = get_possible_includes(document)
          any_additional_includes?(possible_includes, document[:included])
        end

        # Get a collection of all possible includes
        #   Need to check relationships on primary resource(s) and also
        #   relationships on the included resource(s)
        # @param (see #full_linkage?)
        # @return [Hash] Collection of possible includes
        def get_possible_includes(document)
          possible_includes = {}
          primary_data = document[:data]
          include_arr = document[:included]
          populate_w_primary_data(possible_includes, primary_data)
          populate_w_include_mem(possible_includes, include_arr)
          possible_includes
        end

        # @param possible_includes [Hash] The collection of possible includes
        # @param actual_includes [Hash] The included top level object
        def any_additional_includes?(possible_includes, actual_includes)
          actual_includes.each do |res|
            return false unless possible_includes.key? res_id_to_sym(res[:type], res[:id])
          end
          true
        end

        # @param possible_includes (see #any_additional_includes?)
        # @param primary_data [Hash] The primary data of a document
        def populate_w_primary_data(possible_includes, primary_data)
          if primary_data.is_a? Array
            primary_data.each do |res|
              populate_w_res_rels(possible_includes, res)
            end
          else
            populate_w_res_rels(possible_includes, primary_data)
          end
        end

        # @param possible_includes (see #any_additional_includes?)
        # @param include_arr [Array<Hash>] The array of includes
        def populate_w_include_mem(possible_includes, include_arr)
          include_arr.each do |res|
            populate_w_res_rels(possible_includes, res)
          end
        end

        # @param possible_includes (see #any_additional_includes?)
        # @param resource [Hash] The resource to check
        def populate_w_res_rels(possible_includes, resource)
          return unless resource[:relationships]
          resource[:relationships].each_value do |rel|
            res_id = rel[:data]
            next unless res_id

            if res_id.is_a? Array
              res_id.each { |id| possible_includes[res_id_to_sym(id[:type], id[:id])] = true }
            else
              possible_includes[res_id_to_sym(res_id[:type], res_id[:id])] = true
            end
          end
        end

        # Creates a hash key using type and id
        # @param type [String] the resource type
        # @param id [String] the resource id
        def res_id_to_sym(type, id)
          "#{type}|#{id}".to_sym
        end
      end
    end
  end
end