require "uri"
require "rubygems/installer"
require "rubygems/spec_fetcher"
require "rubygems/format"
require "digest/sha1"
require "open3"

module Bundler
  module Source
    # TODO: Refactor this class
    class Rubygems
      attr_reader :remotes

      def initialize(options = {})
        @options = options
        @remotes = (options["remotes"] || []).map { |r| normalize_uri(r) }
        @allow_remote = false
        @allow_cached = false
        # Hardcode the paths for now
        @caches = [ Bundler.app_cache ] + Gem.path.map { |p| File.expand_path("#{p}/cache") }
        @spec_fetch_map = {}
      end

      def remote!
        @allow_remote = true
      end

      def cached!
        @allow_cached = true
      end

      def hash
        Rubygems.hash
      end

      def eql?(o)
        Rubygems === o
      end

      alias == eql?

      def options
        { "remotes" => @remotes.map { |r| r.to_s } }
      end

      def self.from_lock(options)
        s = new(options)
        Array(options["remote"]).each { |r| s.add_remote(r) }
        s
      end

      def to_lock
        out = "GEM\n"
        out << remotes.map {|r| "  remote: #{r}\n" }.join
        out << "  specs:\n"
      end

      def to_s
        remote_names = self.remotes.map { |r| r.to_s }.join(', ')
        "rubygems repository #{remote_names}"
      end
      alias_method :name, :to_s

      def specs
        @specs ||= fetch_specs
      end

      def fetch(spec)
        spec, uri = @spec_fetch_map[spec.full_name]
        if spec
          path = download_gem_from_uri(spec, uri)
          s = Gem::Format.from_file_by_path(path).spec
          spec.__swap__(s)
        end
      end

      def install(spec)
        path = cached_gem(spec)

        if installed_specs[spec].any?
          Bundler.ui.info "Using #{spec.name} (#{spec.version}) "
          return
        end

        Bundler.ui.info "Installing #{spec.name} (#{spec.version}) "

        install_path = Bundler.requires_sudo? ? Bundler.tmp : Gem.dir
        options = { :install_dir         => install_path,
                    :ignore_dependencies => true,
                    :wrappers            => true,
                    :env_shebang         => true }
        options.merge!(:bin_dir => "#{install_path}/bin") unless spec.executables.nil? || spec.executables.empty?

        installer = Gem::Installer.new path, options
        installer.install

        # SUDO HAX
        if Bundler.requires_sudo?
          sudo "mkdir -p #{Gem.dir}/gems #{Gem.dir}/specifications"
          sudo "cp -R #{Bundler.tmp}/gems/#{spec.full_name} #{Gem.dir}/gems/"
          sudo "cp -R #{Bundler.tmp}/specifications/#{spec.full_name}.gemspec #{Gem.dir}/specifications/"
          spec.executables.each do |exe|
            sudo "mkdir -p #{Gem.bindir}"
            sudo "cp -R #{Bundler.tmp}/bin/#{exe} #{Gem.bindir}"
          end
        end

        spec.loaded_from = "#{Gem.dir}/specifications/#{spec.full_name}.gemspec"
      end

      def sudo(str)
        Bundler.sudo(str)
      end

      def cache(spec)
        cached_path = cached_gem(spec)
        raise GemNotFound, "Missing gem file '#{spec.full_name}.gem'." unless cached_path
        return if File.dirname(cached_path) == Bundler.app_cache.to_s
        Bundler.ui.info "  * #{File.basename(cached_path)}"
        FileUtils.cp(cached_path, Bundler.app_cache)
      end

      def add_remote(source)
        @remotes << normalize_uri(source)
      end

      def merge_remotes(source)
        @remotes = []
        source.remotes.each do |r|
          add_remote r.to_s
        end
      end

    private

      def cached_gem(spec)
        possibilities = @caches.map { |p| "#{p}/#{spec.full_name}.gem" }
        possibilities.find { |p| File.exist?(p) }
      end

      def normalize_uri(uri)
        uri = uri.to_s
        uri = "#{uri}/" unless uri =~ %r'/$'
        uri = URI(uri)
        raise ArgumentError, "The source must be an absolute URI" unless uri.absolute?
        uri
      end

      def fetch_specs
        Index.build do |idx|
          idx.use installed_specs
          idx.use cached_specs if @allow_cached || @allow_remote
          idx.use remote_specs if @allow_remote
        end
      end

      def installed_specs
        @installed_specs ||= begin
          idx = Index.new
          have_bundler = false
          Gem.source_index.to_a.reverse.each do |dont_use_this_var, spec|
            next if spec.name == 'bundler' && spec.version.to_s != VERSION
            have_bundler = true if spec.name == 'bundler'
            spec.source = self
            idx << spec
          end

          # Always have bundler locally
          unless have_bundler
           # We're running bundler directly from the source
           # so, let's create a fake gemspec for it (it's a path)
           # gemspec
           bundler = Gem::Specification.new do |s|
             s.name     = 'bundler'
             s.version  = VERSION
             s.platform = Gem::Platform::RUBY
             s.source   = self
             s.loaded_from = File.expand_path("..", __FILE__)
           end
           idx << bundler
          end
          idx
        end
      end

      def cached_specs
        @cached_specs ||= begin
          idx = installed_specs.dup

          path = Bundler.app_cache
          Dir["#{path}/*.gem"].each do |gemfile|
            next if gemfile =~ /bundler\-[\d\.]+?\.gem/

            begin
              s ||= Gem::Format.from_file_by_path(gemfile).spec
            rescue Gem::Package::FormatError
              raise GemspecError, "Could not read gem at #{gemfile}. It may be corrupted."
            end

            s.source = self
            idx << s
          end
        end

        idx
      end

      def remote_specs
        @remote_specs ||= begin
          idx     = Index.new
          old     = Gem.sources

          remotes.each do |uri|
            Bundler.ui.info "Fetching source index for #{uri}"
            Gem.sources = ["#{uri}"]
            fetch_all_remote_specs do |n,v|
              v.each do |name, version, platform|
                next if name == 'bundler'
                spec = RemoteSpecification.new(name, version, platform, uri)
                spec.source = self
                @spec_fetch_map[spec.full_name] = [spec, uri]
                idx << spec
              end
            end
          end
          idx
        ensure
          Gem.sources = old
        end
      end

      def fetch_all_remote_specs(&blk)
        begin
          # Fetch all specs, minus prerelease specs
          Gem::SpecFetcher.new.list(true, false).each(&blk)
          # Then fetch the prerelease specs
          begin
            Gem::SpecFetcher.new.list(false, true).each(&blk)
          rescue Gem::RemoteFetcher::FetchError
            Bundler.ui.warn "Could not fetch prerelease specs from #{self}"
          end
        rescue Gem::RemoteFetcher::FetchError
          Bundler.ui.warn "Could not reach #{self}"
        end
      end

      def download_gem_from_uri(spec, uri)
        spec.fetch_platform

        download_path = Bundler.requires_sudo? ? Bundler.tmp : Gem.dir
        gem_path = "#{Gem.dir}/cache/#{spec.full_name}.gem"

        FileUtils.mkdir_p("#{download_path}/cache")
        Gem::RemoteFetcher.fetcher.download(spec, uri, download_path)

        if Bundler.requires_sudo?
          sudo "mkdir -p #{Gem.dir}/cache"
          sudo "mv #{Bundler.tmp}/cache/#{spec.full_name}.gem #{gem_path}"
        end

        gem_path
      end
    end

    class Path
      attr_reader :path, :options
      # Kind of a hack, but needed for the lock file parser
      attr_writer   :name
      attr_accessor :version

      DEFAULT_GLOB = "{,*/}*.gemspec"

      def initialize(options)
        @options = options
        @glob = options["glob"] || DEFAULT_GLOB

        @allow_cached = false
        @allow_remote = false

        if options["path"]
          @path = Pathname.new(options["path"])
          @path = @path.expand_path(Bundler.root) unless @path.relative?
        end

        @name = options["name"]
        @version = options["version"]
      end

      def remote!
        @allow_remote = true
      end

      def cached!
        @allow_cached = true
      end

      def self.from_lock(options)
        new(options.merge("path" => options.delete("remote")))
      end

      def to_lock
        out = "PATH\n"
        out << "  remote: #{relative_path}\n"
        out << "  glob: #{@glob}\n" unless @glob == DEFAULT_GLOB
        out << "  specs:\n"
      end

      def to_s
        "source at #{@path}"
      end

      def hash
        self.class.hash
      end

      def eql?(o)
        o.instance_of?(Path) &&
        path.expand_path(Bundler.root) == o.path.expand_path(Bundler.root) &&
        name == o.name &&
        version == o.version
      end

      alias == eql?

      def name
        File.basename(@path.to_s)
      end

      def load_spec_files
        index = Index.new

        expanded_path = path.expand_path(Bundler.root)

        if File.directory?(expanded_path)
          Dir["#{expanded_path}/#{@glob}"].each do |file|
            spec = Bundler.load_gemspec(file)
            if spec
              spec.loaded_from = file.to_s
              spec.source = self
              index << spec
            end
          end

          if index.empty? && @name && @version
            index << Gem::Specification.new do |s|
              s.name     = @name
              s.source   = self
              s.version  = Gem::Version.new(@version)
              s.platform = Gem::Platform::RUBY
              s.summary  = "Fake gemspec for #{@name}"
              s.relative_loaded_from = "#{@name}.gemspec"
              if expanded_path.join("bin").exist?
                binaries = expanded_path.join("bin").children.map{|c| c.basename.to_s }
                s.executables = binaries
              end
            end
          end
        else
          raise PathError, "The path `#{expanded_path}` does not exist."
        end

        index
      end

      def local_specs
        @local_specs ||= load_spec_files
      end

      class Installer < Gem::Installer
        def initialize(spec, options = {})
          @spec              = spec
          @bin_dir           = Bundler.requires_sudo? ? "#{Bundler.tmp}/bin" : "#{Gem.dir}/bin"
          @gem_dir           = spec.full_gem_path
          @wrappers          = options[:wrappers] || true
          @env_shebang       = options[:env_shebang] || true
          @format_executable = options[:format_executable] || false
        end

        def generate_bin
          return if spec.executables.nil? || spec.executables.empty?

          if Bundler.requires_sudo?
            FileUtils.mkdir_p("#{Bundler.tmp}/bin") unless File.exist?("#{Bundler.tmp}/bin")
          end
          super
          if Bundler.requires_sudo?
            Bundler.mkdir_p "#{Gem.dir}/bin"
            spec.executables.each do |exe|
              Bundler.sudo "cp -R #{Bundler.tmp}/bin/#{exe} #{Gem.dir}/bin/"
            end
          end
        end
      end

      def install(spec)
        Bundler.ui.info "Using #{spec.name} (#{spec.version}) from #{to_s} "
        # Let's be honest, when we're working from a path, we can't
        # really expect native extensions to work because the whole point
        # is to just be able to modify what's in that path and go. So, let's
        # not put ourselves through the pain of actually trying to generate
        # the full gem.
        Installer.new(spec).generate_bin
      end

      alias specs local_specs

      def cache(spec)
        unless path.expand_path(Bundler.root).to_s.index(Bundler.root.to_s) == 0
          Bundler.ui.warn "  * #{spec.name} at `#{path}` will not be cached."
        end
      end

    private

      def relative_path
        if path.to_s.include?(Bundler.root.to_s)
          return path.relative_path_from(Bundler.root)
        end

        path
      end

      def generate_bin(spec)
        gem_dir  = Pathname.new(spec.full_gem_path)

        # Some gem authors put absolute paths in their gemspec
        # and we have to save them from themselves
        spec.files = spec.files.map do |p|
          next if File.directory?(p)
          begin
            Pathname.new(p).relative_path_from(gem_dir).to_s
          rescue ArgumentError
            p
          end
        end.compact

        gem_file = Dir.chdir(gem_dir){ Gem::Builder.new(spec).build }

        installer = Installer.new(spec, :env_shebang => false)
        installer.build_extensions
        installer.generate_bin
      rescue Gem::InvalidSpecificationException => e
        Bundler.ui.warn "\n#{spec.name} at #{spec.full_gem_path} did not have a valid gemspec.\n" \
                        "This prevents bundler from installing bins or native extensions, but " \
                        "that may not affect its functionality."

        if !spec.extensions.empty? && !spec.email.empty?
          Bundler.ui.warn "If you need to use this package without installing it from a gem " \
                          "repository, please contact #{spec.email} and ask them " \
                          "to modify their .gemspec so it can work with `gem build`."
        end

        Bundler.ui.warn "The validation message from Rubygems was:\n  #{e.message}"
      ensure
        Dir.chdir(gem_dir){ FileUtils.rm_rf(gem_file) if gem_file && File.exist?(gem_file) }
      end

    end

    class Git < Path
      attr_reader :uri, :ref, :options, :submodules

      def initialize(options)
        super

        # stringify options that could be set as symbols
        %w(ref branch tag revision).each{|k| options[k] = options[k].to_s if options[k] }

        @uri        = options["uri"]
        @ref        = options["ref"] || options["branch"] || options["tag"] || 'master'
        @revision   = options["revision"]
        @submodules = options["submodules"]
        @update     = false
      end

      def self.from_lock(options)
        new(options.merge("uri" => options.delete("remote")))
      end

      def to_lock
        out = "GIT\n"
        out << "  remote: #{@uri}\n"
        out << "  revision: #{revision}\n"
        %w(ref branch tag submodules).each do |opt|
          out << "  #{opt}: #{options[opt]}\n" if options[opt]
        end
        out << "  glob: #{@glob}\n" unless @glob == DEFAULT_GLOB
        out << "  specs:\n"
      end

      def eql?(o)
        Git === o            &&
        uri == o.uri         &&
        ref == o.ref         &&
        name == o.name       &&
        version == o.version &&
        submodules == o.submodules
      end

      alias == eql?

      def to_s
        sref = options["ref"] ? shortref_for_display(options["ref"]) : ref
        "#{uri} (at #{sref})"
      end

      def name
        File.basename(@uri, '.git')
      end

      def path
        @install_path ||= begin
          git_scope = "#{base_name}-#{shortref_for_path(revision)}"

          if Bundler.requires_sudo?
            Bundler.user_bundle_path.join(Bundler.ruby_scope).join(git_scope)
          else
            Bundler.install_path.join(git_scope)
          end
        end
      end

      def unlock!
        @revision = nil
      end

      # TODO: actually cache git specs
      def specs
        if allow_git_ops? && !@update
          # Start by making sure the git cache is up to date
          cache
          checkout
          @update = true
        end
        local_specs
      end

      def install(spec)
        Bundler.ui.info "Using #{spec.name} (#{spec.version}) from #{to_s} "

        unless @installed
          Bundler.ui.debug "  * Checking out revision: #{ref}"
          checkout if allow_git_ops?
          @installed = true
        end
        generate_bin(spec)
      end

      def load_spec_files
        super
      rescue PathError, GitError
        raise GitError, "#{to_s} is not checked out. Please run `bundle install`"
      end

    private

      def git(command)
        if allow_git_ops?
          out = %x{git #{command}}

          if $? != 0
            raise GitError, "An error has occurred in git when running `git #{command}`. Cannot complete bundling."
          end
          out
        else
          raise GitError, "Bundler is trying to run a `git #{command}` at runtime. You probably need to run `bundle install`. However, " \
                          "this error message could probably be more useful. Please submit a ticket at http://github.com/carlhuda/bundler/issues " \
                          "with steps to reproduce as well as the following\n\nCALLER: #{caller.join("\n")}"
        end
      end

      def base_name
        File.basename(uri.sub(%r{^(\w+://)?([^/:]+:)},''), ".git")
      end

      def shortref_for_display(ref)
        ref[0..6]
      end

      def shortref_for_path(ref)
        ref[0..11]
      end

      def uri_hash
        if uri =~ %r{^\w+://(\w+@)?}
          # Downcase the domain component of the URI
          # and strip off a trailing slash, if one is present
          input = URI.parse(uri).normalize.to_s.sub(%r{/$},'')
        else
          # If there is no URI scheme, assume it is an ssh/git URI
          input = uri
        end
        Digest::SHA1.hexdigest(input)
      end

      def cache_path
        @cache_path ||= begin
          git_scope = "#{base_name}-#{uri_hash}"

          if Bundler.requires_sudo?
            Bundler.user_bundle_path.join("cache/git", git_scope)
          else
            Bundler.cache.join("git", git_scope)
          end
        end
      end

      def cache
        if cached?
          return if has_revision_cached?
          Bundler.ui.info "Updating #{uri}"
          in_cache do
            git %|fetch --force --quiet --tags "#{uri}" refs/heads/*:refs/heads/*|
          end
        else
          Bundler.ui.info "Fetching #{uri}"
          FileUtils.mkdir_p(cache_path.dirname)
          git %|clone "#{uri}" "#{cache_path}" --bare --no-hardlinks|
        end
      end

      def checkout
        unless File.exist?(path.join(".git"))
          FileUtils.mkdir_p(path.dirname)
          FileUtils.rm_rf(path)
          git %|clone --no-checkout "#{cache_path}" "#{path}"|
        end
        Dir.chdir(path) do
          git %|fetch --force --quiet --tags "#{cache_path}"|
          git "reset --hard #{revision}"

          if @submodules
            git "submodule init"
            git "submodule update"
          end
        end
      end

      def has_revision_cached?
        return unless @revision
        in_cache { git %|cat-file -e #{@revision}| }
        true
      rescue GitError
        false
      end

      def allow_git_ops?
        @allow_remote || @allow_cached
      end

      def revision
        @revision ||= begin
          if allow_git_ops?
            in_cache { git("rev-parse #{ref}").strip }
          else
            raise GitError, "The git source #{uri} is not yet checked out. Please run `bundle install` before trying to start your application"
          end
        end
      end

      def cached?
        cache_path.exist?
      end

      def in_cache(&blk)
        cache unless cached?
        Dir.chdir(cache_path, &blk)
      end
    end

  end
end
