#!/usr/bin/env ruby
# frozen_string_literal: true

#
# Generate an custom permissin file in the correct location.
#
# Automatically stages the file and amends the previous commit if the `--amend`
# argument is used.

require_relative '../../config/bundler_setup'
require 'optparse'
require 'yaml'
require 'fileutils'
require 'uri'
require 'readline'
require 'gitlab/utils/all'

require_relative '../lib/gitlab/custom_roles/shared' unless defined?(::Gitlab::CustomRoles::Shared)
require_relative '../../lib/gitlab/popen'

LEVELS = {
  'Not applicable' => 0,
  'Guest' => 10,
  'Reporter' => 20,
  'Developer' => 30,
  'Maintainer' => 40,
  'Owner' => 50,
  'Admin' => 60
}.freeze

module CustomAbilityHelpers
  Abort = Class.new(StandardError)
  Done = Class.new(StandardError)

  def capture_stdout(cmd)
    output = IO.popen(cmd, &:read)
    fail_with "command failed: #{cmd.join(' ')}" unless $?.success?
    output
  end

  def fail_with(message)
    raise Abort, "\e[31merror\e[0m #{message}"
  end
end

class CustomAbilityOptionParser
  extend CustomAbilityHelpers

  Options = Struct.new(
    :name,
    :description,
    :feature_category,
    :milestone,
    :amend,
    :dry_run,
    :force,
    :introduced_by_issue,
    :introduced_by_mr,
    :group_ability,
    :project_ability,
    :requirements,
    :available_from_access_level
  )

  class << self
    def parse(argv)
      options = Options.new

      parser = OptionParser.new do |opts|
        opts.banner = "Usage: #{__FILE__} [options] <custom-ability-name>\n\n"

        # Note: We do not provide a shorthand for this in order to match the `git
        # commit` interface
        opts.on('--amend', 'Amend the previous commit') do |value|
          options.amend = value
        end

        opts.on('-f', '--force', 'Overwrite an existing entry') do |value|
          options.force = value
        end

        opts.on('-d', '--description [string]', String,
          'A human-readable description of the custom ability') do |value|
          options.description = value
        end

        opts.on('-c', '--feature-category [string]', String,
          "The feature category of this ability. For example, vulnerability_management") do |value|
          options.feature_category = value
        end

        opts.on('-M', '--milestone [string]', String,
          'Milestone that introduced this custom ability. For example, 15.8') do |value|
          options.milestone = value
        end

        opts.on('-n', '--dry-run', "Don't actually write anything, just print") do |value|
          options.dry_run = value
        end

        opts.on('-m', '--introduced-by-mr [string]', String,
          'URL to GitLab merge request that added this ability') do |value|
          options.introduced_by_mr = value
        end

        opts.on('-i', '--introduced-by-issue [string]', String,
          'URL to GitLab issue that added this ability') do |value|
          options.introduced_by_issue = value
        end

        opts.on('-g', '--[no-]group_ability',
          'Is this ability checked on group level?') do |value|
          options.group_ability = value
        end

        opts.on('-p', '--[no-]project_ability',
          'Is this ability checked on project level? (eg. issue, vulnerability)') do |value|
          options.project_ability = value
        end

        opts.on('-r', '--requirements [string]', Array,
          'The custom abilities that need to be enabled for this ability.') do |value|
          options.requirements = value
        end

        opts.on('-a', '--available_from [number]', Integer,
          "The access level of the predefined role from which this ability is available, " \
          "one of: #{LEVELS.values}, if applicable.") do |value|
          options.available_from_access_level = value
        end

        opts.on('-h', '--help', 'Print help message') do
          $stdout.puts opts
          raise Done
        end
      end

      parser.parse!(argv)

      unless argv.one?
        $stdout.puts parser.help
        $stdout.puts
        raise Abort, 'Name for the ability is required'
      end

      options.name = argv.first.downcase.tr('-', '_')

      options
    end

    def read_description
      $stdout.puts
      $stdout.puts ">> Specify a human-readable description of the ability:"

      loop do
        description = Readline.readline('?> ', false)&.strip
        description = nil if description.empty?
        return description unless description.nil?

        warn "description is a required field."
      end
    end

    def read_feature_category
      $stdout.puts
      $stdout.puts ">> Specify the feature category of this ability like `vulnerability_management`:"

      loop do
        feature_category = Readline.readline('?> ', false)&.strip
        feature_category = nil if feature_category.empty?
        return feature_category unless feature_category.nil?

        warn "feature_category is a required field."
      end
    end

    def read_group_ability
      $stdout.puts
      $stdout.puts ">> Specify whether this ability is checked on group level (group related policies) [yes, no]:"

      loop do
        group_ability = Readline.readline('?> ', false)&.strip
        group_ability = Gitlab::Utils.to_boolean(group_ability)
        return group_ability unless group_ability.nil?

        warn "group_ability is a required boolean field."
      end
    end

    def read_project_ability
      $stdout.puts
      $stdout.puts ">> Specify whether this ability is checked on project level (project related policies) [yes, no]:"

      loop do
        project_ability = Readline.readline('?> ', false)&.strip
        project_ability = Gitlab::Utils.to_boolean(project_ability)
        return project_ability unless project_ability.nil?

        warn "project_ability is a required boolean field."
      end
    end

    # TODO: ideally we'll check if the required ability is already defined
    def read_requirements
      $stdout.puts
      $stdout.puts ">> Specify requirements for enabling this ability [read_code, read_vulnerability] - enter to skip:"

      Readline.readline('?> ', false)&.split(',')&.map(&:strip)
    end

    def read_introduced_by_mr
      $stdout.puts
      $stdout.puts ">> URL to GitLab merge request that added this custom ability - enter to skip:"

      loop do
        introduced_by_mr = Readline.readline('?> ', false)&.strip
        introduced_by_mr = nil if introduced_by_mr.empty?
        return introduced_by_mr if introduced_by_mr.nil? || introduced_by_mr.start_with?('https://')

        warn "URL needs to start with https://"
      end
    end

    def read_introduced_by_issue
      $stdout.puts ">> URL to GitLab issue that added this custom ability:"

      loop do
        created_url = Readline.readline('?> ', false)&.strip
        created_url = nil if created_url.empty?
        return created_url if !created_url.nil? && created_url.start_with?('https://')

        warn "URL needs to start with https://"
      end
    end

    def read_milestone
      milestone = File.read('VERSION')
      milestone.gsub(/^(\d+\.\d+).*$/, '\1').chomp
    end

    def read_available_from_access_level
      levels = LEVELS.map { |role, value| "#{value} (#{role})" }
      prompt = 'Specify the access level from which this ability is available'

      unless fzf_available?
        $stdout.puts
        $stdout.puts ">> #{prompt} :"
        $stdout.puts ">> #{levels.join(', ')}"
      end

      loop do
        value = if fzf_available?
                  prompt_fzf(list: levels, prompt: prompt)
                else
                  Readline.readline('?> ', false)&.strip.to_i
                end

        return if value == 0
        return value if value.in?(LEVELS.values)

        warn "The access level needs to be one of: #{LEVELS.values}"
      end
    end

    def fzf_available?
      Gitlab::Popen.popen(%w[which fzf])[1] == 0
    end

    def prompt_fzf(list:, prompt:)
      arr = list.join("\n")
      selection = IO.popen("echo \"#{arr}\" | fzf --tac --prompt=\"#{prompt}\"", &:readlines).join.strip
      selection.match(/(\d+)/)[1].to_i
    end
  end
