#!/usr/bin/env ruby
# -*- encoding: utf-8 -*-

Signal.trap("INT") { exit 1 }

$stdout.sync = true
$stderr.sync = true

require "benchmark"
require "digest"
require "json"
require "optparse"
require "ostruct"
require "tempfile"

class Options

  NAME = File.basename($0).freeze

  def self.parse(args)
    options = OpenStruct.new
    options.templates = calculate_templates("*.json")

    ENV['PACKER_CACHE_DIR'] = "packer_cache"

    global = OptionParser.new do |opts|
      opts.banner = "Usage: #{NAME} [SUBCOMMAND [options]]"
      opts.separator ""
      opts.separator <<-COMMANDS.gsub(/^ {8}/, "")
        build     :   build one or more templates
        help      :   prints this help message
        list      :   list all templates in project
        normalize :   normalize one or more templates
      COMMANDS
    end

    templates_argv_proc = proc { |options|
      options.templates = calculate_templates(args) unless args.empty?

      options.templates.each do |t|
        if !File.exists?("#{t}.json")
          $stderr.puts "File #{t}.json does not exist for template '#{t}'"
          exit(1)
        end
      end
    }

    subcommand = {
      help: {
        parser: OptionParser.new {},
        argv: proc { |options|
          puts global
          exit(0)
        }
      },
      build: {
        class: BuildRunner,
        parser: OptionParser.new { |opts|
          opts.banner = "Usage: #{NAME} build [options] TEMPLATE[ TEMPLATE ...]"

          opts.on("-n", "--[no-]dry-run", "Dry run (what would happen)") do |opt|
            options.dry_run = opt
          end

          opts.on("-d", "--[no-]debug", "Run packer with debug output") do |opt|
            options.debug = opt
          end

          opts.on("-a", "--ask", "Run packer with on-error=ask") do |opt|
            options.ask = opt
          end

          opts.on("-o BUILDS", "--only BUILDS", "Only build some Packer builds") do |opt|
            options.builds = opt
          end

          opts.on("-e BUILDS", "--except BUILDS", "Build all Packer builds except these") do |opt|
            options.except = opt
          end

          opts.on("-m MIRROR", "--mirror MIRROR", "Look for isos at MIRROR") do |opt|
            options.mirror = opt
          end

          opts.on("-v VERSION", "--version VERSION", "Override the version set in the template") do |opt|
            options.override_version = opt
          end
        },
        argv: templates_argv_proc
      },
      normalize: {
        class: NormalizeRunner,
        parser: OptionParser.new { |opts|
          opts.banner = "Usage: #{NAME} normalize TEMPLATE[ TEMPLATE ...]"

          opts.on("-d", "--[no-]debug", "Run packer with debug output") do |opt|
            options.debug = opt
          end

        },
        argv: templates_argv_proc
      },
      list: {
        class: ListRunner,
        parser: OptionParser.new { |opts|
          opts.banner = "Usage: #{NAME} list [TEMPLATE ...]"
        },
        argv: templates_argv_proc
      }
    }

    global.order!
    command = args.empty? ? :help : ARGV.shift.to_sym
    subcommand.fetch(command).fetch(:parser).order!
    subcommand.fetch(command).fetch(:argv).call(options)

    options.command = command
    options.klass = subcommand.fetch(command).fetch(:class)

    options
  end

  def self.calculate_templates(globs)
    Array(globs).
      map { |glob| result = Dir.glob(glob); result.empty? ? glob : result }.
      flatten.
      sort.
      delete_if { |file| file =~ /\.variables\./ }.
      map { |template| template.sub(/\.json$/, '') }
  end
end

module Common

  def banner(msg)
    puts "==> #{msg}"
  end

  def info(msg)
    puts "    #{msg}"
  end

  def warn(msg)
    puts ">>> #{msg}"
  end

  def duration(total)
    total = 0 if total.nil?
    minutes = (total / 60).to_i
    seconds = (total - (minutes * 60))
    format("%dm%.2fs", minutes, seconds)
  end
end

