#!/usr/bin/env ruby
# Ruby 3.1+; credentials stay on your server. No gems required.
require 'json'
require 'net/http'
require 'set'
require 'timeout'
require 'uri'

def monotonic_time
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

def encode_path_segment(value)
  URI.encode_www_form_component(value.to_s).gsub('+', '%20')
end

class PreparationTimeout < StandardError; end

class ScormboxApi
  RETRY_STATUSES = [409, 429, 500, 502, 503, 504].freeze

  def initialize(base_url, api_key, api_secret)
    @base_url = base_url.sub(%r{/+\z}, '')
    service = URI.parse(@base_url)
    unless service.host && (service.scheme == 'https' ||
           (service.scheme == 'http' && service.host.downcase == 'localhost'))
      raise 'Use HTTPS for server credentials, or HTTP localhost for development.'
    end
    if service.userinfo || service.query || service.fragment
      raise 'The API base URL must not include credentials, a query, or a fragment.'
    end
    @authorization = 'Basic ' + [api_key + ':' + api_secret].pack('m0')
  end

  def request(path, method: 'GET', body: nil, headers: {}, deadline: nil)
    binary = body.is_a?(String)
    payload = body.nil? ? nil : (binary ? body : JSON.generate(body))
    uri = URI.parse(@base_url + path)
    retry_number = 0

    loop do
      remaining = deadline ? deadline - monotonic_time : 120
      raise PreparationTimeout if remaining <= 0
      timeout = [120, remaining].min
      request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, uri.request_uri)
      request['Authorization'] = @authorization
      request['Content-Type'] = binary ? 'application/octet-stream' : 'application/json'
      headers.each { |name, value| request[name] = value }
      unless payload.nil?
        request.body = payload
        request['Content-Length'] = payload.bytesize.to_s
      end

      begin
        http = Net::HTTP.new(uri.host, uri.port, nil)
        http.use_ssl = uri.scheme == 'https'
        http.open_timeout = timeout
        http.read_timeout = timeout
        http.write_timeout = timeout
        http.max_retries = 0
        response = Timeout.timeout(timeout) { http.request(request) }
      rescue IOError, SystemCallError, SocketError, Timeout::Error,
             Net::HTTPBadResponse, OpenSSL::SSL::SSLError => error
        raise PreparationTimeout if deadline && monotonic_time >= deadline
        raise error if retry_number >= 4
        wait_to_retry(retry_number, deadline)
        retry_number += 1
        next
      end

      status = response.code.to_i
      raise "HTTP #{status}: Redirects are not allowed." if (300..399).cover?(status)
      if RETRY_STATUSES.include?(status) && retry_number < 4
        wait_to_retry(retry_number, deadline)
        retry_number += 1
        next
      end
      result = JSON.parse(response.body)
      raise 'The API returned a JSON value instead of an object.' unless result.is_a?(Hash)
      return result if (200..299).cover?(status)

      raise "HTTP #{status}: #{result['error'] || 'Request failed.'}"
    end
  end

  private

  def wait_to_retry(retry_number, deadline)
    delay = 2**retry_number
    if deadline
      remaining = deadline - monotonic_time
      raise PreparationTimeout if remaining <= 0
      delay = [delay, remaining].min
    end
    sleep(delay)
  end
end

begin
  filename = ARGV[0]
  raise "Usage: ruby #{File.basename($PROGRAM_NAME)} path/to/course.zip" unless filename
  api_key = ENV['SCORMBOX_API_KEY']
  api_secret = ENV['SCORMBOX_API_SECRET']
  idempotency_key = ENV['SCORMBOX_IDEMPOTENCY_KEY']
  if [api_key, api_secret, idempotency_key].any? { |value| value.nil? || value.empty? }
    raise 'Set SCORMBOX_API_KEY, SCORMBOX_API_SECRET and a stable SCORMBOX_IDEMPOTENCY_KEY for this content version.'
  end

  base_url = ENV['SCORMBOX_API_BASE_URL']
  base_url = 'https://scormbox.com/api/v1' if base_url.nil? || base_url.empty?
  api = ScormboxApi.new(base_url, api_key, api_secret)
  size = File.size(filename)
  options = { 'filename' => File.basename(filename), 'size' => size,
              'shareable' => ENV['SCORMBOX_SHAREABLE'] == 'true' }
  title = ENV['SCORMBOX_TITLE']
  options['title'] = title if title && !title.empty?
  course = api.request('/courses', method: 'POST', body: options,
                       headers: { 'Idempotency-Key' => idempotency_key })

  if %w[awaiting_upload uploading].include?(course['status'])
    upload = course.fetch('upload')
    upload_path = '/uploads/' + encode_path_segment(upload.fetch('upload_id'))
    part_size = upload.fetch('part_size')
    part_count = upload.fetch('part_count')
    unless part_size.is_a?(Integer) && part_size.positive? &&
           part_count.is_a?(Integer) && part_count == (size + part_size - 1) / part_size
      raise 'The API returned invalid upload part sizes.'
    end
    saved_parts = Set.new(upload.fetch('parts').map { |part| part.fetch('part_number') })
    File.open(filename, 'rb') do |file|
      bytes = String.new(encoding: Encoding::BINARY)
      (1..part_count).each do |part_number|
        next if saved_parts.include?(part_number)

        offset = (part_number - 1) * part_size
        length = [part_size, size - offset].min
        file.seek(offset)
        unless file.read(length, bytes) && bytes.bytesize == length
          raise 'The package changed while being read.'
        end
        api.request("#{upload_path}/parts/#{part_number}", method: 'PUT', body: bytes)
        puts "Uploaded part #{part_number} of #{part_count}"
      end
    end
    api.request(upload_path + '/complete', method: 'POST', body: {})
  end

  course_id = course.fetch('course_id')
  deadline = monotonic_time + 20 * 60
  loop do
    course = api.request('/courses/' + encode_path_segment(course_id), deadline: deadline)
    break if course['status'] == 'ready'
    raise(course['error'] || 'Package preparation failed.') if course['status'] == 'failed'

    preparation = course.fetch('preparation')
    puts "Course #{course['status']}: #{preparation['completed_files']} of #{preparation['total_files']} files"
    remaining = deadline - monotonic_time
    raise PreparationTimeout if remaining <= 0
    sleep([2, remaining].min)
  end
  puts JSON.pretty_generate({ 'course_id' => course.fetch('course_id'),
                              'format' => course.fetch('format'),
                              'management_url' => course.fetch('management_url'),
                              'learner_url' => course.fetch('learner_url') })
rescue PreparationTimeout
  warn "Preparation is still running. Check course_id #{course_id} before trying again."
  exit 1
rescue StandardError => error
  warn error.message
  exit 1
end
