# encoding: UTF-8
# frozen_string_literal: true

# Load standard libraries
require 'pathname'
require 'fileutils'
require_relative '../puppet/util/colors'

module Puppet
  module ModuleTool
    require_relative 'module_tool/tar'
    extend Puppet::Util::Colors

    # Directory and names that should not be checksummed.
    ARTIFACTS = ['pkg', /^\./, /^~/, /^#/, 'coverage', 'checksums.json', 'REVISION']
    FULL_MODULE_NAME_PATTERN = %r{\A([^-/|.]+)[-|/](.+)\z}
    REPOSITORY_URL = Puppet.settings[:module_repository]

    # Is this a directory that shouldn't be checksummed?
    #
    # TODO: Should this be part of Checksums?
    # TODO: Rename this method to reflect its purpose?
    # TODO: Shouldn't this be used when building packages too?
    def self.artifact?(path)
      case File.basename(path)
      when *ARTIFACTS
        true
      else
        false
      end
    end

    # Return the +username+ and +modname+ for a given +full_module_name+, or raise an
    # ArgumentError if the argument isn't parseable.
    def self.username_and_modname_from(full_module_name)
      matcher = full_module_name.match(FULL_MODULE_NAME_PATTERN)
      if matcher
        matcher.captures
      else
        raise ArgumentError, _("Not a valid full name: %{full_module_name}") % { full_module_name: full_module_name }
      end
    end

    # Find the module root when given a path by checking each directory up from
    # its current location until it finds one that satisfies is_module_root?
    #
    # @param path [Pathname, String] path to start from
    # @return [Pathname, nil] the root path of the module directory or nil if
    #   we cannot find one
    def self.find_module_root(path)
      path = Pathname.new(path) if path.instance_of?(String)

      path.expand_path.ascend do |p|
        return p if is_module_root?(p)
      end

      nil
    end

    # Analyse path to see if it is a module root directory by detecting a
    # file named 'metadata.json'
    #
    # @param path [Pathname, String] path to analyse
    # @return [Boolean] true if the path is a module root, false otherwise
    def self.is_module_root?(path)
      path = Pathname.new(path) if path.instance_of?(String)

      FileTest.file?(path + 'metadata.json')
    end

    # Builds a formatted tree from a list of node hashes containing +:text+
    # and +:dependencies+ keys.
    def self.format_tree(nodes, level = 0)
      str = ''.dup
      nodes.each_with_index do |node, i|
        last_node = nodes.length - 1 == i
        deps = node[:dependencies] || []

        str << (indent = "  " * level)
        str << (last_node ? "└" : "├")
        str << "─"
        str << (deps.empty? ? "─" : "┬")
        str << " #{node[:text]}\n"

        branch = format_tree(deps, level + 1)
        branch.gsub!(/^#{indent} /, indent + '│') unless last_node
        str << branch
      end

      str
    end

    def self.build_tree(mods, dir)
      mods.each do |mod|
        version_string = mod[:version].to_s.sub(/^(?!v)/, 'v')

        if mod[:action] == :upgrade
          previous_version = mod[:previous_version].to_s.sub(/^(?!v)/, 'v')
          version_string = "#{previous_version} -> #{version_string}"
        end

        mod[:text] = "#{mod[:name]} (#{colorize(:cyan, version_string)})"
        mod[:text] += " [#{mod[:path]}]" unless mod[:path].to_s == dir.to_s

        deps = mod[:dependencies] || []
        deps.sort_by! { |a| a[:name] }
        build_tree(deps, dir)
      end
    end

    # @param options [Hash<Symbol,String>] This hash will contain any
    #   command-line arguments that are not Settings, as those will have already
    #   been extracted by the underlying application code.
    #
    # @note Unfortunately the whole point of this method is the side effect of
    # modifying the options parameter.  This same hash is referenced both
    # when_invoked and when_rendering.  For this reason, we are not returning
    # a duplicate.
    # @todo Validate the above note...
    #
    # An :environment_instance and a :target_dir are added/updated in the
    # options parameter.
    #
    # @api private
    def self.set_option_defaults(options)
      current_environment = environment_from_options(options)

      modulepath = [options[:target_dir]] + current_environment.full_modulepath

      face_environment = current_environment.override_with(:modulepath => modulepath.compact)

      options[:environment_instance] = face_environment

      # Note: environment will have expanded the path
      options[:target_dir] = face_environment.full_modulepath.first
    end

    # Given a hash of options, we should discover or create a
    # {Puppet::Node::Environment} instance that reflects the provided options.
    #
    # Generally speaking, the `:modulepath` parameter should supersede all
    # others, the `:environment` parameter should follow after that, and we
    # should default to Puppet's current environment.
    #
    # @param options [{Symbol => Object}] the options to derive environment from
    # @return [Puppet::Node::Environment] the environment described by the options
    def self.environment_from_options(options)
      if options[:modulepath]
        path = options[:modulepath].split(File::PATH_SEPARATOR)
        Puppet::Node::Environment.create(:anonymous, path, '')
      elsif options[:environment].is_a?(Puppet::Node::Environment)
        options[:environment]
      elsif options[:environment]
        # This use of looking up an environment is correct since it honours
        # a request to get a particular environment via environment name.
        Puppet.lookup(:environments).get!(options[:environment])
      else
        Puppet.lookup(:current_environment)
      end
    end

    # Handles parsing of module dependency expressions into proper
    # {SemanticPuppet::VersionRange}s, including reasonable error handling.
    #
    # @param where [String] a description of the thing we're parsing the
    #        dependency expression for
    # @param dep [Hash] the dependency description to parse
    # @return [Array(String, SemanticPuppet::VersionRange, String)] a tuple of the
    #         dependent module's name, the version range dependency, and the
    #         unparsed range expression.
    def self.parse_module_dependency(where, dep)
      dep_name = dep['name'].tr('/', '-')
      range = dep['version_requirement'] || '>= 0.0.0'

      begin
        parsed_range = Module.parse_range(range)
      rescue ArgumentError => e
        Puppet.debug "Error in #{where} parsing dependency #{dep_name} (#{e.message}); using empty range."
        parsed_range = SemanticPuppet::VersionRange::EMPTY_RANGE
      end

      [dep_name, parsed_range, range]
    end
  end
end

# Load remaining libraries
require_relative 'module_tool/errors'
require_relative 'module_tool/applications'
require_relative 'module_tool/checksums'
require_relative 'module_tool/dependency'
require_relative 'module_tool/metadata'
require_relative '../puppet/forge/cache'
require_relative '../puppet/forge'
