# frozen_string_literal: true

#
# Copyright (C) 2011 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#

module Api
  PER_PAGE = 10
  MAX_PER_PAGE = 100

  # For plugin usage during transition; remove after
  def self.max_per_page
    MAX_PER_PAGE
  end

  # find id in collection, by either id or sis_*_id
  # if the collection is over the users table, `self` is replaced by @current_user.id
  # if `writable` is true and a shadow record is found, the corresponding primary record will be returned
  # otherwise a read-only shadow record will be returned, to avoid a silent failure when attempting to save it
  def api_find(collection, id, account: nil, writable: infer_writable_from_request_method)
    result = api_find_all(collection, [id], account:).first
    raise(ActiveRecord::RecordNotFound, "Couldn't find #{collection.name} with API id '#{id}'") unless result

    if result.shadow_record?
      if writable
        result.reload
      else
        result.readonly!
      end
    end

    result
  end

  def api_find_all(collection, ids, account: nil)
    if collection.table_name == User.table_name && @current_user
      ids = ids.map { |id| (id == "self") ? @current_user.id : id }
    end
    if collection.table_name == Account.table_name
      ids = ids.map do |id|
        case id
        when "self"
          @domain_root_account.id
        when "default"
          Account.default.id
        when "site_admin"
          Account.site_admin.id
        else
          id
        end
      end
    end
    if collection.table_name == EnrollmentTerm.table_name
      current_term = nil
      ids = ids.map do |id|
        case id
        when "default"
          @domain_root_account.default_enrollment_term
        when "current"
          unless current_term
            current_terms = @domain_root_account
                            .enrollment_terms
                            .active
                            .where("(start_at<=? OR start_at IS NULL) AND (end_at >=? OR end_at IS NULL) AND NOT (start_at IS NULL AND end_at IS NULL)", Time.now.utc, Time.now.utc)
                            .limit(2)
                            .to_a
            current_term = (current_terms.length == 1) ? current_terms.first : :nil
          end
          (current_term == :nil) ? nil : current_term
        else
          id
        end
      end
    end
    Api.sis_relation_for_collection(collection, ids, account || @domain_root_account, @current_user)
  end

  # map a list of ids and/or sis ids to plain ids.
  # sis ids that can't be found in the db won't appear in the result, however
  # AR object ids aren't verified to exist in the db so they'll still be
  # returned in the result.
  def self.map_ids(ids, collection, root_account, current_user = nil)
    sis_mapping = sis_find_sis_mapping_for_collection(collection)
    columns = sis_parse_ids(ids,
                            sis_mapping[:lookups],
                            current_user,
                            root_account:)
    result = columns.delete(sis_mapping[:lookups]["id"]) || { ids: [] }
    unless columns.empty?
      relation = relation_for_sis_mapping_and_columns(collection, columns, sis_mapping, root_account)
      # pluck ignores eager_load
      relation = relation.joins(*relation.eager_load_values) if relation.eager_load_values.present?
      result[:ids].concat relation.pluck(:id)
      result[:ids].uniq!
      result[:ids]
    end
    result[:ids]
  end

  SIS_MAPPINGS = {
    "courses" => {
      lookups: { "sis_course_id" => "sis_source_id",
                 "id" => "id",
                 "sis_integration_id" => "integration_id",
                 "lti_context_id" => "lti_context_id",
                 "uuid" => "uuid" }.freeze,
      is_not_scoped_to_account: ["id"].freeze,
      root_account_id_column: "root_account_id"
    }.freeze,
    "enrollment_terms" => {
      lookups: { "sis_term_id" => "sis_source_id",
                 "id" => "id",
                 "sis_integration_id" => "integration_id" }.freeze,
      is_not_scoped_to_account: ["id"].freeze,
      root_account_id_column: "root_account_id"
    }.freeze,
    "users" => {
      lookups: { "sis_user_id" => "pseudonyms.sis_user_id",
                 "sis_login_id" => {
                   column: "LOWER(pseudonyms.unique_id)",
                   transform: ->(id) { QuotedValue.new("LOWER(#{Pseudonym.connection.quote(id)})") }
                 }.freeze,
                 "id" => "users.id",
                 "sis_integration_id" => "pseudonyms.integration_id",
                 "lti_context_id" => "users.lti_context_id", # leaving for legacy reasons
                 "lti_user_id" => {
                   column: [
                     "users.lti_context_id",
                     "user_past_lti_ids.user_lti_context_id",
                   ].freeze,
                   scope: ->(relation) { relation.left_joins(:past_lti_ids) }.freeze,
                 }.freeze,
                 "lti_1_1_id" => "users.lti_context_id",
                 "lti_1_3_id" => "users.lti_id",
                 "uuid" => "users.uuid" }.freeze,
      is_not_scoped_to_account: ["users.id", "users.lti_context_id", "user_past_lti_ids.user_lti_context_id", "users.lti_id", "users.uuid"].freeze,
      root_account_id_column: "pseudonyms.account_id",
      joins: :pseudonym
    }.freeze,
    "accounts" => {
      lookups: { "sis_account_id" => "sis_source_id",
                 "id" => "id",
                 "sis_integration_id" => "integration_id",
                 "lti_context_id" => "lti_context_id",
                 "uuid" => "uuid" }.freeze,
      is_not_scoped_to_account: %w[id lti_context_id uuid].freeze,
      root_account_id_column: "root_account_id"
    }.freeze,
    "course_sections" => {
      lookups: { "sis_section_id" => "sis_source_id",
                 "id" => "id",
                 "sis_integration_id" => "integration_id" }.freeze,
      is_not_scoped_to_account: ["id"].freeze,
      root_account_id_column: "root_account_id"
    }.freeze,
    "groups" => {
      lookups: { "sis_group_id" => "sis_source_id",
                 "lti_context_id" => "lti_context_id",
                 "id" => "id" }.freeze,
      is_not_scoped_to_account: ["id"].freeze,
      root_account_id_column: "root_account_id"
    }.freeze,
    "group_categories" => {
      lookups: { "sis_group_category_id" => "sis_source_id",
                 "id" => "id" }.freeze,
      is_not_scoped_to_account: ["id"].freeze,
      root_account_id_column: "root_account_id"
    }.freeze,
    "assignments" => {
      lookups: { "sis_assignment_id" => "sis_source_id",
                 "id" => "id",
                 "lti_context_id" => "lti_context_id" }.freeze,
      is_not_scoped_to_account: ["id"].freeze,
      root_account_id_column: "root_account_id"
    }.freeze,
  }.freeze

  MAX_ID = ((2**63) - 1)
  MAX_ID_LENGTH = MAX_ID.to_s.length
  MAX_ID_RANGE = (-MAX_ID...MAX_ID)
  ID_REGEX = /\A\d{1,#{MAX_ID_LENGTH}}\z/
  UUID_REGEX = /\Auuid:([\w|-]{36,})\z/

  def self.not_scoped_to_account?(columns, sis_mapping)
    flattened_array_of_columns = [columns].flatten
    not_scoped_to_account_columns = sis_mapping[:is_not_scoped_to_account] || []
    (flattened_array_of_columns - not_scoped_to_account_columns).empty?
  end

  def self.sis_parse_id(id,
                        _current_user = nil,
                        root_account: nil)
    # returns sis_column_name, column_value
    return "id", id if id.is_a?(Numeric) || id.is_a?(ActiveRecord::Base)

    id = id.to_s.strip
    case id
    when /\Ahex:(lti_[\w_]+|sis_[\w_]+):(([0-9A-Fa-f]{2})+)\z/
      sis_column = $1
      sis_id = [$2].pack("H*")
    when /\A(lti_[\w_]+|sis_[\w_]+):(.+)\z/
      sis_column = $1
      sis_id = $2
    when ID_REGEX
      return "id", (/\A\d+\z/.match?(id) ? id.to_i : id)
    when UUID_REGEX
      return "uuid", $1
    else
      return nil, nil
    end

    [sis_column, sis_id]
  end

  def self.sis_parse_ids(ids, lookups, current_user = nil, root_account: nil)
    # returns an object like {
    #   "column_name" => {
    #     ids: [column_value, ...].uniq,
    #     scope: [Proc, ...] <-- optional
    #   }
    # }
    columns = {}
    ids.compact.each do |id|
      sis_column, sis_id = sis_parse_id(id, current_user, root_account:)

      next unless sis_column && sis_id

      column = lookups[sis_column]
      if column.is_a?(Hash)
        column_name = column[:column]

        if column[:transform]
          if sis_id.is_a? Array
            # this means that the MRA override sis_parse_id function turned sis_id into [sis_id, @account]
            sis_id[0] = column[:transform].call(sis_id[0])
          else
            sis_id = column[:transform].call(sis_id)
          end
        end
        if (scope = column[:scope])
          columns[column_name] ||= {}
          columns[column_name][:scope] ||= []
          columns[column_name][:scope] << scope
        end
        column = column_name
      end

      next unless column

      columns[column] ||= {}
      columns[column][:ids] ||= []
      columns[column][:ids] << sis_id
    end
    columns.each_key { |key| columns[key][:ids].uniq! }
    columns
  end

  # remove things that don't look like valid database IDs
  # return in integer format if possible
  # (note that ID_REGEX may be redefined by a plugin!)
  def self.map_non_sis_ids(ids)
    ids.map { |id| id.to_s.strip }.grep(ID_REGEX).map do |id|
      /\A\d+\z/.match?(id) ? id.to_i : id
    end
  end

  def self.sis_find_sis_mapping_for_collection(collection)
    SIS_MAPPINGS[collection.table_name] or
      raise(ArgumentError, "need to add support for table name: #{collection.table_name}")
  end

  def self.sis_relation_for_collection(collection, ids, sis_root_account, current_user = nil)
    relation_for_sis_mapping(collection,
                             sis_find_sis_mapping_for_collection(collection),
                             ids,
                             sis_root_account,
                             current_user)
  end

  def self.relation_for_sis_mapping(relation, sis_mapping, ids, sis_root_account, current_user = nil)
    relation_for_sis_mapping_and_columns(relation,
                                         sis_parse_ids(ids,
                                                       sis_mapping[:lookups],
                                                       current_user,
                                                       root_account: sis_root_account),
                                         sis_mapping,
                                         sis_root_account)
  end

  def self.relation_for_sis_mapping_and_columns(relation, columns, sis_mapping, sis_root_account)
    raise ArgumentError, "sis_root_account required for lookups" unless sis_root_account.is_a?(Account)
    return relation.none if columns.empty?

    relation = relation.all unless relation.is_a?(ActiveRecord::Relation)

    if columns.keys.flatten.length == 1 && not_scoped_to_account?(columns.keys.first, sis_mapping)
      column_def = columns.first.last
      Array.wrap(column_def[:scope]).each do |proc|
        relation = proc.call(relation)
      end
      relation = relation.where(columns.first.first => column_def[:ids])
    else
      args = []
      query = []
      columns.each_key do |column|
        Array.wrap(columns[column][:scope]).each do |proc|
          relation = proc.call(relation)
        end
        if not_scoped_to_account?(column, sis_mapping)
          conditions = []
          if column.is_a?(Array)
            column.each do |column_name|
              conditions << "#{column_name} IN (?)"
              args << columns[column][:ids]
            end
          else
            conditions << "#{column} IN (?)"
            args << columns[column][:ids]
          end
          query << conditions.join(" OR ").to_s
        else
          raise ArgumentError, "missing scope for collection" unless sis_mapping[:root_account_id_column]

          ids = columns[column][:ids]
          if ids.any?(Array)
            ids_hash = {}
            ids.each do |id|
              id = Array(id)
              account = id.last || sis_root_account
              ids_hash[account] ||= []
              ids_hash[account] << id.first
            end
          else
            ids_hash = { sis_root_account => ids }
          end
          Shard.partition_by_shard(ids_hash.keys) do |root_accounts_on_shard|
            sub_query = []
            sub_args = []
            root_accounts_on_shard.each do |root_account|
              ids = ids_hash[root_account]
              conditions = []
              if column.is_a?(Array)
                column.each do |column_name|
                  conditions << "#{column_name} IN (?)"
                  sub_args << ids
                end
              else
                conditions << "#{column} IN (?)"
                sub_args << ids
              end
              sub_query << "(#{sis_mapping[:root_account_id_column]} = #{root_account.id} AND (#{conditions.join(" OR ")}))"
            end
            if Shard.current == relation.primary_shard
              query.concat(sub_query)
              args.concat(sub_args)
            else
              raise "cross-shard non-ID Api lookups are only supported for users" unless relation.klass == User

              sub_args.unshift(sub_query.join(" OR "))
              users = relation.klass.joins(sis_mapping[:joins]).where(*sub_args).select(:id, :updated_at).to_a
              User.preload_shard_associations(users)
              users.each { |u| u.associate_with_shard(relation.primary_shard, :shadow) }
              query << "#{relation.table_name}.id IN (?)"
              args << users
            end
          end
        end
      end

      args.unshift(query.join(" OR "))
      relation = relation.where(*args)
      relation
    end

    relation = relation.eager_load(sis_mapping[:joins]) if sis_mapping[:joins]
    relation
  end

  def self.per_page_for(controller, options = {})
    per_page_requested = controller.params[:per_page] || options[:default] || PER_PAGE
    max = options[:max] || MAX_PER_PAGE
    per_page_requested.to_i.clamp(1, max.to_i)
  end

  # Add [link HTTP Headers](http://www.w3.org/Protocols/9707-link-header.html) for pagination
  # The collection needs to be a will_paginate collection (or act like one)
  # a new, paginated collection will be returned
  def self.paginate(collection, controller, base_url, pagination_args = {}, response_args = {})
    collection = ordered_collection(collection)
    collection = paginate_collection!(collection, controller, pagination_args)
    hash = build_links_hash(base_url, meta_for_pagination(controller, collection))
    links = build_links_from_hash(hash)
    controller.response.headers["Link"] = links.join(",") unless links.empty?
    if response_args[:enhanced_return]
      { hash:, collection: }
    else
      collection
    end
  end

  def self.ordered_collection(collection)
    if collection.is_a?(ActiveRecord::Relation) && collection.order_values.blank?
      collection = collection.order(collection.primary_key.to_sym)
    end
    collection
  end

  # Returns collection as the first return value, and the meta information hash
  # as the second return value
  def self.jsonapi_paginate(collection, controller, base_url, pagination_args = {})
    collection = paginate_collection!(collection, controller, pagination_args)
    meta = jsonapi_meta(collection, controller, base_url)
    hash = build_links_hash(base_url, meta_for_pagination(controller, collection))
    links = build_links_from_hash(hash)
    controller.response.headers["Link"] = links.join(",") unless links.empty?
    [collection, meta]
  end

  def self.jsonapi_meta(collection, controller, base_url)
    pagination = meta_for_pagination(controller, collection)

    meta = {
      per_page: collection.per_page
    }

    meta.merge!(build_links_hash(base_url, pagination))

    if collection.ordinal_pages?
      meta[:page] = pagination[:current]
      meta[:template] = meta[:current].sub(/page=\d+/, "page={page}")
    end

    meta[:count] = collection.total_entries if collection.total_entries
    meta[:page_count] = collection.total_pages if collection.total_pages

    { pagination: meta }
  end

  def self.paginate_collection!(collection, controller, pagination_args)
    wrap_pagination_args!(pagination_args, controller)
    begin
      paginated = collection.paginate(pagination_args)
    rescue Folio::InvalidPage
      # Have to .try(:build_page) because we use some collections (like
      # PaginatedCollection) that do not conform to the full will_paginate API.
      if pagination_args[:page].to_s =~ /\d+/ && pagination_args[:page].to_i > 0 && collection.try(:build_page)&.ordinal_pages?
        # for backwards compatibility we currently require returning [] for
        # pages beyond the end of an ordinal collection, rather than a 404.
        paginated = Folio::Ordinal::Page.create
        paginated.current_page = pagination_args[:page].to_i
      else
        # we're not dealing with a simple out-of-bounds on an ordinal
        # collection, let the exception propagate (and turn into a 404)
        raise
      end
    end
    paginated
  end

  def self.wrap_pagination_args!(pagination_args, controller)
    pagination_args = pagination_args.to_unsafe_h if pagination_args.is_a?(ActionController::Parameters)
    pagination_args.reverse_merge!(
      page: controller.params[:page],
      per_page: per_page_for(controller,
                             default: pagination_args.delete(:default_per_page),
                             max: pagination_args.delete(:max_per_page))
    )
  end

  def self.meta_for_pagination(controller, collection)
    {
      query_parameters: controller.request.query_parameters,
      per_page: collection.per_page,
      current: collection.current_page,
      next: collection.next_page,
      prev: collection.previous_page,
      first: collection.first_page,
      last: collection.last_page,
    }
  end

  PAGINATION_PARAMS = %i[current next prev first last].freeze
  LINK_PRIORITY = %i[next last prev current first].freeze
  EXCLUDE_IN_PAGINATION_LINKS = %w[page per_page access_token api_key].freeze
  def self.build_links(base_url, opts = {})
    links = build_links_hash(base_url, opts)
    build_links_from_hash(links)
  end

  def self.build_links_from_hash(links)
    # iterate in order, but only using the keys present from build_links_hash
    (PAGINATION_PARAMS & links.keys).map do |k|
      v = links[k]
      "<#{v}>; rel=\"#{k}\""
    end
  end

  def self.build_links_hash(base_url, opts = {})
    base_url += (base_url.include?("?") ? "&" : "?")
    qp = opts[:query_parameters] || {}
    qp = qp.with_indifferent_access.except(*EXCLUDE_IN_PAGINATION_LINKS)
    base_url += "#{qp.to_query}&" if qp.present?

    # Apache limits the HTTP response headers to 8KB total; with lots of query parameters, link headers can exceed this
    # so prioritize the links we include and don't exceed (by default) 6KB in total
    max_link_headers_size = 6.kilobytes.to_i
    link_headers_size = 0
    LINK_PRIORITY.each_with_object({}) do |param, obj|
      next unless opts[param].present?

      link = "#{base_url}page=#{opts[param]}&per_page=#{opts[:per_page]}"
      return obj if link_headers_size + link.size > max_link_headers_size

      link_headers_size += link.size
      obj[param] = link
    end
  end

  def self.pagination_params(base_url)
    if base_url.length > 65_536
      # to prevent Link headers from consuming too much of the 8KB Apache allows in response headers
      ESSENTIAL_PAGINATION_PARAMS
    else
      PAGINATION_PARAMS
    end
  end

  def self.parse_pagination_links(link_header)
    link_header.split(",").map do |link|
      url, rel = link.match(/^<([^>]+)>; rel="([^"]+)"/).captures
      uri = URI.parse(url)
      raise(ArgumentError, "pagination url is not an absolute uri: #{url}") unless uri.is_a?(URI::HTTP)

      Rack::Utils.parse_nested_query(uri.query).merge(uri:, rel:)
    end
  end

  def media_comment_json(media_object_or_hash, location: nil)
    media_object_or_hash = OpenStruct.new(media_object_or_hash) if media_object_or_hash.is_a?(Hash)
    convert_media_type = Attachment.mime_class(media_object_or_hash.media_type)
    {
      "content-type" => "#{convert_media_type}/mp4",
      "display_name" => media_object_or_hash.title.presence || media_object_or_hash.user_entered_title,
      "media_id" => media_object_or_hash.media_id,
      "media_type" => convert_media_type,
      "url" => user_media_download_url(user_id: @current_user.id,
                                       entryId: media_object_or_hash.media_id,
                                       type: "mp4",
                                       redirect: "1",
                                       location: (location if @domain_root_account&.feature_enabled?(:file_association_access))),
    }
  end

  def self.api_bulk_load_user_content_attachments(htmls, context = nil)
    regex = context ? %r{/#{context.class.name.tableize}/#{context.id}/files/(\d+)} : %r{/(?:files|media_attachments_iframe)/(\d+)}

    attachment_ids = []
    htmls.compact.each do |html|
      html.scan(regex).each do |match|
        attachment_ids << match.first
      end
    end

    if attachment_ids.blank?
      {}
    else
      attachments = if context.is_a?(User) || context.nil?
                      Attachment.where(id: attachment_ids)
                    else
                      context.attachments.where(id: attachment_ids)
                    end

      attachments.preload(:context).index_by(&:id)
    end
  end

  def api_bulk_load_user_content_attachments(htmls, context = nil)
    Api.api_bulk_load_user_content_attachments(htmls, context)
  end

  PLACEHOLDER_PROTOCOL = "https"
  PLACEHOLDER_HOST = "placeholder.invalid"

  def get_host_and_protocol_from_request
    [request.host_with_port, request.ssl? ? "https" : "http"]
  end

  def resolve_placeholders(content)
    host, protocol = get_host_and_protocol_from_request
    # content is a json-encoded string; slashes are escaped (at least in Rails 4.0)
    content.gsub("#{PLACEHOLDER_PROTOCOL}:\\/\\/#{PLACEHOLDER_HOST}", "#{protocol}:\\/\\/#{host}")
           .gsub("#{PLACEHOLDER_PROTOCOL}://#{PLACEHOLDER_HOST}", "#{protocol}://#{host}")
  end

  def user_can_download_attachment?(attachment, context, user)
    # checking on the context first can improve performance when checking many attachments for admins
    context&.grants_any_right?(
      user,
      :read_as_admin,
      *RoleOverride::GRANULAR_FILE_PERMISSIONS
    ) || attachment&.grants_right?(user, nil, :download)
  end

  def api_user_content(html,
                       context = @context,
                       user = @current_user,
                       preloaded_attachments = {},
                       options = {},
                       is_public = false,
                       location: nil)
    return html if html.blank?

    # use the host of the request if available;
    # use a placeholder host for pre-generated content, which we will replace with the request host when available;
    # otherwise let HostUrl figure out what host is appropriate
    if respond_to?(:request)
      host, protocol = get_host_and_protocol_from_request
      target_shard = Shard.current
    elsif respond_to?(:use_placeholder_host?) && use_placeholder_host?
      host = PLACEHOLDER_HOST
      protocol = PLACEHOLDER_PROTOCOL
    else
      host = HostUrl.context_host(context, @account_domain.try(:host))
      protocol = HostUrl.protocol
    end
    domain_root_account = @domain_root_account || options[:domain_root_account]
    no_verifiers = domain_root_account&.feature_enabled?(:disable_adding_uuid_verifier_in_api) || (params[:no_verifiers] if defined?(params))
    html = context.shard.activate do
      rewriter = UserContent::HtmlRewriter.new(context, user)
      file_handler = proc do |match|
        UserContent::FilesHandler.new(
          match:,
          context:,
          user:,
          preloaded_attachments:,
          is_public:,
          in_app: respond_to?(:in_app?, true) && in_app?,
          no_verifiers:,
          location: (location if domain_root_account&.feature_enabled?(:file_association_access))
        ).processed_url
      end
      rewriter.set_handler("files", &file_handler)
      rewriter.set_handler("media_attachments_iframe", &file_handler)

      rewriter.translate_content(html)
    end

    url_helper = Html::UrlProxy.new(self,
                                    context,
                                    host,
                                    protocol,
                                    target_shard:)
    account = Context.get_account(context) || @domain_root_account
    include_mobile = !(respond_to?(:in_app?, true) && in_app?)
    # Native mobile apps are detected via User-Agent strings
    is_native_mobile_app = respond_to?(:native_app?, true) && native_app?
    Html::Content.rewrite_outgoing(
      html,
      account,
      url_helper,
      include_mobile:,
      is_native_mobile_app:,
      rewrite_api_urls: options[:rewrite_api_urls]
    )
  end

  # This removes the verifier parameters that are added to attachment links by api_user_content
  # and adds context (e.g. /courses/:id/) if it is missing
  # exception: it leaves user-context file links alone
  def process_incoming_html_content(html)
    host, port = [request.host, request.port] if respond_to?(:request)
    Html::Content.process_incoming(html, host:, port:)
  end

  delegate :value_to_boolean, to: :"Canvas::Plugin"

  # takes a comma separated string, an array, or nil and returns an array
  def self.value_to_array(value)
    value.is_a?(String) ? value.split(",") : (value || [])
  end

  def self.invalid_time_stamp_error(attribute, message)
    data = {
      message: "invalid #{attribute}",
      exception_message: message
    }
    Canvas::Errors.capture("invalid_date_time", data, :info)
  end

  # regex for valid iso8601 dates
  ISO8601_REGEX = /^(?<year>[0-9]{4})-
                    (?<month>1[0-2]|0[1-9])-
                    (?<day>3[0-1]|0[1-9]|[1-2][0-9])T
                    (?<hour>2[0-3]|[0-1][0-9]):
                    (?<minute>[0-5][0-9]):
                    (?<second>60|[0-5][0-9])
                    (?<fraction>\.[0-9]+)?
                    (?<timezone>Z|[+-](?:2[0-3]|[0-1][0-9]):[0-5][0-9])?$/x

  # regex for valid dates
  DATE_REGEX = %r{^\d{4}[- /.](0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])$}

  # regex for shard-aware ID
  ID = '(?:\d+~)?\d+'

  # maps a Canvas data type to an API-friendly type name
  API_DATA_TYPE = { "Attachment" => "File",
                    "WikiPage" => "Page",
                    "DiscussionTopic" => "Discussion",
                    "Assignment" => "Assignment",
                    "Quizzes::Quiz" => "Quiz",
                    "ContextModuleSubHeader" => "SubHeader",
                    "ExternalUrl" => "ExternalUrl",
                    "ContextExternalTool" => "ExternalTool",
                    "ContextModule" => "Module",
                    "ContentTag" => "ModuleItem" }.freeze

  # matches the other direction, case insensitively
  def self.api_type_to_canvas_name(api_type)
    unless @inverse_map
      m = {}
      API_DATA_TYPE.each do |k, v|
        m[v.downcase] = k
      end
      @inverse_map = m
    end
    return nil unless api_type

    @inverse_map[api_type.downcase]
  end

  def accepts_jsonapi?
    !!request.headers["Accept"].to_s.include?("application/vnd.api+json")
  end

  # Return a template url that follows the root links key for the jsonapi.org
  # standard.
  def templated_url(method, *args)
    format = /^\{.*\}$/
    placeholder = "PLACEHOLDER"

    placeholders = args.each_with_index.map do |arg, index|
      arg&.match?(format) ? "#{placeholder}#{index}" : arg
    end

    url = send(method, *placeholders)

    args.each_with_index do |arg, index|
      url.sub!("#{placeholder}#{index}", arg) if arg&.match?(format)
    end

    url
  end

  private

  def infer_writable_from_request_method
    respond_to?(:request) && %w[PUT POST PATCH DELETE].include?(request&.method)
  end
end
