# encoding: UTF-8
# frozen_string_literal: true

Puppet::Face.define(:module, '1.0.0') do
  action(:list) do
    summary _("List installed modules")
    description <<-HEREDOC
      Lists the installed puppet modules. By default, this action scans the
      modulepath from puppet.conf's `[main]` block; use the --modulepath
      option to change which directories are scanned.

      The output of this action includes information from the module's
      metadata, including version numbers and unmet module dependencies.
    HEREDOC
    returns _("hash of paths to module objects")

    option "--tree" do
      summary _("Whether to show dependencies as a tree view")
    end

    examples <<-'EOT'
      List installed modules:

      $ puppet module list
        /etc/puppetlabs/code/modules
        ├── bodepd-create_resources (v0.0.1)
        ├── puppetlabs-bacula (v0.0.2)
        ├── puppetlabs-mysql (v0.0.1)
        ├── puppetlabs-sqlite (v0.0.1)
        └── puppetlabs-stdlib (v2.2.1)
        /opt/puppetlabs/puppet/modules (no modules installed)

      List installed modules in a tree view:

      $ puppet module list --tree
        /etc/puppetlabs/code/modules
        └─┬ puppetlabs-bacula (v0.0.2)
          ├── puppetlabs-stdlib (v2.2.1)
          ├─┬ puppetlabs-mysql (v0.0.1)
          │ └── bodepd-create_resources (v0.0.1)
          └── puppetlabs-sqlite (v0.0.1)
        /opt/puppetlabs/puppet/modules (no modules installed)

      List installed modules from a specified environment:

      $ puppet module list --environment production
        /etc/puppetlabs/code/modules
        ├── bodepd-create_resources (v0.0.1)
        ├── puppetlabs-bacula (v0.0.2)
        ├── puppetlabs-mysql (v0.0.1)
        ├── puppetlabs-sqlite (v0.0.1)
        └── puppetlabs-stdlib (v2.2.1)
        /opt/puppetlabs/puppet/modules (no modules installed)

      List installed modules from a specified modulepath:

      $ puppet module list --modulepath /opt/puppetlabs/puppet/modules
        /opt/puppetlabs/puppet/modules (no modules installed)
    EOT

    when_invoked do |options|
      Puppet::ModuleTool.set_option_defaults(options)
      environment = options[:environment_instance]
      modules_by_path = environment.modules_by_path

      {
        :environment => environment,
        :modules_by_path => modules_by_path,
        :unmet_dependencies => unmet_dependencies(environment),
      }
    end

    when_rendering :console do |result, options|
      environment     = result[:environment]
      modules_by_path = result[:modules_by_path]

      output = ''.dup

      warn_unmet_dependencies(environment)

      environment.modulepath.each do |path|
        modules = modules_by_path[path]
        no_mods = modules.empty? ? _(' (no modules installed)') : ''
        output << "#{path}#{no_mods}\n"

        if options[:tree]
          # The modules with fewest things depending on them will be the
          # parent of the tree.  Can't assume to start with 0 dependencies
          # since dependencies may be cyclical.
          modules_by_num_requires = modules.sort_by { |m| m.required_by.size }
          @seen = {}
          tree = list_build_tree(modules_by_num_requires, [], nil,
                                 :label_unmet => true, :path => path, :label_invalid => false)
        else
          tree = []
          modules.sort_by { |mod| mod.forge_name or mod.name }.each do |mod|
            tree << list_build_node(mod, path, :label_unmet => false,
                                               :path => path, :label_invalid => true)
          end
        end

        output << Puppet::ModuleTool.format_tree(tree)
      end

      output
    end
  end

  def unmet_dependencies(environment)
    error_types = [:non_semantic_version, :version_mismatch, :missing]

    unmet_deps = {}
    error_types.each do |type|
      unmet_deps[type] = Hash.new do |hash, key|
        hash[key] = { :errors => [], :parent => nil }
      end
    end

    # Prepare the unmet dependencies for display on the console.
    environment.modules.sort_by(&:name).each do |mod|
      unmet_grouped = Hash.new { |h, k| h[k] = [] }
      unmet_grouped = mod.unmet_dependencies.each_with_object(unmet_grouped) do |dep, acc|
        acc[dep[:reason]] << dep
      end
      unmet_grouped.each do |type, deps|
        next if deps.empty?

        unmet_grouped[type].sort_by { |dep| dep[:name] }.each do |dep|
          dep_name           = dep[:name].tr('/', '-')
          installed_version  = dep[:mod_details][:installed_version]
          version_constraint = dep[:version_constraint]
          parent_name        = dep[:parent][:name].tr('/', '-')
          parent_version     = dep[:parent][:version]

          msg = _("'%{parent_name}' (%{parent_version}) requires '%{dependency_name}' (%{dependency_version})") % { parent_name: parent_name, parent_version: parent_version, dependency_name: dep_name, dependency_version: version_constraint }
          unmet_deps[type][dep[:name]][:errors] << msg
          unmet_deps[type][dep[:name]][:parent] = {
            :name => dep[:parent][:name],
            :version => parent_version
          }
          unmet_deps[type][dep[:name]][:version] = installed_version
        end
      end
    end
    unmet_deps
  end

  def warn_unmet_dependencies(environment)
    @unmet_deps = unmet_dependencies(environment)

    # Display unmet dependencies by category.
    error_display_order = [:non_semantic_version, :version_mismatch, :missing]
    error_display_order.each do |type|
      next if @unmet_deps[type].empty?

      @unmet_deps[type].keys.sort.each do |dep|
        name    = dep.tr('/', '-')
        errors  = @unmet_deps[type][dep][:errors]
        version = @unmet_deps[type][dep][:version]

        msg = case type
              when :version_mismatch
                _("Module '%{name}' (v%{version}) fails to meet some dependencies:\n") % { name: name, version: version }
              when :non_semantic_version
                _("Non semantic version dependency %{name} (v%{version}):\n") % { name: name, version: version }
              else
                _("Missing dependency '%{name}':\n") % { name: name }
              end

        errors.each { |error_string| msg << "  #{error_string}\n" }
        Puppet.warning msg.chomp
      end
    end
  end

  # Prepare a list of module objects and their dependencies for print in a
  # tree view.
  #
  # Returns an Array of Hashes
  #
  # Example:
  #
  #   [
  #     {
  #       :text => "puppetlabs-bacula (v0.0.2)",
  #       :dependencies=> [
  #         { :text => "puppetlabs-stdlib (v2.2.1)", :dependencies => [] },
  #         {
  #           :text => "puppetlabs-mysql (v1.0.0)"
  #           :dependencies => [
  #             {
  #               :text => "bodepd-create_resources (v0.0.1)",
  #               :dependencies => []
  #             }
  #           ]
  #         },
  #         { :text => "puppetlabs-sqlite (v0.0.1)", :dependencies => [] },
  #       ]
  #     }
  #   ]
  #
  # When the above data structure is passed to Puppet::ModuleTool.build_tree
  # you end up with something like this:
  #
  #   /etc/puppetlabs/code/modules
  #   └─┬ puppetlabs-bacula (v0.0.2)
  #     ├── puppetlabs-stdlib (v2.2.1)
  #     ├─┬ puppetlabs-mysql (v1.0.0)
  #     │ └── bodepd-create_resources (v0.0.1)
  #     └── puppetlabs-sqlite (v0.0.1)
  #
  def list_build_tree(list, ancestors = [], parent = nil, params = {})
    list.filter_map do |mod|
      next if @seen[(mod.forge_name or mod.name)]

      node = list_build_node(mod, parent, params)
      @seen[(mod.forge_name or mod.name)] = true

      unless ancestors.include?(mod)
        node[:dependencies] ||= []
        missing_deps = mod.unmet_dependencies.select do |dep|
          dep[:reason] == :missing
        end
        missing_deps.map do |mis_mod|
          str = "#{colorize(:bg_red, _('UNMET DEPENDENCY'))} #{mis_mod[:name].tr('/', '-')} "
          str << "(#{colorize(:cyan, mis_mod[:version_constraint])})"
          node[:dependencies] << { :text => str }
        end
        node[:dependencies] += list_build_tree(mod.dependencies_as_modules,
                                               ancestors + [mod], mod, params)
      end

      node
    end
  end

  # Prepare a module object for print in a tree view.  Each node in the tree
  # must be a Hash in the following format:
  #
  #    { :text => "puppetlabs-mysql (v1.0.0)" }
  #
  # The value of a module's :text is affected by three (3) factors: the format
  # of the tree, its dependency status, and the location in the modulepath
  # relative to its parent.
  #
  # Returns a Hash
  #
  def list_build_node(mod, parent, params)
    str = ''.dup
    str << (mod.forge_name ? mod.forge_name.tr('/', '-') : mod.name)
    str << ' (' + colorize(:cyan, mod.version ? "v#{mod.version}" : '???') + ')'

    unless File.dirname(mod.path) == params[:path]
      str << " [#{File.dirname(mod.path)}]"
    end

    if @unmet_deps[:version_mismatch].include?(mod.forge_name)
      if params[:label_invalid]
        str << '  ' + colorize(:red, _('invalid'))
      elsif parent.respond_to?(:forge_name)
        unmet_parent = @unmet_deps[:version_mismatch][mod.forge_name][:parent]
        if unmet_parent[:name] == parent.forge_name &&
           unmet_parent[:version] == "v#{parent.version}"
          str << '  ' + colorize(:red, _('invalid'))
        end
      end
    end

    { :text => str }
  end
end
