require "fastlane/cli_tools_distributor"

module Fastlane
  class PluginManager
    require "bundler"

    PLUGINFILE_NAME = "Pluginfile".freeze
    DEFAULT_GEMFILE_PATH = "Gemfile".freeze
    AUTOGENERATED_LINE = "# Autogenerated by fastlane\n#\n# Ensure this file is checked in to source control!\n\n"
    GEMFILE_SOURCE_LINE = "source \"https://rubygems.org\"\n"
    FASTLANE_PLUGIN_PREFIX = "fastlane-plugin-"
    TROUBLESHOOTING_URL = "https://docs.fastlane.tools/plugins/plugins-troubleshooting/"

    #####################################################
    # @!group Reading the files and their paths
    #####################################################

    def gemfile_path
      # This is pretty important, since we don't know what kind of
      # Gemfile the user has (e.g. Gemfile, gems.rb, or custom env variable)
      Bundler::SharedHelpers.default_gemfile.to_s
    rescue Bundler::GemfileNotFound
      nil
    end

    def pluginfile_path
      if FastlaneCore::FastlaneFolder.path
        return File.join(FastlaneCore::FastlaneFolder.path, PLUGINFILE_NAME)
      else
        return nil
      end
    end

    def gemfile_content
      File.read(gemfile_path) if gemfile_path && File.exist?(gemfile_path)
    end

    def pluginfile_content
      File.read(pluginfile_path) if pluginfile_path && File.exist?(pluginfile_path)
    end

    #####################################################
    # @!group Helpers
    #####################################################

    def self.plugin_prefix
      FASTLANE_PLUGIN_PREFIX
    end

    def self.to_gem_name(plugin_name)
      plugin_name.start_with?(plugin_prefix) ? plugin_name : (plugin_prefix + plugin_name)
    end

    # Returns an array of gems that are added to the Gemfile or Pluginfile
    def available_gems
      return [] unless gemfile_path
      dsl = Bundler::Dsl.evaluate(gemfile_path, nil, true)
      return dsl.dependencies.map(&:name)
    end

    # Returns an array of fastlane plugins that are added to the Gemfile or Pluginfile
    # The returned array contains the string with their prefixes (e.g. fastlane-plugin-xcversion)
    def available_plugins
      available_gems.keep_if do |current|
        current.start_with?(self.class.plugin_prefix)
      end
    end

    # Check if a plugin is added as dependency to either the
    # Gemfile or the Pluginfile
    def plugin_is_added_as_dependency?(plugin_name)
      UI.user_error!("fastlane plugins must start with '#{self.class.plugin_prefix}' string") unless plugin_name.start_with?(self.class.plugin_prefix)
      return available_plugins.include?(plugin_name)
    end

    #####################################################
    # @!group Modifying dependencies
    #####################################################

    def add_dependency(plugin_name)
      UI.user_error!("fastlane is not setup for this project, make sure you have a fastlane folder") unless pluginfile_path
      plugin_name = self.class.plugin_prefix + plugin_name unless plugin_name.start_with?(self.class.plugin_prefix)

      if plugin_name.gsub(self.class.plugin_prefix, '').include?("-")
        # e.g. "fastlane-plugin-ya_tu-sabes" (which is invalid)
        UI.user_error!("Plugin name must not contain a '-', did you mean '_'?")
      end

      unless plugin_is_added_as_dependency?(plugin_name)
        content = pluginfile_content || AUTOGENERATED_LINE

        unless content.end_with?("\n")
          content += "\n"
        end

        line_to_add = "gem '#{plugin_name}'"
        line_to_add += gem_dependency_suffix(plugin_name)
        UI.verbose("Adding line: #{line_to_add}")

        content += "#{line_to_add}\n"
        File.write(pluginfile_path, content)
        UI.success("Plugin '#{plugin_name}' was added to '#{pluginfile_path}'")
      end

      # We do this *after* creating the Plugin file
      # Since `bundle exec` would be broken if something fails on the way
      ensure_plugins_attached!

      true
    end

    # Get a suffix (e.g. `path` or `git` for the gem dependency)
    def gem_dependency_suffix(plugin_name)
      return "" unless self.class.fetch_gem_info_from_rubygems(plugin_name).nil?

      selection_git_url = "Git URL"
      selection_path = "Local Path"
      selection_rubygems = "RubyGems.org ('#{plugin_name}' seems to not be available there)"
      selection_gem_server = "Other Gem Server"
      selection = UI.select(
        "Seems like the plugin is not available on RubyGems, what do you want to do?",
        [selection_git_url, selection_path, selection_rubygems, selection_gem_server]
      )

      if selection == selection_git_url
        git_url = UI.input('Please enter the URL to the plugin, including the protocol (e.g. https:// or git://)')
        return ", git: '#{git_url}'"
      elsif selection == selection_path
        path = UI.input('Please enter the relative path to the plugin you want to use. It has to point to the directory containing the .gemspec file')
        return ", path: '#{path}'"
      elsif selection == selection_rubygems
        return ""
      elsif selection == selection_gem_server
        source_url = UI.input('Please enter the gem source URL which hosts the plugin you want to use, including the protocol (e.g. https:// or git://)')
        return ", source: '#{source_url}'"
      else
        UI.user_error!("Unknown input #{selection}")
      end
    end

    # Modify the user's Gemfile to load the plugins
    def attach_plugins_to_gemfile!(path_to_gemfile)
      content = gemfile_content || (AUTOGENERATED_LINE + GEMFILE_SOURCE_LINE)

      # We have to make sure fastlane is also added to the Gemfile, since we now use
      # bundler to run fastlane
      content += "\ngem 'fastlane'\n" unless available_gems.include?('fastlane')
      content += "\n#{self.class.code_to_attach}\n"

      File.write(path_to_gemfile, content)
    end

    #####################################################
    # @!group Accessing RubyGems
    #####################################################

    def self.fetch_gem_info_from_rubygems(gem_name)
      require 'json'
      require 'open-uri'
      url = "https://rubygems.org/api/v1/gems/#{gem_name}.json"
      begin
        JSON.parse(URI.open(url).read)
      rescue
        nil
      end
    end

    #####################################################
    # @!group Installing and updating dependencies
    #####################################################

    # Warning: This will exec out
    # This is necessary since the user might be prompted for their password
    def install_dependencies!
      # Using puts instead of `UI` to have the same style as the `echo`
      puts("Installing plugin dependencies...")
      ensure_plugins_attached!
      with_clean_bundler_env do
        cmd = "bundle install"
        cmd << " --quiet" unless FastlaneCore::Globals.verbose?
        cmd << " && bundle exec fastlane generate_swift" if FastlaneCore::FastlaneFolder.swift?
        cmd << " && echo 'Successfully installed plugins'"
        UI.command(cmd) if FastlaneCore::Globals.verbose?
        exec(cmd)
      end
    end

    # Warning: This will exec out
    # This is necessary since the user might be prompted for their password
    def update_dependencies!
      puts("Updating plugin dependencies...")
      ensure_plugins_attached!
      plugins = available_plugins
      if plugins.empty?
        UI.user_error!("No plugins are installed")
      end
      with_clean_bundler_env do
        cmd = "bundle update"
        cmd << " #{plugins.join(' ')}"
        cmd << " --quiet" unless FastlaneCore::Globals.verbose?
        cmd << " && bundle exec fastlane generate_swift" if FastlaneCore::FastlaneFolder.swift?
        cmd << " && echo 'Successfully updated plugins'"
        UI.command(cmd) if FastlaneCore::Globals.verbose?
        exec(cmd)
      end
    end

    def with_clean_bundler_env
      # There is an interesting problem with using exec to call back into Bundler
      # The `bundle ________` command that we exec, inherits all of the Bundler
      # state we'd already built up during this run. That was causing the command
      # to fail, telling us to install the Gem we'd just introduced, even though
      # that is exactly what we are trying to do!
      #
      # Bundler.with_clean_env solves this problem by resetting Bundler state before the
      # exec'd call gets merged into this process.

      Bundler.with_original_env do
        yield if block_given?
      end
    end

    #####################################################
    # @!group Initial setup
    #####################################################

    def setup
      UI.important("It looks like fastlane plugins are not yet set up for this project.")

      path_to_gemfile = gemfile_path || DEFAULT_GEMFILE_PATH

      if gemfile_content.to_s.length > 0
        UI.important("fastlane will modify your existing Gemfile at path '#{path_to_gemfile}'")
      else
        UI.important("fastlane will create a new Gemfile at path '#{path_to_gemfile}'")
      end

      UI.important("This change is necessary for fastlane plugins to work")

      unless UI.confirm("Should fastlane modify the Gemfile at path '#{path_to_gemfile}' for you?")
        UI.important("Please add the following code to '#{path_to_gemfile}':")
        puts("")
        puts(self.class.code_to_attach.magenta) # we use `puts` instead of `UI` to make it easier to copy and paste
        UI.user_error!("Please update '#{path_to_gemfile} and run fastlane again")
      end

      attach_plugins_to_gemfile!(path_to_gemfile)
      UI.success("Successfully modified '#{path_to_gemfile}'")
    end

    # The code required to load the Plugins file
    def self.code_to_attach
      if FastlaneCore::FastlaneFolder.path
        fastlane_folder_name = File.basename(FastlaneCore::FastlaneFolder.path)
      else
        fastlane_folder_name = "fastlane"
      end
      "plugins_path = File.join(File.dirname(__FILE__), '#{fastlane_folder_name}', '#{PluginManager::PLUGINFILE_NAME}')\n" \
      "eval_gemfile(plugins_path) if File.exist?(plugins_path)"
    end

    # Makes sure, the user's Gemfile actually loads the Plugins file
    def plugins_attached?
      gemfile_path && gemfile_content.include?(PluginManager::PLUGINFILE_NAME)
    end

    def ensure_plugins_attached!
      return if plugins_attached?
      self.setup
    end

    #####################################################
    # @!group Requiring the plugins
    #####################################################

    # Iterate over all available plugins
    # which follow the naming convention
    #   fastlane-plugin-[plugin_name]
    # This will make sure to load the action
    # and all its helpers
    def load_plugins(print_table: true)
      UI.verbose("Checking if there are any plugins that should be loaded...")

      loaded_plugins = false
      available_plugins.each do |gem_name|
        UI.verbose("Loading '#{gem_name}' plugin")
        begin
          # BEFORE requiring the gem, we get a list of loaded actions
          # This way we can check inside `store_plugin_reference` if
          # any actions were overwritten
          self.loaded_fastlane_actions.concat(Fastlane::Actions.constants)

          FastlaneRequire.install_gem_if_needed(gem_name: gem_name, require_gem: true)

          store_plugin_reference(gem_name)
          loaded_plugins = true
        rescue StandardError, ScriptError => ex # some errors, like ScriptError are not caught unless explicitly
          UI.error("Error loading plugin '#{gem_name}': #{ex}")

          # We'll still add it to the table, to make the error
          # much more visible and obvious
          self.plugin_references[gem_name] = {
            version_number: Fastlane::ActionCollector.determine_version(gem_name),
            actions: []
          }
        end
      end

      if !loaded_plugins && self.pluginfile_content.to_s.include?(PluginManager.plugin_prefix)
        UI.error("It seems like you wanted to load some plugins, however they couldn't be loaded")
        UI.error("Please follow the troubleshooting guide: #{TROUBLESHOOTING_URL}")
      end

      skip_print_plugin_info = self.plugin_references.empty? || CLIToolsDistributor.running_version_command? || !print_table

      # We want to avoid printing output other than the version number if we are running `fastlane -v`
      print_plugin_information(self.plugin_references) unless skip_print_plugin_info
    end

    # Prints a table all the plugins that were loaded
    def print_plugin_information(references)
      no_action_found = false

      rows = references.collect do |current|
        if current[1][:actions].empty?
          # Something is wrong with this plugin, no available actions
          no_action_found = true
          [current[0].red, current[1][:version_number], "No actions found".red]
        else
          [current[0], current[1][:version_number], current[1][:actions].join(", ")]
        end
      end

      require 'terminal-table'
      puts(Terminal::Table.new({
        rows: FastlaneCore::PrintTable.transform_output(rows),
        title: "Used plugins".green,
        headings: ["Plugin", "Version", "Action"]
      }))
      puts("")

      if no_action_found
        puts("[!] No actions were found while loading one or more plugins".red)
        puts("    Please use `bundle exec fastlane` with plugins".red)
        puts("    More info - https://docs.fastlane.tools/plugins/using-plugins/#run-with-plugins".red)
        puts("")
      end
    end

    #####################################################
    # @!group Reference between plugins to actions
    #####################################################

    # Connection between plugins and their actions
    # Example value of plugin_references
    # => {"fastlane-plugin-ruby" => {
    #          version_number: "0.1.0",
    #          actions: [:rspec, :rubocop]
    #     }}
    def plugin_references
      @plugin_references ||= {}
    end

    # Contains an array of symbols for the action classes
    def loaded_fastlane_actions
      @fastlane_actions ||= []
    end

    def store_plugin_reference(gem_name)
      module_name = gem_name.gsub(PluginManager.plugin_prefix, '').fastlane_class
      # We store a collection of the imported plugins
      # This way we can tell which action came from what plugin
      # (a plugin may contain any number of actions)
      version_number = Fastlane::ActionCollector.determine_version(gem_name)
      references = Fastlane.const_get(module_name).all_classes.collect do |path|
        next unless File.dirname(path).include?("/actions") # we only want to match actions

        File.basename(path).gsub(".rb", "").gsub(/_action$/, '').to_sym # the _action is optional
      end
      references.compact!

      # Check if this overwrites a built-in action and
      # show a warning if that's the case
      references.each do |current_ref|
        # current_ref is a symbol, e.g. :emoji_fetcher
        class_name = (current_ref.to_s.fastlane_class + 'Action').to_sym

        if self.loaded_fastlane_actions.include?(class_name)
          UI.important("Plugin '#{module_name}' overwrites already loaded action '#{current_ref}'")
        end
      end

      self.plugin_references[gem_name] = {
        version_number: version_number,
        actions: references
      }
    end
  end
end
