require 'engineyard-serverside/dependency_manager/base'

module EY
  module Serverside
    class DependencyManager
      class Bundler < Base
        DEFAULT_VERSION = "1.14.6"

        # Number of Bundler workers (--jobs)
        JOBS_NUMBER = 4

        def self.default_version
          DEFAULT_VERSION
        end

        def self.jobs_number
          JOBS_NUMBER
        end

        def detected?
          gemfile?
        end

        def gemfile?
          paths.gemfile.exist?
        end

        def check
          if lockfile
            shell.status "Gemfile and Gemfile.lock found"

            if config.check_database_adapter? && !lockfile.any_database_adapter?
              shell.warning <<-WARN
Gemfile.lock does not contain a recognized database adapter.
A database-adapter gem such as pg, mysql2, mysql, or do_mysql was expected.
This can prevent applications that use PostgreSQL or MySQL from booting.

To fix, add any needed adapter to your Gemfile, bundle, commit, and redeploy.
Applications not using PostgreSQL or MySQL can safely ignore this warning by
adding ignore_database_adapter_warning: true to the application's ey.yml file
under the defaults: top level key and committing the file to the repository.
              WARN
            end
          elsif ! config.ignore_gemfile_lock_warning
            shell.warning <<-WARN
Gemfile found but Gemfile.lock is missing!
You can get different versions of gems in production than what you tested with.
You can get different versions of gems on every deployment even if your Gemfile hasn't changed.
Deploying will take longer and some deploy options will be limited.

