require 'optparse'
require 'shellwords'
require 'yaml'
require 'english'
require 'private_attr'

module HerokuRailsDeploy
  class Deployer
    extend PrivateAttr

    PRODUCTION_BRANCH_REGEX = /\A((master)|(release\/.+)|(hotfix\/.+))\z/
    PRODUCTION = 'production'.freeze

    attr_reader :config_file, :args
    private_attr_reader :options, :app_registry

    class Options < Struct.new(:environment, :revision, :register_avro_schemas, :skip_avro_schemas)
      def self.create_default(app_registry)
        new(app_registry.keys.first, 'HEAD')
      end
    end

    def initialize(config_file, args)
      raise "Missing config file #{config_file}" unless File.file?(config_file)
      @app_registry = YAML.load(File.read(config_file))
      @config_file = config_file
      @args = args
      @options = parse_options
    end

    def run
      return unless options

      app_name = app_registry.fetch(options.environment) do
        raise OptionParser::InvalidArgument.new("Invalid environment '#{options.environment}'. " \
          "Must be in #{app_registry.keys.join(', ')}")
      end

      raise 'Only master, release or hotfix branches can be deployed to production' if production? && !production_branch?(options.revision)

      no_uncommitted_changes!

      puts "Deploying to Heroku app #{app_name} for environment #{options.environment}"

      if !options.skip_avro_schemas && (production? || options.register_avro_schemas)
        puts 'Checking for pending Avro schemas'
        pending_schemas = list_pending_schemas(app_name)
        if pending_schemas.any?
          puts 'Registering Avro schemas'
          register_avro_schemas!(registry_url(app_name), pending_schemas)
        else
          puts 'No pending Avro schemas'
        end
      end

      puts 'Pushing code'
      push_code(app_name, options.revision)

      puts 'Checking for pending migrations'
      if pending_migrations?(app_name)
        puts 'Running migrations'
        run_migrations(app_name)
        puts 'Restarting dynos'
        restart_dynos(app_name)
      else
        puts 'No migrations required'
      end
    end

    def production?
      options.try(:environment) == PRODUCTION
    end

    private

    def parse_options
      options = Options.create_default(app_registry)
      OptionParser.new do |parser|
        parser.on_tail('-h', '--help', 'Show this message') do
          puts parser
          # rubocop:disable Lint/NonLocalExitFromIterator
          return
          # rubocop:enable Lint/NonLocalExitFromIterator
        end

        parser.on('-e', '--environment ENVIRONMENT',
                  "The environment to deploy to. Must be in #{app_registry.keys.join(', ')} (default #{app_registry.keys.first})") do |environment|
          options.environment = environment
        end

        parser.on('-r', '--revision REVISION',
                  'The git revision to push. (default HEAD)') do |revision|
          options.revision = revision
        end

        parser.on('--register-avro-schemas',
                  'Force the registration of Avro schemas when deploying to a non-production environment.') do |register_avro_schemas|
          options.register_avro_schemas = register_avro_schemas
        end

        parser.on('--skip-avro-schemas',
                  'Skip the registration of Avro schemas when deploying to production.') do |skip_avro_schemas|
          options.skip_avro_schemas = skip_avro_schemas
        end
      end.parse!(args)

      options
    end

    def production_branch?(revision)
      git_branch_name(revision).match(PRODUCTION_BRANCH_REGEX)
    end

    def no_uncommitted_changes!
      uncommitted_changes = run_command!('git status --porcelain', quiet: true)
      raise "There are uncommitted changes:\n#{uncommitted_changes}" unless uncommitted_changes.blank?
    end

    def push_code(app_name, revision)
      run_command!("git push --force #{app_remote(app_name)} #{revision}:master")
    end

    def git_branch_name(revision)
      run_command!("git rev-parse --abbrev-ref #{Shellwords.escape(revision)}", quiet: true).strip
    rescue
      raise "Unable to get branch for #{revision}"
    end

    def run_migrations(app_name)
      run_heroku_command!(app_name, 'run rake db:migrate')
    end

    def restart_dynos(app_name)
      run_heroku_command!(app_name, 'ps:restart')
    end

    def pending_migrations?(app_name)
      run_heroku_command(app_name, 'run rake db:abort_if_pending_migrations')
    end

    def run_heroku_command!(app_name, command)
      run_heroku_command(app_name, command, validate: true)
    rescue
      raise "Heroku command '#{command}' failed"
    end

    def run_heroku_command(app_name, command, validate: nil)
      cli_command = "heroku #{command} --app #{app_name}"
      if command.start_with?('run ')
        # If we're running a shell command, return the underlying
        # shell command exit code
        cli_command << ' --exit-code'
        display_output = true
      else
        display_output = false
      end
      run_command(cli_command, validate: validate, display_output: display_output)
    end

    def registry_url(app_name)
      result = run_command!("heroku config -a #{app_name} | grep AVRO_SCHEMA_REGISTRY_URL:", quiet: true)
      result.split.last
    end

    def register_avro_schemas!(registry_url, schemas)
      cmd = "rake avro:register_schemas schemas=#{schemas.join(',')}"
      run_command!("DEPLOYMENT_SCHEMA_REGISTRY_URL=#{registry_url} #{cmd}", print_command: cmd)
    end

    def list_pending_schemas(app_name)
      changed_files(app_name).select { |filename| /\.avsc$/ =~ filename }
    end

    def changed_files(app_name)
      run_command("git diff --name-only #{remote_commit(app_name)}..#{current_commit}", quiet: true).split("\n").map(&:strip)
    end

    def current_commit
      run_command!("git log --pretty=format:'%H' -n 1", quiet: true)
    end

    def remote_commit(app_name)
      run_command!("git ls-remote --heads #{app_remote(app_name)} master", quiet: true).split.first
    end

    def app_remote(app_name)
      "git@heroku.com:#{app_name}.git"
    end

    def run_command!(command, print_command: nil, quiet: false, display_output: false)
      run_command(command, print_command: print_command, quiet: quiet, display_output: display_output, validate: true)
    end

    def run_command(command, print_command: nil, validate: nil, quiet: false, display_output: false)
      printed_command = print_command || command
      puts printed_command unless quiet
      if display_output
        Bundler.with_clean_env { system(command) }
      else
        output = Bundler.with_clean_env { `#{command}` }
      end
      exit_status = $CHILD_STATUS.exitstatus
      raise "Command '#{printed_command}' failed" if validate && !exit_status.success?
      display_output ? exit_status.success? : output
    end
  end
end