# Licensed to Elasticsearch B.V. under one or more contributor
# license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright
# ownership. Elasticsearch B.V. licenses this file to you under
# the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#  http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

module LogStash
  class GemfileError < StandardError; end

  class Gemfile
    attr_accessor :gemset

    HEADER = \
      "# This is a Logstash generated Gemfile.\n" + \
      "# If you modify this file manually all comments and formatting will be lost.\n\n"

    # @params io [IO] any IO object that supports read, write, truncate, rewind
    def initialize(io)
      @io = io
      @gemset = nil
    end

    def load(with_backup = true)
      # encoding must be set to UTF-8 here to avoid ending up with Windows-1252 encoding on Windows
      # which will break the DSL instance_eval of that string
      @io.set_encoding(Encoding::UTF_8)
      @gemset ||= DSL.parse(@io.read)
      backup if with_backup
      self
    end

    def save
      raise(GemfileError, "a Gemfile must first be loaded") unless @gemset
      @io.truncate(0)
      @io.rewind
      @io.write(generate)
      @io.flush
    end

    def generate
      "#{HEADER}#{gemset.to_s}"
    end

    def find(name)
      @gemset.find_gem(name)
    end

    # @param name [String] gem name
    # @param *requirements params following name use the same notation as the Gemfile gem DSL statement
    # @raise GemfileError if gem already exists in Gemfile
    def add(name, *requirements)
      @gemset.add_gem(Gem.parse(name, *requirements))
    end

    # update existing or add new and merge passed options with current gem options if it exists
    # @param name [String] gem name
    # @param *requirements params following name use the same notation as the Gemfile gem DSL statement
    def update(name, *requirements)
      @gemset.update_gem(Gem.parse(name, *requirements))
    end

    # overwrite existing or add new
    # @param name [String] gem name
    # @param *requirements params following name use the same notation as the Gemfile gem DSL statement
    def overwrite(name, *requirements)
      @gemset.overwrite_gem(Gem.parse(name, *requirements))
    end

    # @return [Gem] removed gem or nil if not found
    def remove(name)
      @gemset.remove_gem(name)
    end

    def backup
      @original_backup = @gemset.copy
    end

    def restore
      @gemset = @original_backup
    end

    def defined_in_gemfile?(name)
      @gemset.find_gem(name)
    end

    def restore!
      restore
      save
    end

    def locally_installed_gems
      @gemset.gems.select { |gem| gem.options.include?(:path) }
    end
  end

  class Gemset
    attr_accessor :sources, :gems, :gemspec

    def initialize
      @sources = []      # list of urls
      @gems = []         # list of Gem class
      @gems_by_name = {} # hash of name => Gem
      @gemspec = {}      # gemspec is a options hash
    end

    def to_s
      [sources_to_s, gemspec_to_s, gems_to_s].select {|s| !s.empty?}.join("\n") + "\n"
    end

    # @return [Gem] found gem or nil if not found
    def find_gem(name)
      @gems_by_name[name.downcase]
    end

    # @raise GemfileError if gem already exists
    def add_gem(_gem)
      raise(GemfileError, "duplicate gem #{_gem.name}") if find_gem(_gem.name)
      @gems << _gem
      @gems_by_name[_gem.name.downcase] = _gem
    end

    # update existing or add new
    def update_gem(_gem)
      if old = find_gem(_gem.name)
        # always overwrite requirements
        old.requirements = _gem.requirements
        # but merge options
        old.options = old.options.merge(_gem.options)
      else
        @gems << _gem
        @gems_by_name[_gem.name.downcase] = _gem
      end
    end

    # update existing or add new
    def overwrite_gem(_gem)
      if old = find_gem(_gem.name)
        @gems[@gems.index(old)] = _gem
      else
        @gems << _gem
      end
      @gems_by_name[_gem.name.downcase] = _gem
    end

    # @return [Gem] removed gem or nil if not found
    def remove_gem(name)
      if _gem = @gems_by_name.delete(name.downcase)
        @gems.delete_at(@gems.index(_gem))
      end
      _gem
    end

    # deep clone self
    def copy
      Marshal.load(Marshal.dump(self))
    end

    private

    def no_constrains?(requirements)
      return true if requirements.nil? || requirements.empty?

      # check for the dummy ">= 0" version constrain or any variations thereof
      # which is in fact a "no constrain" constrain which we should discard
      return true if requirements.size == 1 && requirements.first.to_s.gsub(/\s+/, "") == ">=0"

      false
    end

    def sources_to_s
      return "" if @sources.empty?
      @sources.map {|source| "source #{source.inspect}"}.join("\n")
    end

    def gems_to_s
      return "" if @gems.empty?
      @gems.map do |gem|
        requirements = gem.requirements.empty? ? nil : gem.requirements.map {|r| r.inspect}.join(", ")
        options = gem.options.empty? ? nil : gem.options.map {|k, v| "#{k.inspect} => #{v.inspect}"}.join(", ")
        "gem " + [gem.name.inspect, requirements, options].compact.join(", ")
      end.join("\n")
    end

    def gemspec_to_s
      return "" if @gemspec.empty?
      options = @gemspec.map {|k, v| "#{k.inspect} => #{v.inspect}"}.join(", ")
      "gemspec #{options}"
    end
  end

  # DSL is a minimal, incomplete Gemfile DSL subset parser, only what is currently required is implemented.
  class DSL
    attr_reader :gemset

    def initialize
      @gemset = Gemset.new
    end

    # @param gemfile_content [String] the Gemfile string content
    # @return [Gemset] parsed Gemfile content as a Gemset
    def self.parse(gemfile_content)
      dsl = self.new
      dsl.instance_eval(gemfile_content)
      dsl.gemset
    end

    # DSL methods

    def source(url)
      @gemset.sources << url
    end

    def gem(name, *requirements)
      parsed = Gem.parse(name, *requirements)
      @gemset.add_gem(parsed)
    end

    def gemspec(options = {})
      raise(GemfileError, "cannot declare multiple gemspec directives") unless @gemset.gemspec.empty?
      @gemset.gemspec = options
    end
  end

  class Gem
    attr_accessor :name, :requirements, :options

    def initialize(name, requirements = [], options = {})
      @name = name
      @requirements = requirements.map {|r| r.to_s.strip}.select {|r| !r.empty?}
      @options = options
    end

    def self.parse(name, *requirements)
      options = requirements.last.is_a?(Hash) ? requirements.pop : {}
      self.new(name, requirements, options)
    end
  end
end