To fix this problem, commit your Gemfile.lock to your repository and redeploy.
            WARN
          end
        end

        def install
          shell.status "Bundling gems..."
          clean_bundle_on_system_version_change
          install_bundler_gem
          run "#{clean_environment} && cd #{paths.active_release} && #{bundle_install_command}"
          write_system_version
        end

        def uses_sqlite3?
          lockfile && lockfile.uses_sqlite3?
        end

        def show_ey_config_instructions
          if lockfile && !lockfile.has_ey_config? && !config.ignore_ey_config_warning
            shell.warning "Gemfile.lock does not contain ey_config. Add gem 'ey_config' to your Gemfile to access service configurations through EY::Config."
          end
        end

        def rails_version
          lockfile && lockfile.rails_version
        end

        protected

        def write_system_version
          store_ruby_version   = "#{config.ruby_version_command} > #{paths.ruby_version}"
          store_system_version = "#{config.system_version_command} > #{paths.system_version}"

          run "mkdir -p #{paths.bundled_gems} && chown #{config.user}:#{config.group} #{paths.bundled_gems}"
          run "#{store_ruby_version} && #{store_system_version}"
        end

        # Install bundler in the system ruby
        def install_bundler_gem
          egrep_escaped_version = bundler_version.gsub(/\./, '\.')
          # the grep "bundler " is so that gems like bundler08 don't get
          # their versions considered too
          #
          # the [,)] is to stop us from looking for e.g. 0.9.2, seeing
          # 0.9.22, and mistakenly thinking 0.9.2 is there
          has_gem_cmd = %{gem list bundler | grep "bundler " | egrep -q "#{egrep_escaped_version}[,)]"}
          install_cmd = %{gem install bundler -q -v "#{bundler_version}"}
          sudo "#{clean_environment} && #{has_gem_cmd} || #{install_cmd}"
        end

        # GIT_SSH needs to be defined in the environment for customers with private bundler repos in their Gemfile.
        # It seems redundant to declare the env var again, but I'm hesitant to remove it right now.
        def clean_environment
          %{export GIT_SSH="#{ENV['GIT_SSH']}" && export LANG="en_US.UTF-8" && unset RUBYOPT BUNDLE_PATH BUNDLE_FROZEN BUNDLE_WITHOUT BUNDLE_BIN BUNDLE_GEMFILE}
        end

        def bundle_install_options
          options = [
            "--gemfile",  "#{paths.gemfile}",
            "--path",     "#{paths.bundled_gems}",
            "--binstubs", "#{paths.binstubs}",
          ]

          # Install gems in parallel (Bundler.1.5+ only)
          # @see EYPP-58
          options += ["--jobs", self.class.jobs_number] if check_version(bundler_version, ">= 1.5")

          options += ["--deployment"] if lockfile
          options += config.extra_bundle_install_options
          options
        end

        def bundle_install_command
          "ruby -S bundle _#{bundler_version}_ install #{bundle_install_options.join(" ")}"
        end

        def clean_bundle_on_system_version_change
          # diff exits with 0 for same and 1/2 for different/file not found.
          clean_bundle = "rm -Rf #{paths.bundled_gems}"

          if config.clean?
            shell.substatus "Clean bundle forced (--clean)"
            run clean_bundle
          else
            check_system = "#{config.system_version_command} | diff - #{paths.system_version} >/dev/null 2>&1"
            shell.substatus "Checking for system version changes"
            run "#{check_system} || #{clean_bundle}"
          end
        end

        def bundler_version
          @bundler_version ||= lockfile && lockfile.bundler_version || self.class.default_version
        end

        def lockfile
          return @lockfile if defined? @lockfile

          lockfile_path = paths.gemfile_lock
          if lockfile_path.exist?
            @lockfile = Lockfile.new(lockfile_path.read, self.class.default_version)
          end
        end

        ##
        # Checks that given version is satisfies give requirement.
        #
        # @param [String] version
        # @param [String] requirement
        # @return [true, false]
        #
        def check_version(version, requirement)
          Gem::Requirement.new( requirement ).satisfied_by? Gem::Version.new( version )
        end

        class Lockfile
          attr_reader :bundler_version

          METADATA     = 'METADATA'.freeze
          BUNDLED      = 'BUNDLED WITH'.freeze
          DEPENDENCIES = 'DEPENDENCIES'.freeze

          def initialize(lockfile_contents, default = EY::Serverside::DependencyManager::Bundler.default_version)
            @contents = lockfile_contents
            @default = default
            @default_gem_version = Gem::Version.new(@default)
            parse
          end

          def has_ey_config?
            !!@contents.index(/^\s+ey_config\s\([^\)]+\)$/)
          end

          def rails_version
            section = dependencies_section
            if section.empty?
              return nil
            end
            result = scan_gem('rails', section)
            result ? result.last : nil
          end

          def any_database_adapter?
            any_ruby_adapter = %w[mysql2 mysql do_mysql pg do_postgres].any? do |type|
              @contents.index(/^\s+#{type}\s\([^\)]+\)$/)
            end

            any_jruby_adapter = %w[mysql postgresql postgres].any? do |type|
              @contents.index(/^\s+jdbc-#{type}\s\([^\)]+\)$/) || @contents.index(/^\s+activerecord-jdbc#{type}-adapter\s\([^\)]+\)$/)
            end

            any_ruby_adapter || any_jruby_adapter
          end

          def uses_sqlite3?
            !any_database_adapter? && @contents.index(/^\s+sqlite3\s\([^\)]+\)$/)
          end

          def parse
            @bundler_version = parse_from_metadata || parse_from_bundled_with || parse_from_dependencies
            raise("Malformed or pre bundler-1.0.0 Gemfile.lock: #{@contents[0,50]}...") unless @bundler_version
          end

          def slice_section(header)
            if start = @contents.index(/^#{header}/)
              finish = @contents.index(/(^\S|\Z)/, start + header.length)
              @contents.slice(start..finish)
            else
              ""
            end
          end

          def parse_from_metadata
            section = slice_section(METADATA)
            return if section.empty?

            result = section.scan(/^\s*version:\s*(.*)$/).first
            result ? result.first : @default
          end

          def parse_from_bundled_with
            section = slice_section(BUNDLED)
            return if section.empty?

            result = section.scan(/^#{BUNDLED}\s*(.*)$/).first
            result ? result.first : @default
          end

          def dependencies_section
            @dependencies_section ||= slice_section(DEPENDENCIES)
          end

          def parse_from_dependencies
            section = dependencies_section
            return if section.empty?

            result = scan_gem('bundler', section)
            bundler_version = result ? result.last : nil
            version_qualifier = result ? result.first : nil
            @bundler_version = fetch_version(version_qualifier, bundler_version)
          end

          def fetch_version(operator, version)
            return version || @default unless operator && version
            req = Gem::Requirement.new(["#{operator} #{version}"])
            req.satisfied_by?(@default_gem_version) ? @default : version
          end

          def scan_gem(gem, dep_section)
            dep_section.scan(/^\s*#{Regexp.escape(gem)}\s*\((>?=|~>)\s*([^,\)]+)/).first
          end
        end
      end
    end
  end
end
