require 'r10k/util/purgeable'

# This abstract base class implements an environment that can include module
# content
#
# @since 3.4.0
class R10K::Environment::WithModules < R10K::Environment::Base

  # @!attribute [r] moduledir
  #   @return [String] The directory to install environment-defined modules
  #     into (default: #{basedir}/modules)
  attr_reader :moduledir

  # Initialize the given environment.
  #
  # @param name [String] The unique name describing this environment.
  # @param basedir [String] The base directory where this environment will be created.
  # @param dirname [String] The directory name for this environment.
  # @param options [Hash] An additional set of options for this environment.
  #
  # @param options [String] :moduledir The path to install modules to
  # @param options [Hash] :modules Modules to add to the environment
  def initialize(name, basedir, dirname, options = {})
    super

    @all_modules = nil
    @managed_content = {}
    @modules = []
    @moduledir = case options[:moduledir]
                 when nil
                   File.join(@basedir, @dirname, 'modules')
                 when File.absolute_path(options[:moduledir])
                   options.delete(:moduledir)
                 else
                   File.join(@basedir, @dirname, options.delete(:moduledir))
                 end

    modhash = options.delete(:modules)
    load_modules(modhash) unless modhash.nil?
  end

  # @return [Array<R10K::Module::Base>] All modules associated with this environment.
  #   Modules may originate from either:
  #     - The r10k environment object
  #     - A Puppetfile in the environment's content
  def modules
    if @all_modules.nil?
      puppetfile_modules = super()
      @all_modules = @modules + puppetfile_modules
    end

    @all_modules
  end

  def module_conflicts?(mod_b)
    conflict = @modules.any? { |mod_a| mod_a.name == mod_b.name }
    return false unless conflict

    msg_vars = {src: mod_b.origin, name: mod_b.name}
    msg_error = _('Environment and %{src} both define the "%{name}" module' % msg_vars)
    msg_continue = _("#{msg_error}. The %{src} definition will be ignored" % msg_vars)

    case conflict_opt = @options[:module_conflicts]
    when 'override_and_warn', nil
      logger.warn msg_continue
    when 'override'
      logger.debug msg_continue
    when 'error'
      raise R10K::Error, msg_error
    else
      raise R10K::Error, _('Unexpected value for `module_conflicts` setting in %{env} ' \
                           'environment: %{val}' % {env: self.name, val: conflict_opt})
    end

    true
  end

  def accept(visitor)
    visitor.visit(:environment, self) do
      @modules.each do |mod|
        mod.sync
      end

      puppetfile.accept(visitor)
    end
  end

  def deploy
    @modules.each do |mod|
      mod.sync
    end

    super
  end

  def load_modules(module_hash)
    module_hash.each do |name, args|
      if !args.is_a?(Hash)
        args = { type: 'forge', version: args }
      end

      add_module(name, args)
    end
  end

  def resolve_path(base, dirname, path)
    if Pathname.new(path).absolute?
      cleanpath(path)
    else
      cleanpath(File.join(base, dirname, path))
    end
  end

  # .cleanpath is as good as we can do without touching the filesystem.
  # The .realpath methods will choke if some of the intermediate paths
  # are missing, even though in some cases we will create them later as
  # needed.
  def cleanpath(path)
    Pathname.new(path).cleanpath.to_s
  end

  def validate_install_path(path, modname)
    unless /^#{Regexp.escape(@basedir)}.*/ =~ path
      raise R10K::Error.new("Environment cannot manage content '#{modname}' outside of containing environment: #{path} is not within #{@basedir}")
    end
    true
  end

  # @param [String] name
  # @param [Hash] args
  def add_module(name, args)
    # symbolize keys in the args hash
    args = args.inject({}) { |memo,(k,v)| memo[k.to_sym] = v; memo }
    args[:overrides] = @overrides

    if install_path = args.delete(:install_path)
      install_path = resolve_path(@basedir, @dirname, install_path)
      validate_install_path(install_path, name)
    else
      install_path = @moduledir
    end

    # Keep track of all the content this environment is managing to enable purging.
    @managed_content[install_path] = Array.new unless @managed_content.has_key?(install_path)

    mod = R10K::Module.new(name, install_path, args, self.name)
    mod.origin = :environment

    @managed_content[install_path] << mod.name
    @modules << mod
  end

  include R10K::Util::Purgeable

  # Returns an array of the full paths of filenames that should exist. Files
  # inside managed_directories that are not listed in desired_contents will
  # be purged.
  # @note This implements a required method for the Purgeable mixin
  # @return [Array<String>]
  def desired_contents
    list = @managed_content.keys
    list += @managed_content.flat_map do |install_path, modnames|
      modnames.collect { |name| File.join(install_path, name) }
    end
  end

  def purge_exclusions
    super + @managed_content.flat_map do |install_path, modnames|
      modnames.map do |name|
        File.join(install_path, name, '**', '*')
      end
    end
  end
end
