#!/usr/bin/env ruby

require 'pathname'
require 'pp'
require 'yaml'
require 'json'
require 'fileutils'

require 'cmds'

require 'qb'

# constants
# =========

DEBUG_ARGS = ['-D', '--DEBUG']

# globals
# =======

# @api util
# *pure*
#
# format a debug message with optional key / values to print
#
# @param msg [String] message to print.
# @param dump [Hash] optional hash of keys and values to dump.
def format msg, dump = {}
  unless dump.empty?
    msg += "\n" + dump.map {|k, v| "  #{ k }: #{ v.inspect }" }.join("\n")
  end
  msg
end

def debug *args
  QB.debug *args
end

def set_debug! args
  if DEBUG_ARGS.any? {|arg| args.include? arg}
    ENV['QB_DEBUG'] = 'true'
    debug "ON"
    DEBUG_ARGS.each {|arg| args.delete arg}
  end
end

# needed for to clean the env if using bundler (like in dev).
# 
# this is because the ansible gem module doesn't work right with the bundler
# env vars set, so we need to remove them, but when running in dev we want
# modules written in ruby like nrser.state_mate's `state` script to have
# access to them so it can fire up bundler and get the right libraries.
# 
# to accomplish this, we detect Bundler, and when it's present we copy the
# bundler-related env vars (which i found by looking at 
# https://github.com/bundler/bundler/blob/master/lib/bundler.rb#L257)
# into a hash to pass around the env sanitization, then copy them into
# corresponding 'QB_DEV_ENV_<NAME>' vars that modules can restore.
# 
# we also set a 'QB_DEV_ENV=true' env var for modules to easily detect that
# we're running in dev and restore the variables.
# 
def with_clean_env &block
  if defined? Bundler
    # copy the Bundler env vars into a hash
    dev_env = ENV.select {|k, v|
      k.start_with?("BUNDLE_") ||
      [
        'GEM_HOME',
        'GEM_PATH',
        'MANPATH',
        'RUBYOPT',
        'RUBYLIB',
      ].include?(k)
    }
    
    qb_env = ENV.select {|k, v| k.start_with? 'QB_'}
    
    Bundler.with_clean_env do
      # now that we're in a clean env, copy the Bundler env vars into 
      # 'QB_DEV_ENV_<NAME>' vars.
      dev_env.each {|k, v| ENV["QB_DEV_ENV_#{ k }"] = v}
      
      qb_env.each {|k, v| ENV[k] = v}
      
      # and set QB_DEV_ENV=true
      ENV['QB_DEV_ENV'] = 'true'
      
      # invoke the block
      block.call
    end
  else
    # bundler isn't loaded, so no env var silliness to deal with 
    block.call
  end
end

def metadata
  if QB.gemspec.metadata && !QB.gemspec.metadata.empty?
    "metadata:\n" + QB.gemspec.metadata.map {|key, value|
      "  #{ key }: #{ value }"
    }.join("\n") + "\n"
  end
end

def help
  puts <<-END
version: #{ QB::VERSION }
#{ metadata }
syntax:

    qb ROLE [OPTIONS] DIRECTORY

use `qb ROLE -h` for role options.
  
available roles:

END
  puts QB::Role.available
  puts
  exit 1
end

