require 'simp/cli/commands/command'
require 'highline/import'

class Simp::Cli::Commands::Passgen < Simp::Cli::Commands::Command
  require 'fileutils'

  DEFAULT_ENVIRONMENT = 'production'

  def initialize
    @operation = nil
    @environment = nil
    @password_dir = nil
    @names = Array.new
    @backup_passwords = nil
    @force_remove = false
  end

  def help
    parse_command_line( [ '--help' ] )
  end

  def run(args)
    parse_command_line(args)
    return if @help_requested

    @environment = (@environment.nil? ? DEFAULT_ENVIRONMENT : @environment)
    @password_dir = get_password_dir if @password_dir.nil?

    case @operation
    when :show_environment_list
      show_environment_list
    when :show_name_list
      show_name_list
    when :show_passwords
      show_passwords
    when :set_passwords
      set_passwords
    when :remove_passwords
      remove_passwords
    end
  end

  def parse_command_line(args)
    raise OptionParser::ParseError.new('The SIMP Passgen Tool requires at least one option') if args.empty?

    opt_parser = OptionParser.new do |opts|
      opts.banner = "\n=== The SIMP Passgen Tool ===."
      opts.separator ''
      opts.separator 'The SIMP Passgen Tool is a simple password control utility. It allows the'
      opts.separator 'viewing, setting, and removal of passwords generated by passgen.'
      opts.separator ''
      opts.separator '  simp passgen [-e ENV|-d DIR] -l'
      opts.separator '  simp passgen [-e ENV|-d DIR] -n NAME1,...'
      opts.separator '  simp passgen [-e ENV|-d DIR] [--[no-]backup] -s NAME1,...'
      opts.separator '  simp passgen [-e ENV|-d DIR] [--force-remove] -r NAME1,...'
      opts.separator '  simp passgen -E'
      opts.separator ''
      opts.separator "OPTIONS:\n"

      opts.on('-e', '--env ENV',
            'Puppet environment to which the passgen operation will',
            "be applied. Defaults to '#{DEFAULT_ENVIRONMENT}'.",
            "Ignored when '-d' option is specified." ) do |env|
        @environment = env
      end

      opts.on('-E', '--list-env', 'List possible environments that may contain passwords.') do
        @operation = :show_environment_list
      end

      opts.on('-d', '--dir DIR',
            'Fully qualified path to a password store.',
            "Overrides an environment specified by the '-e' option.") do |dir|
        @password_dir = dir
      end

      opts.on('-l', '--list-name', 'List possible password names for the specified environment.') do
        @operation = :show_name_list
      end

      opts.on('-n', '--name NAME1[,NAME2,...]', Array,
            'Show password(s) for NAME1[,NAME2,...] in the',
            'specified environment.') do |names|
        @operation = :show_passwords
        @names = names
      end

      opts.on('-s', '--set NAME1[,NAME2,...]', Array,
            'Set password(s) for NAME1[,NAME2,...] in the',
            'specified environment.') do |names|
        @operation = :set_passwords
        @names = names
      end

      opts.on('-b', '--[no-]backup',
            'Back up passwords when generating new ones. If',
            'unspecified, user will be prompted for action',
            'to take.') do |backup|
        @backup_passwords = backup
      end

      opts.on('-r', '--remove NAME1[,NAME2,...]', Array,
            'Remove all passwords for NAME1[,NAME2,...] in the',
            'specified environment.') do |names|
        @operation = :remove_passwords
        @names = names
      end

      opts.on('-f', '--force-remove',
            'Remove passwords without prompting user to verify.',
            'If unspecified, user will be prompted to verify',
            'removal action.') do |force_remove|
        @force_remove = force_remove
      end

      opts.on('-h', '--help', 'Print this message.') do
        puts opts
        @help_requested = true
      end
    end

    opt_parser.parse!(args)

    unless @help_requested
      if @operation.nil?
        raise OptionParser::ParseError.new("No password operation specified.\n" + opt_parser.help)
      end
    end
  end

  def get_names
    names = []
    begin
      Dir.chdir(@password_dir) do
        names = Dir.glob('*').select do |x|
          File.file?(x) && (x !~ /\.salt$|\.last$/)  # exclude salt and backup files
        end
      end
    rescue SystemCallError => err
      err_msg = "Error occurred while accessing '#{@password_dir}': #{err}"
      raise Simp::Cli::ProcessingError.new(err_msg)
    end
    names.sort
  end

  def get_password(allow_autogenerate = true, attempts = 5)
    if (attempts == 0)
      raise Simp::Cli::ProcessingError.new('FATAL: Too may failed attempts to enter password')
    end

    password = ''
    if allow_autogenerate and yes_or_no('Do you want to autogenerate the password?', true )
      password = Simp::Cli::Utils.generate_password
      puts "  Password set to '#{password}'"
    else
      question1 = "> #{'Enter password'.bold}: "
      password = ask(question1) do |q|
        q.echo = '*'
        q.validate = lambda { |answer| validate_password(answer) }
        q.responses[:not_valid] = nil
        q.responses[:ask_on_error] = :question
        q
      end

      question2 = "> #{'Confirm password'.bold}: "
      confirm_password = ask(question2) do |q|
        q.echo = '*'
        q
      end

      if password != confirm_password
        $stderr.puts '  Passwords do not match! Please try again.'.red.bold

        # start all over, skipping the autogenerate question
        password = get_password(false, attempts - 1)
      end
    end
    password
  end

  def get_password_dir
    password_env_dir = File.join(`puppet config print vardir --section master 2>/dev/null`.strip, 'simp', 'environments')
    File.join(password_env_dir, @environment, 'simp_autofiles', 'gen_passwd')
  end

  def validate_names
    names = get_names
    @names.each do |name|
      unless names.include?(name)
        #FIXME print out names nicely (e.g., max 8 per line)
        raise OptionParser::ParseError.new("Invalid name '#{name}' selected.\n\nValid names: #{names.join(', ')}")
      end
    end
  end

  def validate_password(password)
    begin
      Simp::Cli::Utils::validate_password(password)
      return true
    rescue Simp::Cli::PasswordError => e
      $stderr.puts "  #{e.message}.".red.bold
      return false
    end
  end

  def validate_password_dir
    unless File.exist?(@password_dir)
      err_msg = "Password directory '#{@password_dir}' does not exist"
      raise Simp::Cli::ProcessingError.new(err_msg)
    end

    unless File.directory?(@password_dir)
      err_msg = "Password directory '#{@password_dir}' is not a directory"
      raise Simp::Cli::ProcessingError.new(err_msg)
    end
  end

  def show_environment_list
    # FIXME This ASSUMES @password_dir follows a known pattern of
    #   <env dir>/<env>/simp_autofiles/gen_passwd
    # (which also assumes Linux path separators)
    unless @password_dir.include?("/simp_autofiles/gen_passwd")
      err_msg = "Password environment directory could not be determined from '#{@password_dir}'"
      raise Simp::Cli::ProcessingError.new(err_msg)
    end

    env_dir = File.dirname(@password_dir.split("/simp_autofiles/")[0])

    unless File.exist?(env_dir)
      err_msg ="Password environment directory '#{env_dir}' does not exist"
      raise Simp::Cli::ProcessingError.new(err_msg)
    end

    unless File.directory?(env_dir)
      err_msg = "Password environment directory '#{env_dir}' is not a directory"
      raise Simp::Cli::ProcessingError.new(err_msg)
    end

    environments = []
    begin
      Dir.chdir(env_dir) do
        environments = Dir.glob('*').sort
      end
    rescue SystemCallError => err
      err_msg = "Error occurred while accessing '#{env_dir}': #{err}"
      raise Simp::Cli::ProcessingError.new(err_msg)
    end
    puts "Environments:\n\t#{environments.join("\n\t")}"
    puts
  end

  def show_name_list
    validate_password_dir
    names = get_names
    puts "#{@environment} Names:\n\t#{names.join("\n\t")}"
    puts
  end

  def show_passwords
    validate_password_dir
    validate_names

    title =  "#{@environment} Environment"
    puts title
    puts '='*title.length
    @names.each do |name|
      Dir.chdir(@password_dir) do
        puts "Name: #{name}"
        current_password = File.open("#{@password_dir}/#{name}", 'r').gets
        puts "  Current:  #{current_password}"
        last_password = nil
        last_password_file = "#{@password_dir}/#{name}.last"
        if File.exists?(last_password_file)
          last_password = File.open(last_password_file, 'r').gets
        end
        puts "  Previous: #{last_password}" if last_password
      end
      puts
    end
  end

  def backup_password_files(password_filename)
    backup_passwords = @backup_passwords
    if backup_passwords.nil?
      backup_passwords = yes_or_no("Would you like to rotate the old password?", false)
    end
    if backup_passwords
      begin
        FileUtils.mv(password_filename, password_filename + '.last', :verbose => true, :force => true)
        salt_filename = password_filename + '.salt'
        if File.exists?(salt_filename)
          FileUtils.mv(salt_filename, salt_filename + '.last', :verbose => true, :force => true)
        end
      rescue SystemCallError => err
        err_msg = "Error occurred while backing up '#{password_filename}' files: #{err}"
        raise Simp::Cli::ProcessingError.new(err_msg)
      end
    end
  end

  def set_passwords
    validate_password_dir
    @names.each do |name|
      next if name.strip.empty?
      password_filename = "#{@password_dir}/#{name}"

      puts "#{@environment} Name: #{name}"
      password = get_password
      backup_password_files(password_filename) if File.exists?(password_filename)

      begin
        # Remove any residual salt file, as per best security practices,
        # it should not be used with the new password.  simplib::passgen()
        # (from SIMP's simplib puppet module) will create a new salt file
        # when it is needed.
        FileUtils.rm_f(password_filename + '.salt')

        File.open(password_filename, 'w') { |file| file.puts password }

        # Ensure that the ownership and permissions are correct
        puppet_user = `puppet config print user 2>/dev/null`.strip
        puppet_group = `puppet config print group 2>/dev/null`.strip
        if puppet_user.empty? or puppet_group.empty?
          err_msg = 'Could not set password file ownership:  unable to determine puppet user and group'
          raise Simp::Cli::ProcessingError.new(err_msg)
        end
        FileUtils.chown(puppet_user, puppet_group, password_filename)
        FileUtils.chmod(0640, password_filename)

      rescue ArgumentError => err
        # This will happen if group does not exist
        err_msg = "Could not set password file ownership: #{err}"
        raise Simp::Cli::ProcessingError.new(err_msg)
      rescue SystemCallError => err
        err_msg = "Error occurred while writing '#{password_filename}': #{err}"
        raise Simp::Cli::ProcessingError.new(err_msg)
      end
      puts
    end
  end

  def remove_passwords
    validate_password_dir
    validate_names

    @names.each do |name|
      password_filename = "#{@password_dir}/#{name}"
      if File.exists?(password_filename)
        remove = @force_remove
        unless remove
          remove = yes_or_no("Are you sure you want to remove all entries for #{name}?", false)
        end
        if remove
          File.delete(password_filename)
          puts "#{password_filename} deleted"

          salt_password_filename = password_filename + '.salt'
          if File.exists?(salt_password_filename)
            File.delete(salt_password_filename)
            puts "#{salt_password_filename} deleted"
          end

          last_password_filename = password_filename + '.last'
          if File.exists?(last_password_filename)
            File.delete(last_password_filename)
            puts "#{last_password_filename} deleted"
          end
        end
      end
      puts
    end
  end

  def yes_or_no(prompt, default_yes)
    question = "> #{prompt.bold}: "
    answer = ask(question) do |q|
      q.validate = /^y$|^n$|^yes$|^no$/i
      q.default = (default_yes ? 'yes' : 'no')
      q.responses[:not_valid] = "Invalid response. Please enter 'yes' or 'no'".red
      q.responses[:ask_on_error] = :question
      q
    end
    result = (answer.downcase[0] == 'y')
  end
end
