require 'bundler'
require 'bundler/vendor/thor/lib/thor'
require 'bundler/advise'
require 'slop'
require 'open3'

module Bundler::Patch
  class CLI
    def self.execute
      original_command = ARGV.join(' ')

      opts = Slop.parse! do
        banner "Bundler Patch Version #{Bundler::Patch::VERSION}\nUsage: bundle patch [options] [gems-to-update]\n\nbundler-patch attempts to update gems conservatively.\n"
        on '-m', '--minor', 'Prefer update to the latest minor.patch version.'
        on '-n', '--minimal', 'Prefer minimal version updates over most recent patch (or minor if -m used).'
        on '-s', '--strict', 'Restrict any gem to be upgraded past most recent patch (or minor if -m used).'
        on '-l', '--list', 'List vulnerable gems and new version target. No updates will be performed.'
        on '-v', '--vulnerable-gems-only', 'Only update vulnerable gems.'
        on '-a=', '--advisory-db-path=', 'Optional custom advisory db path. `gems` dir will be appended to this path.'
        on '-d=', '--ruby-advisory-db-path=', 'Optional path for ruby advisory db. `gems` dir will be appended to this path.'
        on '-r', '--ruby', 'Update Ruby version in related files.'
        on '--rubies=', 'Supported Ruby versions. Comma delimited or multiple switches.', as: Array, delimiter: ','
        on '-g=', '--gemfile=', 'Optional Gemfile to execute against. Defaults to Gemfile in current directory.'
        on '--use_target_ruby', 'Optionally attempt to use Ruby version of target bundle specified in --gemfile.'
        on '-h', 'Show this help'
        on '--help', 'Show README.md'

        # will be stripped in help display and normalized to hyphenated options
        on '--vulnerable_gems_only'
        on '--advisory_db_path='
        on '--ruby_advisory_db_path='
        on '-p', '--prefer_minimal'
        on '--minor_preferred'
        on '--strict_updates'
      end

      options = opts.to_hash
      options[:gems_to_update] = ARGV
      options[:original_command] = original_command
      STDERR.puts options.inspect if ENV['DEBUG']

      show_help(opts) if options[:h]
      show_readme if ARGV.include?('help') || options[:help]

      CLI.new.patch(options)
    end

    def self.show_help(slop)
      slop.options.delete_if { |o| o.long =~ /_/ }
      puts slop
      exit
    end

    def self.show_readme
      Kernel.exec "less '#{File.expand_path('../../../../README.md', __FILE__)}'"
      exit
    end

    def initialize
      @no_vulns_message = 'No known vulnerabilities to update.'
    end

    def patch(options={})
      Bundler.ui = Bundler::UI::Shell.new

      options = Bundler::Patch::CLI::Options.new.normalize_options(options)

      tb = options[:target]
      if options[:use_target_ruby] && tb.target_ruby_is_different?
        launch_target_bundler_patch(options)
      else
        return list(options) if options[:list]

        patch_ruby(options) if options[:ruby]

        patch_gems(options)
      end
    end

    def launch_target_bundler_patch(options)
      tb = options[:target]
      ruby = tb.ruby_bin_exe
      tb.install_bundler_patch_in_target
      bundler_patch = File.join(tb.bin_dir, 'bundler-patch')
      full_command = %Q{GEM_HOME="#{tb.gem_home}" "#{ruby}" "#{bundler_patch}" #{options[:original_command].gsub(/use_target_ruby/, '')}}
      result = shell_command(full_command)
      puts result[:stdout] unless ENV['BP_DEBUG']
    end

    private

    def list(options)
      gem_patches = AdvisoryConsolidator.new(options).vulnerable_gems

      if gem_patches.empty?
        Bundler.ui.info @no_vulns_message
      else
        Bundler.ui.info '' # extra line to separate from advisory db update text
        Bundler.ui.info 'Detected vulnerabilities:'
        Bundler.ui.info '-------------------------'
        Bundler.ui.info gem_patches.map(&:to_s).uniq.sort.join("\n")
      end
    end

    def patch_ruby(options)
      supported = options[:rubies]
      RubyVersion.new(target_bundle: options[:target], patched_versions: supported).update
    end

    def patch_gems(options)
      vulnerable_patches = AdvisoryConsolidator.new(options).patch_gemfile_and_get_gem_specs_to_patch
      requested_patches = (options.delete(:gems_to_update) || []).map { |gem_name| GemPatch.new(gem_name: gem_name) }

      all_gem_patches = GemsToPatchReconciler.new(vulnerable_patches, requested_patches).reconciled_patches
      all_gem_patches.push(*vulnerable_patches) if options[:vulnerable_gems_only] && all_gem_patches.empty?

      vulnerable_patches, warnings = vulnerable_patches.partition { |gp| !gp.new_version.nil? }

      unless warnings.empty?
        warnings.each do |gp|
          Bundler.ui.warn "* Could not attempt upgrade for #{gp.gem_name} from #{gp.old_version} to any patched versions " \
            + "#{gp.patched_versions.join(', ')}. Most often this is because a major version increment would be " \
            + "required and it's safer for a major version increase to be done manually."
        end
      end

      if vulnerable_patches.empty?
        Bundler.ui.info @no_vulns_message
      else
        vulnerable_patches.each do |gp|
          Bundler.ui.info "Attempting conservative update for vulnerable gem '#{gp.gem_name}': #{gp.old_version} => #{gp.new_version}"
        end
      end

      if all_gem_patches.empty?
        if options[:vulnerable_gems_only]
          return # nothing to do
        else
          Bundler.ui.info 'Updating all gems conservatively.'
        end
      else
        Bundler.ui.info "Updating '#{all_gem_patches.map(&:gem_name).join(' ')}' conservatively."
      end
      conservative_update(all_gem_patches, options)
    end

    def conservative_update(gem_patches, options={}, bundler_def=nil)
      prep = DefinitionPrep.new(bundler_def, gem_patches, options).tap { |p| p.prep }

      # update => true is very important, otherwise without any Gemfile changes, the installer
      # may end up concluding everything can be resolved locally, nothing is changing,
      # and then nothing is done. lib/bundler/cli/update.rb also hard-codes this.
      Bundler::Installer.install(options[:target].dir, prep.bundler_def, {'update' => true})
      Bundler.load.cache if Bundler.app_cache.exist?
    end
  end
end

def shell_command(command)
  puts "-command:  #{command}" if ENV['BP_DEBUG']
  stdout, stderr, status = Open3.capture3(command)
  if ENV['BP_DEBUG']
    puts "--stdout:#{indent(stdout)}"
    puts "--stderr:#{indent(stderr)}"
  end
  {stdout: stdout,
   stderr: stderr,
   status: status}
end

def indent(s)
  s.split("\n").map { |ln| "  #{ln}" }.join("\n")
end

if __FILE__ == $0
  Bundler::Patch::CLI.execute
end