def main args
  set_debug! args
  debug args: args
  
  QB.check_ansible_version
  
  role_arg = args.shift
  debug "role arg" => role_arg
  
  help if role_arg.nil? || ['-h', '--help', 'help'].include?(role_arg)
  
  begin
    role = QB::Role.require role_arg
  rescue QB::Role::NoMatchesError => e
    puts "ERROR - #{ e.message }\n\n"
    # exits with status code 1
    help
  rescue QB::Role::MultipleMatchesError => e
    puts "ERROR - #{ e.message }\n\n"
    exit 1
  end
  
  options, qb_options = QB::Options.parse! role, args
  
  debug "options set on cli", options.select {|k, o| !o.value.nil?}
  debug "qb options", qb_options
  
  cwd = Dir.getwd
  
  dir = nil
  
  unless role.meta['default_dir'] == false
    # get the target dir
    dir = case args.length
    when 0
      # in this case, a dir has not been provided
      # 
      # in some cases (like projects) the dir can be figured out in other ways:
      # 
      QB.get_default_dir role, cwd, options
      
    when 1
      # there is a single positional arg, which is used as dir
      args[0]
      
    else
      # there are multiple positional args, which is not allowed
      raise "can't supply more than one argument: #{ args.inspect }"
      
    end
    
    debug "input_dir", dir
    
    # normalize to expanded path (has no trailing slash)
    dir = File.expand_path dir
    
    debug "normalized_dir", dir
    
    # create the dir if it doesn't exist (so don't have to cover this in
    # every role)
    if role.mkdir
      FileUtils.mkdir_p dir unless File.exists? dir
    end
  
    saved_options_path = Pathname.new(dir) + '.qb-options.yml'
    
    saved_options = if saved_options_path.exist?
      # convert old _ separated names to - separated
      YAML.load(saved_options_path.read).map {|role_options_key, role_options|
        [
          role_options_key,
          role_options.map {|name, value|
            [QB::Options.cli_ize_name(name), value]
          }.to_h
        ]
      }.to_h.tap {|saved_options|
        debug "found saved options", saved_options
      }
    else
      debug "no saved options"
      {}
    end
    
    if saved_options.key? role.options_key
      role_saved_options = saved_options[role.options_key]
      
      debug "found saved options for role", role_saved_options
      
      role_saved_options.each do |option_cli_name, value|
        option = options[option_cli_name]
        
        if option.value.nil?
          debug "setting from saved options", option: option, value: value
          
          option.value = value
        end
      end
    end
  end # unless default_dir == false
  
  # check that required options are present
  missing = options.values.select {|option|
    option.required? && option.value.nil?
  }
  
  unless missing.empty?
    puts "ERROR: options #{ missing.map {|o| o.cli_name } } are required."
    exit 1
  end
  
  set_options = options.select {|k, o| !o.value.nil?}
  
  debug "set options", set_options
  
  playbook_role = {'role' => role.name}
  
  playbook_vars = {
    'qb_dir' => dir,
    # depreciated due to mass potential for conflict
    'dir' => dir,
    'qb_cwd' => cwd,
    'qb_user_roles_dir' => QB::USER_ROLES_DIR.to_s,
  }
  
  set_options.values.each do |option|
    playbook_role[option.var_name] = option.value
  end
  
  play =
  {
    'hosts' => qb_options['hosts'],
    'vars' => playbook_vars,
    # 'gather_subset' => ['!all'],
    'gather_facts' => qb_options['facts'],
    'pre_tasks' => [
      # need ansible 2.1.2.0 at least to run
      # but this is obviously not flexible enough
      # {
      #   'assert' => {
      #     'that' => "'ansible 2.1.2' in lookup('pipe', 'ansible --version')",
      #   },
      # },
      {
        'qb_facts' => {
          'qb_dir' => dir,
        }
      },
    ],
    'roles' => [
      'nrser.blockinfile',
      playbook_role
    ],
  }
  
  if qb_options['user']
    play['become'] = true
    play['become_user'] = qb_options['user']
  end
  
  playbook = [play]
  
  debug "playbook", playbook
  
  playbook_path = Pathname.new(Dir.getwd) + '.qb-playbook.yml'
  debug playbook_path: playbook_path.to_s
  
  tmp_roles_path = QB::ROOT + 'tmp' + 'roles'
  
  ansible_roles_path = (
    [
      role.path.expand_path.dirname,
      tmp_roles_path
    ] + QB::Role.search_path
  ).join(':')
  
  ansible_library_path = [
    QB::ROOT + 'library',
  ].join(':')
  
  ansible_filter_plugins_path = QB::ROOT.join 'plugins', 'filter_plugins'
  
  template = []
  template << "ANSIBLE_ROLES_PATH=<%= roles_path %>"
  template << "ANSIBLE_LIBRARY=<%= library_path %>"
  template << "ANSIBLE_FILTER_PLUGINS=<%= filter_plugins_path %>"
  template << "ansible-playbook"
  
  if play['hosts'] != ['localhost']
    template << "-i <%= hosts %>"
  end
  
  if qb_options['tags']
    template << "--tags=<%= tags %>"
  end
  
  if qb_options['verbose']
    template << "-#{ 'v' * qb_options['verbose'] }"
  end
  
  template << "<%= playbook_path %>"
  
  cmd = Cmds.sub template.join(" "), [], {
    roles_path: ansible_roles_path,
    library_path: ansible_library_path,
    playbook_path: playbook_path.to_s,
    hosts: "#{ play['hosts'].join(',') },",
    tags: (qb_options['tags'] ? qb_options['tags'].join(',') : nil),
    filter_plugins_path: ansible_filter_plugins_path.to_s,
  }
  
  # print
  # =====
  # 
  # print useful stuff for debugging / running outside of qb
  # 
  
  if qb_options['print'].include? 'options'
    puts "SET OPTIONS:\n\n#{ YAML.dump set_options }\n\n"
  end
  
  if qb_options['print'].include? 'cmd'
    puts "COMMAND:\n\n#{ cmd }\n\n"
  end
  
  if qb_options['print'].include? 'playbook'
    puts "PLAYBOOK:\n\n#{ YAML.dump playbook }\n\n"
  end
  
  # stop here if we're not supposed to run
  exit 0 if !qb_options['run']
  
  # run
  # ===
  # 
  # stuff below here does stuff
  # 
  
  playbook_path.open('w') do |f|
    f.write YAML.dump(playbook)
  end
  
  # save the options back
  if (
    dir &&
    # we set some options that we can save
    set_options.values.select {|o| o.save? }.length > 0 &&
    # the role says to save options
    role.save_options
  )
    saved_options[role.options_key] = set_options.select{|key, option|
      option.save?
    }.map {|key, option|
      [key, option.value]
    }.to_h
    
    unless saved_options_path.dirname.exist?
      FileUtils.mkdir_p saved_options_path.dirname
    end
    
    saved_options_path.open('w') do |f|
      f.write YAML.dump(saved_options)
    end
  end
  
  Dir.chdir QB::ROOT do    
    with_clean_env do
      # boot up stdio services so that ansible modules can stream to our
      # stdout and stderr to print stuff (including debug lines) in real-time
      stdio_services = {'out' => $stdout, 'err' => $stderr}.map do |name, dest|
        QB::Util::STDIO::Service.new(name, dest).tap {|s| s.open! }
      end
      
      status = Cmds.stream cmd
      
      # close the stdio services
      stdio_services.each {|s| s.close! }
      
      if status != 0
        puts "ERROR ansible-playbook failed."
      end
      
      exit status
    end
  end
end

main(ARGV) # if __FILE__ == $0 # doesn't work with gem stub or something?