module PackerExec

  def for_packer_run_with(template)
    Tempfile.open("#{template}-metadata.json") do |md_file|
      Tempfile.open("#{template}-metadata-var-file") do |var_file|
        write_box_metadata(template, md_file)
        write_var_file(template, md_file, var_file)
        yield md_file, var_file
      end
    end
  end

  def write_box_metadata(template, io)
    md = BuildMetadata.new(template, build_timestamp, override_version).read

    io.write(JSON.pretty_generate(md))
    io.close
  end

  def write_var_file(template, md_file, io)
    md = BuildMetadata.new(template, build_timestamp, override_version).read

    io.write(JSON.pretty_generate({
      box_basename:     md[:box_basename],
      build_timestamp:  md[:build_timestamp],
      git_revision:     md[:git_revision],
      metadata:         md_file.path,
      version:          md[:version]
    }))
    io.close
  end
end

class BuildRunner

  include Common
  include PackerExec

  attr_reader :templates, :dry_run, :debug, :ask, :builds, :except, :mirror, :override_version, :build_timestamp

  def initialize(opts)
    @templates = opts.templates
    @dry_run = opts.dry_run
    @debug = opts.debug
    @ask = opts.ask
    @builds = opts.builds
    @except = opts.except
    @mirror = opts.mirror
    @override_version = opts.override_version
    @build_timestamp = Time.now.gmtime.strftime("%Y%m%d%H%M%S")
  end

  def start
    banner("Starting build for templates: #{templates}")
    time = Benchmark.measure do
      templates.each { |template| build(template) }
    end
    banner("Build finished in #{duration(time.real)}.")
  end

  private

  def build(template)
    for_packer_run_with(template) do |md_file, var_file|
      cmd = packer_build_cmd(template, var_file.path)
      banner("[#{template}] Building: '#{cmd.join(' ')}'")
      time = Benchmark.measure do
        system(*cmd) or raise "[#{template}] Error building, exited #{$?}"
        write_final_metadata(template)
      end
      banner("[#{template}] Finished building in #{duration(time.real)}.")
    end
  end

  def packer_build_cmd(template, var_file)
    vars = "#{template}.variables.json"
    headless = !(RUBY_PLATFORM =~ /darwin/)
    cmd = %W[packer build -var-file=#{var_file} #{template}.json]
    cmd.insert(2, "-var-file=#{vars}") if File.exist?(vars)
    cmd.insert(2, "-only=#{builds}") if builds
    cmd.insert(2, "-except=#{except}") if except
    # Build the command line in the correct order and without spaces as future input for the splat operator.
    cmd.insert(2, "mirror=#{mirror}") if mirror
    cmd.insert(2, "-var") if mirror
    cmd.insert(2, "headless=true") if headless
    cmd.insert(2, "-var") if headless
    cmd.insert(2, "-debug") if debug
    cmd.insert(2, "-on-error=ask") if ask
    cmd.insert(0, "echo") if dry_run
    cmd
  end

  def write_final_metadata(template)
    md = BuildMetadata.new(template, build_timestamp, override_version).read
    path = File.join(File.dirname(__FILE__), "..", "builds")
    filename = File.join(path, "#{md[:box_basename]}.metadata.json")

    md[:providers] = ProviderMetadata.new(path, md[:box_basename]).read

    if dry_run
      banner("(Dry run) Metadata file contents would be something similar to:")
      puts JSON.pretty_generate(md)
    else
      File.open(filename, "wb") { |file| file.write(JSON.pretty_generate(md)) }
    end
  end
end

class NormalizeRunner

  include Common
  include PackerExec

  attr_reader :templates, :build_timestamp, :debug, :override_version

  def initialize(opts)
    @templates = opts.templates
    @debug = opts.debug
    @modified = []
    @build_timestamp = Time.now.gmtime.strftime("%Y%m%d%H%M%S")
  end

  def start
    banner("Normalizing for templates: #{templates}")
    time = Benchmark.measure do
      templates.each do |template|
        validate(template)
        fix(template)
      end
    end
    if !@modified.empty?
      info("")
      info("The following templates were modified:")
      @modified.sort.each { |template| info("  * #{template}")}
    end
    banner("Normalizing finished in #{duration(time.real)}.")
  end

  private

  def checksum(file)
    Digest::MD5.file(file).hexdigest
  end

  def fix(template)
    file = "#{template}.json"

    banner("[#{template}] Fixing")
    original_checksum = checksum(file)
    output = %x{packer fix #{file}}
    raise "[#{template}] Error fixing, exited #{$?}" if $?.exitstatus != 0
    # preserve ampersands in shell commands,
    # see: https://github.com/mitchellh/packer/issues/784
    output.gsub!("\\u0026", "&")
    File.open(file, "wb") { |dest| dest.write(output) }
    fixed_checksum = checksum(file)

    if original_checksum == fixed_checksum
      puts("No changes made.")
    else
      warn("Template #{template} has been modified.")
      @modified << template
    end
  end

  def packer_validate_cmd(template, var_file)
    vars = "#{template}.variables.json"
    cmd = %W[packer validate -var-file=#{var_file} #{template}.json]
    cmd.insert(2, "-var-file=#{vars}") if File.exist?(vars)
    cmd
  end

  def validate(template)
    for_packer_run_with(template) do |md_file, var_file|
      cmd = packer_validate_cmd(template, var_file.path)
      banner("[#{template}] Validating: '#{cmd.join(' ')}'")
      if debug
        banner("[#{template}] DEBUG: var_file(#{var_file.path}) is:")
        puts IO.read(var_file.path)
        banner("[#{template}] DEBUG: md_file(#{md_file.path}) is:")
        puts IO.read(md_file.path)
      end
      system(*cmd) or raise "[#{template}] Error validating, exited #{$?}"
    end
  end
end

class ListRunner

  include Common

  attr_reader :templates

  def initialize(opts)
    @templates = opts.templates
  end

  def start
    templates.each { |template| puts template }
  end
end

class Runner

  attr_reader :options

  def initialize(options)
    @options = options
  end

  def start
    options.klass.new(options).start
  end
end

class BuildMetadata

  def initialize(template, build_timestamp, override_version)
    @template = template
    @build_timestamp = build_timestamp
    @override_version = override_version
  end

  def read
    {
      name:             name,
      version:          version,
      build_timestamp:  build_timestamp,
      git_revision:     git_revision,
      box_basename:     box_basename,
      template:         template_vars.fetch("template", UNKNOWN),
    }
  end

  private

  UNKNOWN = "__unknown__".freeze

  attr_reader :template, :build_timestamp, :override_version

  def box_basename
    "#{name.gsub("/", "__")}-#{version}.git.#{git_revision}"
  end

  def git_revision
    sha = %x{git rev-parse HEAD}.strip

    git_clean? ? sha : "#{sha}_dirty"
  end

  def git_clean?
    %x{git status --porcelain}.strip.empty?
  end

  def merged_vars
    @merged_vars ||= begin
      if File.exist?("#{template}.variables.json")
        template_vars.merge(JSON.load(IO.read("#{template}.variables.json")))
      else
        template_vars
      end
    end
  end

  def name
    merged_vars.fetch("name", template)
  end

  def template_vars
    @template_vars ||= JSON.load(IO.read("#{template}.json")).fetch("variables")
  end

  def version
    if override_version
       override_version
    else
    merged_vars.fetch("version", "#{UNKNOWN}.TIMESTAMP").
      rpartition(".").first.concat(".#{build_timestamp}")
    end
  end
end

class ProviderMetadata

  def initialize(path, box_basename)
    @base = File.join(path, box_basename)
  end

  def read
    Dir.glob("#{base}.*.box").map do |file|
      {
        name: provider_from_file(file),
        file: "#{File.basename(file)}",
        checksum_type: "sha256",
        checksum: shasum(file)
      }
    end
  end

  private

  attr_reader :base

  def provider_from_file(file)
    case provider = file.sub(/^.*\.([^.]+)\.box$/, '\1')
    when /vmware/i then "vmware_desktop"
    else provider
    end
  end

  def shasum(file)
    Digest::SHA256.file(file).hexdigest
  end
end

begin
  Runner.new(Options.parse(ARGV)).start
rescue => ex
  $stderr.puts ">>> #{ex.message}"
  exit(($? && $?.exitstatus) || 99)
end