end

class CustomAbilityCreator
  include CustomAbilityHelpers

  attr_reader :options

  def initialize(options)
    @options = options
  end

  def execute
    assert_feature_branch!
    assert_name!
    assert_custom_ability_does_not_exist!

    options.description ||= CustomAbilityOptionParser.read_description
    options.feature_category ||= CustomAbilityOptionParser.read_feature_category
    options.group_ability ||= CustomAbilityOptionParser.read_group_ability
    options.project_ability ||= CustomAbilityOptionParser.read_project_ability
    options.introduced_by_mr ||= CustomAbilityOptionParser.read_introduced_by_mr
    options.introduced_by_issue ||= CustomAbilityOptionParser.read_introduced_by_issue
    options.requirements ||= CustomAbilityOptionParser.read_requirements
    options.available_from_access_level ||= CustomAbilityOptionParser.read_available_from_access_level
    options.milestone ||= CustomAbilityOptionParser.read_milestone

    $stdout.puts "\e[32mcreate\e[0m #{file_path}"
    $stdout.puts contents

    unless options.dry_run
      write
      amend_commit if options.amend
    end

    system("#{editor} '#{file_path}' &") if editor
  end

  private

  def contents
    # Slice is used to ensure that YAML keys
    # are always ordered in a predictable way
    config_hash.slice(
      *::Gitlab::CustomRoles::Shared::PARAMS.map(&:to_s)
    ).to_yaml
  end

  def config_hash
    {
      'name' => options.name,
      'description' => options.description,
      'feature_category' => options.feature_category,
      'milestone' => options.milestone,
      'group_ability' => options.group_ability,
      'project_ability' => options.project_ability,
      'introduced_by_mr' => options.introduced_by_mr,
      'introduced_by_issue' => options.introduced_by_issue,
      'requirements' => options.requirements,
      'available_from_access_level' => options.available_from_access_level
    }
  end

  def write
    FileUtils.mkdir_p(File.dirname(file_path))
    File.write(file_path, contents)
  end

  def editor
    ENV['EDITOR']
  end

  def amend_commit
    fail_with "git add failed" unless system(*%W[git add #{file_path}])

    Kernel.exec(*%w[git commit --amend])
  end

  def assert_feature_branch!
    return unless branch_name == 'master'

    fail_with "Create a branch first!"
  end

  def assert_custom_ability_does_not_exist!
    existing_path = all_custom_abilities[options.name]

    return unless existing_path
    return if options.force

    fail_with "#{existing_path} already exists! Use `--force` to overwrite."
  end

  def assert_name!
    return if /\A[a-z0-9_-]+\Z/.match?(options.name)

    fail_with "Provide a name for the custom ability that is [a-z0-9_-]"
  end

  def file_path
    custom_ability_path.sub('*.yml', "#{options.name}.yml")
  end

  def all_custom_abilities
    return if @all_custom_abilities

    @all_custom_abilities = {}

    Dir.glob(custom_ability_path).map do |path|
      @all_custom_abilities[File.basename(path, '.yml')] = path
    end

    @all_custom_abilities
  end

  def custom_ability_path
    File.join('ee', 'config', 'custom_abilities', '*.yml')
  end

  def group_ability?
    options.group_ability
  end

  def project_ability?
    options.project_ability
  end

  def branch_name
    @branch_name ||= capture_stdout(%w[git symbolic-ref --short HEAD]).strip
  end
end

if $PROGRAM_NAME == __FILE__
  begin
    options = CustomAbilityOptionParser.parse(ARGV)
    CustomAbilityCreator.new(options).execute
  rescue CustomAbilityHelpers::Abort => ex
    warn ex.message
    exit 1
  rescue CustomAbilityHelpers::Done
    exit
  end
end
