#!/usr/bin/env ruby

require 'fileutils'
require 'json'
require 'mkmf'
require 'open-uri'
require 'optparse'
require 'tmpdir'

REPOSITORIES_URL = 'https://sdk.squareup.com/ios'.freeze
SPECS_URL = "#{REPOSITORIES_URL}/specs.zip".freeze

def cmd(command)
  output = `#{command}`
  raise "failed to run: #{command}\n#{output}" unless $?.success?
end

module SquareReaderFrameworkName
  def framework_name(version)
    return 'SquareReaderSDK.framework' unless xcframework?(version)

    'SquareReaderSDK.xcframework'
  end

  def xcframework?(version)
    version >= '1.4.7'
  end

  def m1_support?(version)
    version >= '1.6.1'
  end

  def separate_plists?(version)
    version >= '1.4.7'
  end
end

# Installs Square Reader SDK
class Installer
  include SquareReaderFrameworkName

  def initialize(application_id, repository_password)
    @downloader = Downloader.new(application_id, repository_password)
    @key_artifact_name = "SquareReaderSDK-#{application_id}"
  end

  # Download and bootstrap SquareReaderSDK.framework
  def install(version, installation_dir)
    installation_dir = validate_installation_dir(installation_dir)

    tmp do
      puts 'Checking available versions ...'
      version = validate_version(version)
      puts "Installing version #{version} ..."

      framework = Framework.new(@downloader, version)
      framework.download(@key_artifact_name)
      move_framework(installation_dir, version)
    end

    puts "Installed version #{version}: #{installation_dir}/#{framework_name(version)}"
  end

  # Get a list of versions that can be installed
  def list_versions
    tmp do
      versions
    end
  end

  def validate_version(version)
    if version.nil?
      latest_release_version
    elsif versions.include?(version)
      version
    else
      abort "Invalid version: #{version}. Use list-versions to see which versions are available."
    end
  end

  def validate_installation_dir(installation_dir)
    if installation_dir.nil?
      absolute_installation_dir = FileUtils.pwd
    else
      absolute_installation_dir = File.absolute_path(installation_dir)
    end

    # Make sure a SquareReaderSDK.framework does not already exist in the installation directory.
    framework_destination_path = File.join(absolute_installation_dir, 'SquareReaderSDK.framework')
    if File.directory?(framework_destination_path)
      abort "#{framework_destination_path} already exists. Delete the existing framework and try again."
    end

    # Make sure a SquareReaderSDK.xcframework does not already exist in the installation directory
    xcframework_destination_path = File.join(absolute_installation_dir, 'SquareReaderSDK.xcframework')
    if File.directory?(xcframework_destination_path)
      abort "#{framework_destination_path} already exists. Delete the existing framework and try again."
    end

    absolute_installation_dir
  end

  def move_framework(installation_dir, version)
    FileUtils.mkdir_p(installation_dir) unless File.directory?(installation_dir)
    installation_path = File.join(installation_dir, framework_name(version))
    FileUtils.mv(framework_name(version), installation_path)
  end

  private

  def versions
    @versions ||= begin
      download_specs
      Dir.glob("#{@key_artifact_name}/*").map { |dir| File.basename(dir) }.sort_by { |v| Gem::Version.new(v) }.reverse
    end
  end

  def latest_release_version
    versions.find { |v| Gem::Version.new(v).prerelease? == false }
  end

  def download_specs
    @downloader.download_and_unzip(SPECS_URL, 'specs.zip')
  end

  def tmp
    Dir.mktmpdir('sq-readersdk') do |tmp_dir|
      Dir.chdir(tmp_dir) { yield tmp_dir }
    end
  end
end

