module Nanoc::Extra::Deployers
  # A deployer that deploys a site using [fog](https://github.com/geemus/fog).
  #
  # @example A deployment configuration with public and staging configurations
  #
  #   deploy:
  #     public:
  #       kind:       fog
  #       bucket:     nanoc-site
  #       cdn_id:     XXXXXX
  #     preprod:
  #       kind:       fog
  #       provider:   local
  #       local_root: ~/myCloud
  #       bucket:     nanoc-site
  #     staging:
  #       kind:       fog
  #       provider:   local
  #       local_root: ~/myCloud
  #       bucket:     nanoc-site-staging
  #
  # @api private
  class Fog < ::Nanoc::Extra::Deployer
    # @see Nanoc::Extra::Deployer#run
    def run
      require 'fog'

      # Get params, unsetting anything we don't want to pass through to fog.
      src      = File.expand_path(source_path)
      bucket   = config.delete(:bucket) || config.delete(:bucket_name)
      path     = config.delete(:path)
      cdn_id   = config.delete(:cdn_id)

      config.delete(:kind)

      # Validate params
      error 'The path requires no trailing slash' if path && path[-1, 1] == '/'

      # Mock if necessary
      if dry_run?
        puts 'Dry run - simulation'
        ::Fog.mock!
      end

      # Get connection
      puts 'Connecting'
      connection = ::Fog::Storage.new(config)

      # Get bucket
      puts 'Getting bucket'
      begin
        directory = connection.directories.get(bucket, prefix: path)
      rescue ::Excon::Errors::NotFound
        should_create_bucket = true
      end
      should_create_bucket = true if directory.nil?

      # Create bucket if necessary
      if should_create_bucket
        puts 'Creating bucket'
        directory = connection.directories.create(key: bucket, prefix: path)
      end

      # Get list of remote files
      files = directory.files
      truncated = files.respond_to?(:is_truncated) && files.is_truncated
      while truncated
        set = directory.files.all(marker: files.last.key)
        truncated = set.is_truncated
        files += set
      end
      keys_to_destroy = files.map(&:key)
      keys_to_invalidate = []
      etags = read_etags(files)

      # Upload all the files in the output folder to the clouds
      puts 'Uploading local files'
      FileUtils.cd(src) do
        files = Dir['**/*'].select { |f| File.file?(f) }
        files.each do |file_path|
          key = path ? File.join(path, file_path) : file_path
          upload(key, file_path, etags, keys_to_destroy, keys_to_invalidate, directory)
        end
      end

      # delete extraneous remote files
      puts 'Removing remote files'
      keys_to_destroy.each do |key|
        directory.files.get(key).destroy
      end

      # invalidate CDN objects
      if cdn_id
        puts 'Invalidating CDN distribution'
        keys_to_invalidate.concat(keys_to_destroy)
        cdn = ::Fog::CDN.new(config)
        # fog cannot mock CDN requests
        unless dry_run?
          distribution = cdn.get_distribution(cdn_id)
          # usual limit per invalidation: 1000 objects
          keys_to_invalidate.each_slice(1000) do |paths|
            cdn.post_invalidation(distribution, paths)
          end
        end
      end

      puts 'Done!'
    end

    private

    def upload(key, file_path, etags, keys_to_destroy, keys_to_invalidate, dir)
      keys_to_destroy.delete(key)

      return unless needs_upload?(key, file_path, etags)

      dir.files.create(
        key: key,
        body: File.open(file_path),
        public: true)
      keys_to_invalidate.push(key)
    end

    def needs_upload?(key, file_path, etags)
      remote_etag = etags[key]
      return true if remote_etag.nil?

      local_etag = calc_local_etag(file_path)
      remote_etag != local_etag
    end

    def read_etags(files)
      case config[:provider]
      when 'aws'
        files.each_with_object({}) do |file, etags|
          etags[file.key] = file.etag
        end
      else
        {}
      end
    end

    def calc_local_etag(file_path)
      case config[:provider]
      when 'aws'
        Digest::MD5.file(file_path).hexdigest
      end
    end

    # Prints the given message on stderr and exits.
    def error(msg)
      raise RuntimeError.new(msg)
    end
  end
end
