# 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.

require "pluginmanager/command"
require "pluginmanager/install_strategy_factory"
require "pluginmanager/ui"
require "pluginmanager/errors"
require "jar-dependencies"
require "jar_install_post_install_hook"
require "fileutils"

class LogStash::PluginManager::Install < LogStash::PluginManager::Command
  parameter "[PLUGIN] ...", "plugin name(s) or file", :attribute_name => :plugins_arg
  option "--version", "VERSION", "version of the plugin to install"
  option "--[no-]verify", :flag, "verify plugin validity before installation", :default => true
  option "--preserve", :flag, "preserve current gem options", :default => false
  option "--development", :flag, "install all development dependencies of currently installed plugins", :default => false
  option "--local", :flag, "force local-only plugin installation. see bin/logstash-plugin package|unpack", :default => false
  option "--[no-]conservative", :flag, "do a conservative update of plugin's dependencies", :default => true

  # the install logic below support installing multiple plugins with each a version specification
  # but the argument parsing does not support it for now so currently if specifying --version only
  # one plugin name can be also specified.
  def execute
    # Turn off any jar dependencies lookup when running with `--local`
    ENV["JARS_SKIP"] = "true" if local?

    # This is a special flow for PACK related plugins,
    # if we dont detect an pack we will just use the normal `Bundle install` Strategy`
    # this could be refactored into his own strategy
    begin
      if strategy = LogStash::PluginManager::InstallStrategyFactory.create(plugins_arg)
        LogStash::PluginManager.ui.debug("Installing with strategy: #{strategy.class}")
        strategy.execute
        return
      end
    rescue LogStash::PluginManager::InstallError => e
      report_exception("An error occured when installing the: #{plugins_args_human}, to have more information about the error add a DEBUG=1 before running the command.", e.original_exception)
      return
    rescue LogStash::PluginManager::FileNotFoundError => e
      report_exception("File not found for: #{plugins_args_human}", e)
      return
    rescue LogStash::PluginManager::InvalidPackError => e
      report_exception("Invalid pack for: #{plugins_args_human}, reason: #{e.message}", e)
      return
    rescue => e
      report_exception("Something went wrong when installing #{plugins_args_human}", e)
      return
    end

    # TODO(ph): refactor this into his own strategy
    validate_cli_options!

    if local_gems?
      gems = extract_local_gems_plugins
    elsif development?
      gems = plugins_development_gems
    else
      gems = plugins_gems
      gems = verify_remote!(gems) if !local? && verify?
    end

    check_for_integrations(gems)
    update_logstash_mixin_dependencies(gems)
    install_gems_list!(gems)
    remove_unused_locally_installed_gems!
    remove_unused_integration_overlaps!
    remove_orphan_dependencies!
  end

  private

  def remove_unused_integration_overlaps!
    installed_plugin_specs = plugins_arg.flat_map do |plugin_arg|
      if LogStash::PluginManager.plugin_file?(plugin_arg)
        LogStash::PluginManager.plugin_file_spec(plugin_arg)
      else
        LogStash::PluginManager.find_plugins_gem_specs(plugin_arg)
      end
    end.select do |spec|
      LogStash::PluginManager.integration_plugin_spec?(spec)
    end.flat_map do |spec|
      LogStash::PluginManager.integration_plugin_provides(spec)
    end.select do |plugin_name|
      LogStash::PluginManager.installed_plugin?(plugin_name, gemfile)
    end.each do |plugin_name|
      puts "Removing '#{plugin_name}' since it is provided by an integration plugin"
      ::Bundler::LogstashUninstall.uninstall!(plugin_name)
    end
  end

  def check_for_integrations(gems)
    gems.each do |plugin, _version|
      integration_plugin = LogStash::PluginManager.which_integration_plugin_provides(plugin, gemfile)
      if integration_plugin
        signal_error("Installation aborted, plugin '#{plugin}' is already provided by '#{integration_plugin.name}'")
      end
    end
  end

  def validate_cli_options!
    if development?
      signal_usage_error("Cannot specify plugin(s) with --development, it will add the development dependencies of the currently installed plugins") unless plugins_arg.empty?
    else
      signal_usage_error("No plugin specified") if plugins_arg.empty? && verify?
      # TODO: find right syntax to allow specifying list of plugins with optional version specification for each
      signal_usage_error("Only 1 plugin name can be specified with --version") if version && plugins_arg.size > 1
    end
    signal_error("File #{LogStash::Environment::GEMFILE_PATH} does not exist or is not writable, aborting") unless ::File.writable?(LogStash::Environment::GEMFILE_PATH)
  end

  # Check if the specified gems contains
  # the logstash `metadata`
  def verify_remote!(gems)
    gems_swap = {}
    options = { :rubygems_source => gemfile.gemset.sources }
    gems.each do |plugin, version|
      puts("Validating #{[plugin, version].compact.join("-")}")
      next if validate_plugin(plugin, version, options)

      signal_usage_error("Installs of an alias doesn't require version specification --version") if version

      # if the plugin is an alias then fallback to the original name
      if LogStash::PluginManager::ALIASES.has_key?(plugin)
        resolved_plugin = LogStash::PluginManager::ALIASES[plugin]
        if validate_plugin(resolved_plugin, version, options)
          puts "Remapping alias #{plugin} to #{resolved_plugin}"
          gems_swap[plugin] = resolved_plugin
          next
        end
      end
      signal_error("Installation aborted, verification failed for #{plugin} #{version}")
    end

    # substitute in gems the list the alias plugin with the original
    gems.collect do |plugin, version|
      [gems_swap.fetch(plugin, plugin), version]
    end
  end

  def validate_plugin(plugin, version, options)
    LogStash::PluginManager.logstash_plugin?(plugin, version, options)
  rescue SocketError
    false
  end

  def plugins_development_gems
    # Get currently defined gems and their dev dependencies
    specs = []

    specs = LogStash::PluginManager.all_installed_plugins_gem_specs(gemfile)

    # Construct the list of dependencies to add to the current gemfile
    specs.each_with_object([]) do |spec, install_list|
      dependencies = spec.dependencies
        .select { |dep| dep.type == :development }
        .map { |dep| [dep.name] + dep.requirement.as_list }

      install_list.concat(dependencies)
    end
  end

  def plugins_gems
    version ? [plugins_arg << version] : plugins_arg.map { |plugin| [plugin, nil] }
  end

  def local_gem?
    plugins_arg.any? { |plugin_arg| LogStash::PluginManager.plugin_file?(plugin_arg) }
  end

  def update_logstash_mixin_dependencies(install_list)
    return if !verify? || preserve? || development? || local? || local_gem?

    puts "Resolving mixin dependencies"
    LogStash::Bundler.prepare
    plugins_to_update = install_list.map(&:first)
    unlock_dependencies = LogStash::Bundler.expand_logstash_mixin_dependencies(plugins_to_update) - plugins_to_update

    if unlock_dependencies.any?
      puts "Updating mixin dependencies #{unlock_dependencies.join(', ')}"
      LogStash::Bundler.invoke! update: unlock_dependencies,
                                rubygems_source: gemfile.gemset.sources,
                                conservative: conservative?
    end

    unlock_dependencies
  end

  # install_list will be an array of [plugin name, version, options] tuples, version it
  # can be nil at this point we know that plugins_arg is not empty and if the
  # --version is specified there is only one plugin in plugins_arg
  #
  def install_gems_list!(install_list)
    # If something goes wrong during the installation `LogStash::Gemfile` will restore a backup version.
    install_list = LogStash::PluginManager.merge_duplicates(install_list)

    # Add plugins/gems to the current gemfile
    puts("Installing" + (install_list.empty? ? "..." : " " + install_list.collect(&:first).join(", ")))
    install_list.each do |plugin, version, options|
      plugin_gem = gemfile.find(plugin)
      if preserve?
        puts("Preserving Gemfile gem options for plugin #{plugin}") if plugin_gem && !plugin_gem.options.empty?
        # if the plugin exists and no version was specified, keep the existing requirements
        requirements = (plugin_gem && version.nil? ? plugin_gem.requirements : [version]).compact
        gemfile.update(plugin, *requirements, options)
      else
        gemfile.overwrite(plugin, version, options)
      end
    end

    # Sync gemfiles changes to disk to make them available to the `bundler install`'s API
    gemfile.save

    bundler_options = {:install => true}
    bundler_options[:without] = [] if development?
    bundler_options[:rubygems_source] = gemfile.gemset.sources
    bundler_options[:local] = true if local?
    output = nil
    # Unfreeze the bundle when installing gems
    Bundler.settings.temporary({:frozen => false}) do
      output = LogStash::Bundler.invoke!(bundler_options)
      output << LogStash::Bundler.genericize_platform.to_s
    end
    puts("Installation successful")
  rescue => exception
    gemfile.restore!
    report_exception("Installation Aborted", exception)
  ensure
    display_bundler_output(output)
  end

  # Extract the specified local gems in a predefined local path
  # Update the gemfile to use a relative path to this plugin and run
  # Bundler, this will mark the gem not updatable by `bin/logstash-plugin update`
  # This is the most reliable way to make it work in bundler without
  # hacking with `how bundler works`
  #
  # Bundler 2.0, will have support for plugins source we could create a .gem source
  # to support it.
  def extract_local_gems_plugins
    FileUtils.mkdir_p(LogStash::Environment::CACHE_PATH)
    plugins_arg.collect do |plugin|
      # We do the verify before extracting the gem so we dont have to deal with unused path
      if verify?
        puts("Validating #{plugin}")
        signal_error("Installation aborted, verification failed for #{plugin}") unless LogStash::PluginManager.logstash_plugin?(plugin, version)
      end

      # Make the original .gem available for the prepare-offline-pack,
      # paquet will lookup in the cache directory before going to rubygems.
      FileUtils.cp(plugin, ::File.join(LogStash::Environment::CACHE_PATH, ::File.basename(plugin)))
      package, path = LogStash::Rubygems.unpack(plugin, LogStash::Environment::LOCAL_GEM_PATH)
      [package.spec.name, package.spec.version, { :path => relative_path(path) }, package.spec]
    end
  end

  # We cannot install both .gem and normal plugin in one call of `plugin install`
  def local_gems?
    return false if plugins_arg.empty?

    local_gem = plugins_arg.collect { |plugin| ::File.extname(plugin) == ".gem" }.uniq

    if local_gem.size == 1
      return local_gem.first
    else
      signal_usage_error("Mixed source of plugins, you can't mix local `.gem` and remote gems")
    end
  end

  def plugins_args_human
    plugins_arg.join(", ")
  end
end # class Logstash::PluginManager