# Downloads and bootstraps SquareReaderSDK.framework
class Framework
  include SquareReaderFrameworkName

  def initialize(downloader, version)
    @downloader = downloader
    @version = version
  end

  def download(key_artifact_name)
    @key_artifact_name = key_artifact_name
    download_artifacts
    inject_credentials
  end

  def download_artifacts
    @downloader.download_and_unzip(key_artifact_url, 'key_artifact.zip')
    @downloader.download_and_unzip(binary_artifact_url, 'binary_artifact.zip', true)
  end

  # Inject the key artifact credentials into the binary artifact
  def inject_credentials
    application_id = File.read(File.join(@key_artifact_name, 'application-id.txt'))
    sdk_key = File.read(File.join(@key_artifact_name, 'wbid.txt'))
    sdk_id = File.read(File.join(@key_artifact_name, 'sdk-id.txt'))

    Dir.glob("./**/SquareReaderSDK.framework/Info.plist").each do |plist|
      cmd("chmod u+w #{plist}")
      cmd("plutil -replace SDKApplicationID -string '#{application_id}' #{plist}")
      unless separate_plists?(@version)
        cmd("plutil -replace SDKKey -string '#{sdk_key}' #{plist}")
        cmd("plutil -replace SDKID -string '#{sdk_id}' #{plist}")
      end
    end

    if separate_plists?(@version)
      Dir.glob("./**/SquareReaderSDK.framework/Info.plist").each do |info_plist|
        wbkeys_plist = File.join(File.dirname(info_plist), 'WBKeys.plist')
        if File.exist?(wbkeys_plist)
          cmd("chmod u+w #{wbkeys_plist}")
        else
          cmd("plutil -create xml1 #{wbkeys_plist}")
        end
        cmd("plutil -replace SDKKey -string '#{sdk_key}' #{wbkeys_plist}")
        cmd("plutil -replace SDKID -string '#{sdk_id}' #{wbkeys_plist}")
      end
    end
  end

  def key_artifact_url
    path = key_artifact_spec['source'].values.first
    "#{REPOSITORIES_URL}/#{path}"
  end

  def binary_artifact_url
    path = binary_artifact_spec['source'].values.first
    "#{REPOSITORIES_URL}/#{path}"
  end

  def key_artifact_spec
    @key_artifact_spec ||= begin
      spec_path = File.join(@key_artifact_name, @version, "#{@key_artifact_name}.podspec.json")
      spec = File.read(spec_path)
      JSON.parse(spec)
    end
  end

  def binary_artifact_spec
    @binary_artifact_spec ||= begin
      binary_artifact_name = key_artifact_spec['dependencies'].keys.first
      spec_path = File.join(binary_artifact_name, @version, "#{binary_artifact_name}.podspec.json")
      spec = File.read(spec_path)
      JSON.parse(spec)
    end
  end
end

# Downloads files using basic auth
class Downloader
  def initialize(username, password)
    @username = username
    @password = password
  end

  def download_and_unzip(url, path, show_progress_bar = false)
    download(url, path, show_progress_bar)
    unzip(path)
    FileUtils.rm(path)
  end

  def download(url, path, show_progress_bar)
    total_download_size_in_bytes = nil

    begin
      URI.open(
        url,
        content_length_proc: lambda do |content_length|
          total_download_size_in_bytes = content_length if show_progress_bar
        end,
        progress_proc: lambda do |bytes_downloaded|
          show_download_progress(bytes_downloaded, total_download_size_in_bytes) if show_progress_bar
        end,
        http_basic_authentication: [@username, @password]
      ) do |downloaded_file|
        # Avoid adding subsequent logs on the same line as the progress bar.
        print "\n" if show_progress_bar

        File.open(path, 'w') do |local_file|
          IO.copy_stream(downloaded_file, local_file)
        end
      end
    rescue OpenURI::HTTPError => error
      handle_http_error(error)
    end
  end

  def show_download_progress(bytes_downloaded, total_download_size_in_bytes)
    download_progress =
      if total_download_size_in_bytes.nil?
        "#{bytes_downloaded} bytes downloaded (total size unknown)"
      else
        download_progress_bar(bytes_downloaded, total_download_size_in_bytes)
      end

    # \r to allow overwriting of the current stdout line.
    print "#{download_progress}\r"
  end

  def download_progress_bar(bytes_downloaded, total_download_size_in_bytes)
    completion_progress = bytes_downloaded.to_f / total_download_size_in_bytes.to_f
    completion_percentage = (100 * completion_progress).round(1)

    complete_indicator_count = completion_percentage.to_i
    remaining_indicator_count = 100 - complete_indicator_count
    progress_bar_text = "#{'#' * complete_indicator_count}#{'-' * remaining_indicator_count}"

    # E.g., [50% complete] [##################################################--------------------------------------------------]
    " [#{completion_percentage}% complete] [#{progress_bar_text}]"
  end

  def handle_http_error(error)
    error_code = error.io.status.first

    if error_code =~ /40(1|3)/
      abort 'Invalid credentials. Please make sure that you have provided the correct Reader SDK repository credentials from the Square Developer Console.'
    elsif error_code =~ /404/
      abort 'Resource not found. Try again later.'
    else
      abort error.message
    end
  end

  def unzip(file)
    cmd("unzip #{file}")
  end
