# frozen_string_literal: true

#
# Copyright (C) 2016 - 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 InstFS
  LONG_JWT_EXPIRATION = 10.minutes
  SHORT_JWT_EXPIRATION = 5.minutes
  class << self
    def enabled?
      # true if plugin is enabled AND all settings values are set
      Canvas::Plugin.find("inst_fs").enabled? && !!app_host && !!jwt_secret
    end

    def check_migration_rate?
      rand < Canvas::Plugin.find("inst_fs").settings[:migration_rate].to_f / 100.0
    end

    def service_worker_enabled?
      Canvas::Plugin.value_to_boolean(Canvas::Plugin.find("inst_fs").settings[:service_worker])
    end

    def migrate_attachment?(attachment)
      enabled? && !attachment.instfs_hosted? && Attachment.s3_storage? && check_migration_rate?
    end

    def login_pixel(user, session, oauth_host)
      return if session[:oauth2] # don't stomp an existing oauth flow in progress
      return if session[:pending_otp]

      if !session[:shown_instfs_pixel] && user && enabled?
        session[:shown_instfs_pixel] = true
        pixel_url = login_pixel_url(token: session_jwt(user, oauth_host))
        %(<img src="#{pixel_url}" alt="" role="presentation" />).html_safe
      end
    end

    def logout(user)
      return unless user && enabled?

      CanvasHttp.delete(logout_url(user))
    rescue CanvasHttp::Error => e
      Canvas::Errors.capture_exception(:page_view, e, :warn)
    end

    def bearer_token(options)
      expires_in = options[:expires_in] || 5.minutes
      claims = {
        iat: Time.now.utc.to_i,
        user_id: options[:user]&.global_id&.to_s
      }
      Canvas::Security.create_jwt(claims, expires_in.from_now, jwt_secret, :HS512)
    end

    def authenticated_url(attachment, options = {})
      query_params = { token: access_jwt(access_path(attachment), options) }
      query_params[:download] = 1 if options[:download]
      access_url(attachment, query_params)
    end

    def authenticated_metadata_url(attachment, options = {})
      query_params = { token: access_jwt(metadata_path(attachment), options) }
      metadata_url(attachment, query_params)
    end

    def logout_url(user)
      query_params = { token: logout_jwt(user) }
      service_url("/session", query_params)
    end

    def authenticated_thumbnail_url(attachment, options = {})
      query_params = { token: access_jwt(thumbnail_path(attachment), options) }
      query_params[:geometry] = options[:geometry] if options[:geometry]
      thumbnail_url(attachment, query_params)
    end

    def export_references_url
      query_params = { token: export_references_jwt }
      service_url("/references", query_params)
    end

    def app_host
      setting("app_host")
    end

    def jwt_secrets
      secret = setting("secret")
      return [] unless secret

      secret.split(/\s+/).map { |key| Base64.decode64(key) }
    end

    def jwt_secret
      # if there are multiple keys (to allow for validating during key
      # rotation), the foremost is used for signing
      jwt_secrets.first
    end

    def validate_capture_jwt(token)
      Canvas::Security.decode_jwt(token, jwt_secrets)
      true
    rescue
      false
    end

    def upload_preflight_json(context:,
                              root_account:,
                              user:,
                              acting_as:,
                              access_token:,
                              folder:,
                              filename:,
                              content_type:,
                              quota_exempt:,
                              on_duplicate:,
                              capture_url:,
                              target_url: nil,
                              progress_json: nil,
                              include_param: nil,
                              additional_capture_params: {})
      raise ArgumentError unless !!target_url == !!progress_json # these params must both be present or both absent

      token = upload_jwt(
        user:,
        acting_as:,
        access_token:,
        root_account:,
        capture_url:,
        capture_params: additional_capture_params.merge(
          context_type: context.class.to_s,
          context_id: context.global_id.to_s,
          user_id: acting_as.global_id.to_s,
          folder_id: folder&.global_id&.to_s,
          root_account_id: root_account.global_id.to_s,
          quota_exempt: !!quota_exempt,
          on_duplicate:,
          progress_id: progress_json && progress_json[:id],
          include: include_param
        )
      )

      upload_params = {
        filename:,
        content_type:
      }
      if target_url
        upload_params[:target_url] = target_url
      end

      {
        file_param: target_url ? nil : "file",
        progress: progress_json,
        upload_url: upload_url(token),
        upload_params:
      }
    end

    def direct_upload(file_name:, file_object:)
      # example of a call to direct_upload:
      # > res = InstFS.direct_upload(
      # >   file_name: "a.png",
      # >   file_object: File.open("public/images/a.png")
      # > )

      token = direct_upload_jwt(file_object)
      url = "#{app_host}/files?token=#{token}"

      data = {}
      data[file_name] = file_object

      begin
        retries ||= 0
        response = CanvasHttp.post(url, form_data: data, multipart: true, streaming: true)
      rescue Timeout::Error
        if file_object.respond_to?(:rewind) && (retries += 1) < 2
          file_object.rewind
          retry
        end
        raise InstFS::ServiceError, "timed out communicating with instfs"
      rescue CanvasHttp::CircuitBreakerError
        raise InstFS::ServiceError, "unable to communicate with instfs"
      end
      if response.code.to_i == 201
        json_response = JSON.parse(response.body)
        return json_response["instfs_uuid"] if json_response.key?("instfs_uuid")

        raise InstFS::DirectUploadError, "upload succeeded, but response did not contain an \"instfs_uuid\" key"
      end

      err_message = "received code \"#{response.code}\" from service, with message \"#{response.body}\""
      if response.code.to_i >= 500
        raise InstFS::ServiceError, err_message
      elsif response.code.to_i == 400
        raise InstFS::BadRequestError, err_message
      end

      raise InstFS::DirectUploadError, err_message
    end

    def export_reference(attachment)
      raise InstFS::ExportReferenceError, "attachment already has instfs_uuid" if attachment.instfs_hosted?
      raise InstFS::ExportReferenceError, "can't export non-s3 attachments to inst-fs" unless Attachment.s3_storage?

      # compare to s3_bucket_url in the aws-sdk-s3 gem's
      # lib/aws-sdk-s3/customizations/bucket.rb; we're leaving out the bucket
      # name from the url. otherwise, this is effectively
      # `attachment.bucket.url`
      s3_client = attachment.bucket.client
      s3_url = s3_client.config.endpoint.dup
      if s3_client.config.region == "us-east-1" &&
         s3_client.config.s3_us_east_1_regional_endpoint == "legacy"
        s3_url.host = s3_url.host.sub(".us-east-1", "")
      end

      body = {
        objectStore: {
          type: "s3",
          params: {
            host: s3_url.to_s,
            bucket: attachment.bucket.name
          }
        },
        # single reference
        references: [{
          storeKey: attachment.full_filename,
          timestamp: attachment.created_at.to_i,
          filename: attachment.filename,
          displayName: attachment.display_name,
          content_type: attachment.content_type,
          encoding: attachment.encoding,
          size: attachment.size,
          user_id: attachment.context_user&.global_id&.to_s,
          root_account_id: attachment.root_account&.global_id&.to_s,
          sha512: nil, # to be calculated by inst-fs
        }]
      }.to_json

      response = CanvasHttp.post(export_references_url, body:, content_type: "application/json")
      raise InstFS::ExportReferenceError, "received code \"#{response.code}\" from service, with message \"#{response.body}\"" unless response.code.to_i == 200

      json_response = JSON.parse(response.body)
      well_formed =
        json_response.is_a?(Hash) &&
        json_response.key?("success") &&
        json_response["success"].is_a?(Array) &&
        json_response["success"].length == 1 &&
        json_response["success"][0].is_a?(Hash)
      json_response["success"][0].key?("id")
      raise InstFS::ExportReferenceError, "import succeeded, but response body did not have expected shape" unless well_formed

      json_response["success"][0]["id"]
    end

    def duplicate_file(instfs_uuid, new_tenant_auth: nil, tenant_auth: nil)
      token = duplicate_file_jwt(instfs_uuid, new_tenant_auth:, tenant_auth:)
      url = "#{app_host}/files/#{instfs_uuid}/duplicate?token=#{token}"

      response = CanvasHttp.post(url)
      if response.code.to_i == 201
        json_response = JSON.parse(response.body)
        return json_response["id"] if json_response.key?("id")

        raise InstFS::DuplicationError, "duplication succeeded, but response did not contain an \"id\" key"
      end
      raise InstFS::DuplicationError, "received code \"#{response.code}\" from service, with message \"#{response.body}\""
    end

    def delete_file(instfs_uuid)
      token = delete_file_jwt(instfs_uuid)
      url = "#{app_host}/files/#{instfs_uuid}?token=#{token}"

      response = CanvasHttp.delete(url)
      unless response.code.to_i == 200
        raise InstFS::DeletionError, "received code \"#{response.code}\" from service, with message \"#{response.body}\""
      end

      true
    end

    def get_file_metadata(attachment)
      token = metadata_file_jwt(attachment)
      url = metadata_url(attachment, {})

      response = CanvasHttp.get(url, { "Authorization" => "Bearer #{token}" })
      return JSON.parse(response.body) if response.code.to_i == 200

      raise InstFS::MetadataError, "received code \"#{response.code}\" from service, with message \"#{response.body}\""
    end

    private

    def setting(key)
      Rails.application.credentials.inst_fs&.with_indifferent_access&.[](key)
    end

    def service_url(path, query_params = nil)
      url = "#{app_host}#{path}"
      url += "?#{query_params.to_query}" if query_params
      url
    end

    def login_pixel_url(query_params)
      service_url("/session/ensure", query_params)
    end

    def access_url(attachment, query_params)
      service_url(access_path(attachment), query_params)
    end

    def metadata_url(attachment, query_params)
      service_url(metadata_path(attachment), query_params)
    end

    def thumbnail_url(attachment, query_params)
      service_url(thumbnail_path(attachment), query_params)
    end

    def upload_url(token = nil)
      query_string = { token: } if token
      service_url("/files", query_string)
    end

    def access_path(attachment)
      res = "/files/#{attachment.instfs_uuid}"
      display_name = attachment.display_name || attachment.filename
      if display_name
        unencoded_characters = Addressable::URI::CharacterClasses::UNRESERVED
        encoded_display_name = Addressable::URI.encode_component(display_name, unencoded_characters)
        res += "/#{encoded_display_name}"
      end
      res
    end

    def metadata_path(attachment)
      "/files/#{attachment.instfs_uuid}/metadata"
    end

    def thumbnail_path(attachment)
      "/thumbnails/#{attachment.instfs_uuid}"
    end

    # `expires_at` can be either a Time or an ActiveSupport::Duration
    def service_jwt(claims, expires_at)
      expires_at = expires_at.from_now if expires_at.respond_to?(:from_now)
      Canvas::Security.create_jwt(claims, expires_at, jwt_secret, :HS512)
    end

    # floor_to rounds `number` down to a multiple of the chosen step.
    def floor_to(number, step)
      whole, = number.divmod(step)
      whole * step
    end

    # If we just say every token was created at Time.now, since that token
    # is included in the url, every time we make a url it will be a new url and no browser
    # will never be able to get it from their cache. Which means, for example: every time you
    # load your dash cards you will download all new thumbnails instead of using one from
    # your browser cache. That's not what we want to do.
    #
    # But we also don't want to just have them all expire at the same time because then we'd
    # get a thundering herd at the end of that cache window.
    #
    # So what we do is have all tokens for a certain resource say they were signed at same
    # time within a 12 hour window. that way you're browser will be able to cache it for at
    # least 12 hours and up to 24. And instead of picking something like the beginning of
    # the day or hour, we use a random offset that is evenly distributed throughout the
    # cache window. (this example uses 24 and 12 hours because the default expiration time is
    # 24 hours, but the logic is the same if you say expires_in is 2 hours or 24 hours, it
    # just makes sure that there is at least half of the availibilty time left before it expires)
    def consistent_iat(resource, expires_in)
      now = Time.now.utc.to_i
      window = expires_in.to_i / 2
      beginning_of_cache_window = floor_to(now, window)
      this_resources_random_offset = resource.hash % window
      if (beginning_of_cache_window + this_resources_random_offset) > now
        # step back a window if adding the random offset would put us into the future
        beginning_of_cache_window -= window
      end
      beginning_of_cache_window + this_resources_random_offset
    end

    def access_jwt(resource, options = {})
      expires_in = options[:expires_in] || 1.day
      iat = if expires_in >= 1.hour.to_i
              consistent_iat(resource, expires_in)
            else
              Time.now.utc.to_i
            end

      claims = {
        iat:,
        user_id: options[:user]&.global_id&.to_s,
        resource:,
        host: options[:oauth_host]
      }
      claims[:jti] = SecureRandom.uuid unless options[:no_jti]
      claims[:tenant_auth] = @token.tenant_auth if @token&.tenant_auth.present?
      claims[:fallback_url] = options[:fallback_url] if options[:fallback_url].present?
      if options[:acting_as] && options[:acting_as] != options[:user]
        claims[:acting_as_user_id] = options[:acting_as].global_id.to_s
      end
      if options[:internal]
        claims[:internal] = true
      end
      amend_claims_for_access_token(claims, options[:access_token], options[:root_account])
      service_jwt(claims, Time.zone.at(iat) + expires_in)
    end

    def upload_jwt(user:, acting_as:, access_token:, root_account:, capture_url:, capture_params:)
      claims = {
        iat: Time.now.utc.to_i,
        user_id: user.global_id.to_s,
        resource: "/files",
        capture_url:,
        capture_params:
      }
      unless acting_as == user
        claims[:acting_as_user_id] = acting_as.global_id.to_s
      end
      amend_claims_for_access_token(claims, access_token, root_account)
      service_jwt(claims, LONG_JWT_EXPIRATION)
    end

    def direct_upload_jwt(file_object)
      claims = {
        iat: Time.now.utc.to_i,
        user_id: nil,
        host: "canvas",
        resource: "/files",
      }

      claims[:filesize] = file_object.size if file_object.respond_to?(:size)

      service_jwt(claims, LONG_JWT_EXPIRATION)
    end

    def session_jwt(user, host)
      service_jwt({
                    iat: Time.now.utc.to_i,
                    user_id: user.global_id&.to_s,
                    host:,
                    resource: "/session/ensure"
                  },
                  SHORT_JWT_EXPIRATION)
    end

    def logout_jwt(user)
      service_jwt({
                    iat: Time.now.utc.to_i,
                    user_id: user.global_id&.to_s,
                    resource: "/session"
                  },
                  SHORT_JWT_EXPIRATION)
    end

    def export_references_jwt
      service_jwt({
                    iat: Time.now.utc.to_i,
                    resource: "/references"
                  },
                  SHORT_JWT_EXPIRATION)
    end

    def duplicate_file_jwt(instfs_uuid, new_tenant_auth: nil, tenant_auth: nil)
      jwt_contents = {
        iat: Time.now.utc.to_i,
        resource: "/files/#{instfs_uuid}/duplicate"
      }
      jwt_contents[:new_tenant_auth] = new_tenant_auth if new_tenant_auth.present?
      jwt_contents[:tenant_auth] = tenant_auth if tenant_auth.present?
      service_jwt(jwt_contents, SHORT_JWT_EXPIRATION)
    end

    def delete_file_jwt(instfs_uuid)
      service_jwt({
                    iat: Time.now.utc.to_i,
                    resource: "/files/#{instfs_uuid}"
                  },
                  SHORT_JWT_EXPIRATION)
    end

    def metadata_file_jwt(attachment)
      jwt_contents = {
        iat: Time.now.utc.to_i,
        resource: metadata_path(attachment)
      }
      jwt_contents[:tenant_auth] = attachment.instfs_tenant_auth if attachment.instfs_tenant_auth.present?
      service_jwt(jwt_contents, SHORT_JWT_EXPIRATION)
    end

    def amend_claims_for_access_token(claims, access_token, root_account)
      return unless access_token

      if whitelisted_access_token?(access_token)
        # temporary workaround for legacy API consumers
        claims[:legacy_api_developer_key_id] = access_token.global_developer_key_id.to_s
        claims[:legacy_api_root_account_id] = root_account.global_id.to_s
      else
        nil # TODO: long term solution for updated API consumers goes here
      end
    end

    def whitelisted_access_token?(access_token)
      if access_token.nil?
        false
      elsif Setting.get("instfs.whitelist_all_developer_keys", "false") == "true"
        true
      else
        whitelist = Setting.get("instfs.whitelisted_developer_key_global_ids", "")
        whitelist = whitelist.split(",").map(&:to_i)
        whitelist.include?(access_token.global_developer_key_id)
      end
    end
  end

  class DirectUploadError < StandardError; end

  class ServiceError < DirectUploadError
    def response_status
      502
    end
  end

  class BadRequestError < DirectUploadError
    def response_status
      400
    end
  end

  class ExportReferenceError < StandardError; end

  class DuplicationError < StandardError; end

  class DeletionError < StandardError; end

  class MetadataError < StandardError; end
end
