require 'yaml'

module MRuby
  module LoadGems

    def gembox(gemboxfile)
      gembox = File.expand_path("#{gemboxfile}.gembox", "#{MRUBY_ROOT}/mrbgems")
      fail "Can't find gembox '#{gembox}'" unless File.exist?(gembox)

      GemBox.config = self
      GemBox.path = gembox

      instance_eval File.read(gembox)

      GemBox.path = nil
    end

    def gem(gem_src, &block)

      caller_dir = File.expand_path(File.dirname(caller(1,1)[0][/^(.*?):\d/,1]))

      gem_src = {gemdir: gem_src} if gem_src.is_a? String

      @gem_checkouts ||= {}
      checkout = GemLoader
                   .new(self, caller_dir, @gem_checkouts, **gem_src)
                   .fetch!
      return nil unless checkout
      @gem_checkouts[checkout.gemdir] = checkout

      # Load the gem's rakefile
      gemrake = File.join(checkout.full_gemdir, "mrbgem.rake")
      fail "Can't find #{gemrake}" unless File.exist?(gemrake)

      current_build = MRuby::Build.current
      build = self.is_a?(MRuby::Build) ? self : MRuby::Build.current
      MRuby::Build.current = build
      Gem.current = nil
      begin
        load gemrake
      ensure
        MRuby::Build.current = current_build
      end
      return nil unless Gem.current
      current = Gem.current

      # Add it to gems
      current.dir = checkout.full_gemdir
      current.build = build
      current.build_config_initializer = block
      gems << current

      cxx_srcs = Dir.glob("#{current.dir}/{src,test,tools}/*.{cpp,cxx,cc}")
      enable_cxx_exception unless cxx_srcs.empty?

      current
    end


    # Class to represent the relationship between a gem dependency and
    # its remote repository (if any).
    class GemCheckout
      attr_reader :gemdir, :repo, :branch, :commit

      def initialize(gemdir, repo, branch, commit, canonical, path = nil)
        @gemdir = gemdir            # Working copy of the gem
        @path = path                # Path to gem relative to checkout

        @repo = repo                # Remote gem repo
        @branch = branch            # Branch to check out
        @commit = commit            # Commit-id to use

        @canonical = canonical      # This is the One True checkout
      end

      def full_gemdir
        return @gemdir unless @path
        return File.join(@gemdir, @path)
      end

      def canonical?()  return @canonical;                  end
      def git?()        return !!@repo;                     end
      def gemname()     return File.basename(@gemdir);      end

      def hash
        return [@gemdir, @repo, @branch, @commit, @canonical, @path].hash
      end

      def ==(other)
        return @gemdir == other.gemdir &&
          @repo == other.repo &&
          @branch == other.branch &&
          @commit == other.commit &&
          @canonical == other.canonical? &&
          full_gemdir == other.full_gemdir
      end
      alias_method :eql?, :==

      def to_s
        desc = @gemdir
        desc += " -> #{@repo}/#{@branch}" if git?
        desc += "/#{commit}" if commit
        return desc
      end
    end

    # Class to decode the argument set given to 'MRuby::Build::gem',
    # and git-clone+git-checkout the sources if needed.
    class GemLoader
      def initialize(build,
                     build_config_dir,      # Parent dir. of build_config
                     gem_checkouts,         # Hash of existing checkouts

                     # Git repo:
                     git: nil,
                     branch: "master",
                     checksum_hash: nil,
                     options: [],
                     path: nil,     # path to root relative to gem checkout

                     # Git repo on GitHub
                     github: nil,

                     # Git repo on BitBucket
                     bitbucket: nil,
                     method: nil,

                     # mgem entry
                     mgem: nil,

                     # Core package
                     core: nil,

                     # Local file(s)
                     gemdir: nil,

                     # Related flags:
                     canonical: false   # Ignore subsequent checkout of this gem
                    )
        # Tolerate a single string option
        options = [options] unless options.is_a? Array

        @build = build
        @build_config_dir = build_config_dir
        @gem_checkouts = gem_checkouts
        @canonical = canonical

        @git = git
        @path = path
        @branch = branch
        @checksum_hash = checksum_hash
        @options = options
        @canonical = canonical

        @github = github

        @bitbucket = bitbucket
        @method = method

        @mgem = mgem
        @core = core
        @gemdir = gemdir


        actions = [git, github, bitbucket, mgem, core, gemdir]
        fail("Need to set exactly ONE of git, github, bitbucket, mgem, core, " +
             "or gemdir") unless actions.compact.size == 1
      end

      # Retrieve the repo and return the details in a GemCheckout
      # object or nil if nothing needed to be done.
      def fetch!
        return fromGemdir!              if @gemdir
        return fromCore!                if @core
        return fromGitHub!              if @github
        return fromBitBucket!           if @bitbucket

        return fromMGem!                if @mgem

        return fromGit!(@git, @branch)  if @git

        # Shouldn't be reachable, but...
        fail "Invalid gem configuration!"
      end

      private

      #
      # Local Paths
      #

      def fromGemdir!
        gem_src = @gemdir

        # If @gemdir is a relative path, we first convert it to an
        # absolute path; this depends on circumstances.
        if MRuby::GemBox.path
          # If GemBox.path is set, it means that this fetch operation is
          # happening as part of a gembox evaluation and we use the
          # gembox's path as the starting point.
          gem_src = File.expand_path(gem_src, File.dirname(MRuby::GemBox.path))
        else
          # Otherwise, we use the path to the build_config.rb file that
          # requested this gem. This path was extracted earlier and
          # stored in @build_config_dir via the second argument of
          # 'initialize'.
          root_dir = @build_config_dir

          # And we default to the repo root if the file is one of the
          # stock configs in build_config/.
          root_dir = MRUBY_ROOT if root_dir == "#{MRUBY_ROOT}/build_config"

          gem_src = File.expand_path(gem_src, root_dir)
        end

        return GemCheckout.new(gem_src, nil, nil, nil, @canonical)
      end

      def fromCore!
        return GemCheckout.new("#{@build.root}/mrbgems/#{@core}", nil, nil,
                               nil, @canonical)
      end


      #
      # Git forges
      #

      def fromGitHub!
        url = "https://github.com/#{@github}.git"
        return fromGit!(url, @branch)
      end

      def fromBitBucket!
        if @method == "ssh"
          url = "git@bitbucket.org:#{@bitbucket}.git"
        else
          url = "https://bitbucket.org/#{@bitbucket}.git"
        end

        return fromGit!(url, @branch)
      end


      #
      # mgem file
      #

      def fromMGem!
        mgem = fetchMGem(@mgem)

        url = mgem['repository']
        branch = mgem['branch'] || @branch

        return fromGit!(url, branch)
      end

      # Fetch the contents of the named mgem item. Will clone the
      # mgem-list repo if not present
      def fetchMGem(mgem)
        list_dir = "#{@build.gem_clone_dir}/mgem-list"
        url = 'https://github.com/mruby/mgem-list.git'

        git_clone_dependency(url, list_dir, nil, 'master')

        conf_path = "#{list_dir}/#{mgem}.gem"
        conf_path = "#{list_dir}/mruby-#{mgem}.gem" unless
          File.exist? conf_path
        fail "mgem not found: #{mgem}" unless File.exist? conf_path

        conf = YAML.load File.read conf_path
        fail "unknown mgem protocol: #{conf['protocol']}" if
          conf['protocol'] != 'git'

        return conf
      end


      #
      # Git checkouts
      #

      def fromGit!(url, branch)
        repo_dir = "#{@build.gem_clone_dir}/" +
                   "#{url.match(/([-\w]+)(\.[-\w]+|)$/).to_a[1]}"
        commit = @checksum_hash

        return nil if skip_this?(url, repo_dir, branch, commit)

        # If there's a lockfile entry for this repo AND the user hasn't
        # specified a specific commit ID, we use the locked branch and
        # commit.
        lock = @build.locks[url] if @build.lock_enabled?
        if !commit && lock
          branch = lock['branch']
          commit = lock['commit']
        end

        # Clone the dependency (if needed) and checkout the expected
        # revision.
        git_clone_dependency(url, repo_dir, commit, branch)
        git_checkout_dependency(repo_dir, commit, branch)

        # Set the lockfile entry if enabled
        if @build.lock_enabled?
          @build.gem_dir_to_repo_url[repo_dir] = url
          @build.locks[url] = {
            'url' => url,
            'branch' => branch || @build.git.current_branch(repo_dir),
            'commit' => @build.git.commit_hash(repo_dir),
          }
        end

        return GemCheckout.new(repo_dir, url, branch, commit, @canonical,@path)
      end


      # Test if this repo can be skipped. This will happen if it's
      # already in @gem_checkouts and EITHER it is identical (same
      # url, branch, commit-ID and subdirectory path) as the current
      # checkout OR its "canonical" flag is true. If it's in
      # @gem_checkouts and neither of these conditions is true, that's
      # a fatal error; it means there are multiple incompatible
      # versions of this gem to be checked out into this directory.
      #
      # Otherwise, returns false.
      def skip_this?(url, repo_dir, branch, commit)
        prev = @gem_checkouts[repo_dir]
        return false unless prev

        # Canonical declarations must precede all others.
        fail("Attempted to redeclare #{prev.gemname} as canonical!\n" +
             "('canonical' can only be used on its first declaration.)") if
          prev && @canonical

        # If prev is canonical, we can ignore this
        if prev.canonical?
          puts "Found canonical #{prev.gemname}; skipping this one."
          return true
        end

        # If this checkout is identical to the current one, we can skip it.
        candidate = GemCheckout.new(repo_dir, url, branch, commit, @canonical,
                                    @path)
        if prev == candidate
          puts "Found duplicate checkout for #{repo_dir}; ignoring."
          return true
        end

        # Otherwise, we have a checkout conflict. This is an error.
        fail "Conflicting gem definitions for '#{repo_dir}':\n" +
             "  #{candidate}\n" +
             "  #{prev}\n"
      end


      # Retrieve a git repo if it's not present. Return
      # [path_to_checkout, did_clone]
      def git_clone_dependency(url, repo_dir, commit, branch)
        return if
          File.exist?(repo_dir) && File.exist?(File.join(repo_dir, '.git'))

        FileUtils.mkdir_p repo_dir

        options = @options.dup
        options << "--recursive"
        options << "--branch \"#{branch}\""
        options << "--depth 1" unless commit

        @build.git.run_clone repo_dir, url, options
      end

      def git_checkout_dependency(repo_dir, commit, branch)
        return unless commit
        @build.git.run_checkout_detach(repo_dir, commit)
      end
    end

    def enable_gems?
      !@gems.empty?
    end
  end # LoadGems
end # MRuby