end

#####################
# Commands
#####################

class InstallCommand
  def usage_description
    'Usage: install --app-id APP_ID --repo-password REPO_PWD [options]'
  end

  def parse_options!
    options = { app_id: '', repo_password: '' }
    option_parser = OptionParser.new do |parser|
      parser.banner = usage_description

      parser.separator 'Required:'
      add_app_id_option(parser, options)
      add_repo_password_option(parser, options)

      parser.separator 'Optional:'
      add_version_option(parser, options)
      add_installation_dir_option(parser, options)
    end

    begin
      option_parser.parse!
    rescue
      # ignore OptionParser exceptions
    end

    if options[:app_id].empty? || options[:repo_password].empty?
      abort option_parser.help
    end
    options
  end

  def run
    options = parse_options!
    installer = Installer.new(options[:app_id], options[:repo_password])
    installer.install(options[:version], options[:installation_dir])
  end
end

class ListVersionsCommand
  def usage_description
    'Usage: list-versions --app-id=APPLICATION_ID repo-password=REPOSITORY_PASSWORD'
  end

  def run
    options = parse_options!
    installer = Installer.new(options[:app_id], options[:repo_password])
    print_versions(installer.list_versions)
  end

  def parse_options!
    options = { app_id: '', repo_password: '' }
    option_parser = OptionParser.new do |parser|
      parser.banner = usage_description

      parser.separator 'Required:'
      add_app_id_option(parser, options)
      add_repo_password_option(parser, options)
    end

    begin
      option_parser.parse!
    rescue
      # ignore OptionParser exceptions
    end

    if options[:app_id].empty? || options[:repo_password].empty?
      abort option_parser.help
    end
    options
  end

  def print_versions(versions)
    puts 'Square Reader SDK Versions:'
    versions.each do |version|
      puts "* #{version}"
    end
  end
end

class HelpCommand
  def usage_description
    <<-DESCRIPTION
      Commands:
        install --app-id APP_ID --repo-password REPO_PWD [options]          # Install Reader SDK
        list-versions --app-id APP_ID --repo-password REPO_PWD [options]    # List available Reader SDK versions
        help                                                                # Display this message
    DESCRIPTION
  end

  def run
    puts usage_description
  end
end

#####################
# Command options
#####################

def add_app_id_option(parser, options)
  parser.on('--app-id APP_ID', 'Your Square Application ID') do |app_id|
    options[:app_id] = app_id
  end
end

def add_repo_password_option(parser, options)
  parser.on('--repo-password REPO_PWD', 'Your Reader SDK Repository Password') do |repo_password|
    options[:repo_password] = repo_password
  end
end

def add_version_option(parser, options)
  parser.on('-v', '--version [VERSION]', 'The Square Reader SDK version to install') do |version|
    options[:version] = version
  end
end

def add_installation_dir_option(parser, options)
  parser.on('-d', '--installation-dir [DIR]', 'The directory where Square Reader SDK should be installed') do |dir|
    options[:installation_dir] = File.absolute_path(dir)
  end
end

#####################
# Helpers
#####################

def validate_required_executables(*executables)
  # Don't generate mkmf.log file
  MakeMakefile::Logging.instance_variable_set(:@log, File.open(File::NULL, 'w'))

  # Silence console output
  MakeMakefile::Logging.instance_variable_set(:@quiet, true)

  executables.each do |executable|
    if MakeMakefile.find_executable(executable).nil?
      abort "Missing required executable: #{executable}"
    end
  end
end

#####################
# Script
#####################

def run
  validate_required_executables('unzip', 'plutil')

  command = ARGV.shift
  case command
  when 'install'
    InstallCommand.new.run
  when 'list-versions'
    ListVersionsCommand.new.run
  else
    HelpCommand.new.run
  end
end

if __FILE__ == $0
  run
end
