# frozen_string_literal: true

require_relative '../puppet/util/logging'
require_relative 'module/task'
require_relative 'module/plan'
require_relative '../puppet/util/json'
require 'semantic_puppet/gem_version'

# Support for modules
class Puppet::Module
  class Error < Puppet::Error; end
  class MissingModule < Error; end
  class IncompatibleModule < Error; end
  class UnsupportedPlatform < Error; end
  class IncompatiblePlatform < Error; end
  class MissingMetadata < Error; end
  class FaultyMetadata < Error; end
  class InvalidName < Error; end
  class InvalidFilePattern < Error; end

  include Puppet::Util::Logging

  FILETYPES = {
    "manifests" => "manifests",
    "files" => "files",
    "templates" => "templates",
    "plugins" => "lib",
    "pluginfacts" => "facts.d",
    "locales" => "locales",
    "scripts" => "scripts",
  }

  # Find and return the +module+ that +path+ belongs to. If +path+ is
  # absolute, or if there is no module whose name is the first component
  # of +path+, return +nil+
  def self.find(modname, environment = nil)
    return nil unless modname

    # Unless a specific environment is given, use the current environment
    env = environment ? Puppet.lookup(:environments).get!(environment) : Puppet.lookup(:current_environment)
    env.module(modname)
  end

  def self.is_module_directory?(name, path)
    # it must be a directory
    fullpath = File.join(path, name)
    return false unless Puppet::FileSystem.directory?(fullpath)

    is_module_directory_name?(name)
  end

  def self.is_module_directory_name?(name)
    # it must match an installed module name according to forge validator
    return true if name =~ /^[a-z][a-z0-9_]*$/

    false
  end

  def self.is_module_namespaced_name?(name)
    # it must match the full module name according to forge validator
    return true if name =~ /^[a-zA-Z0-9]+-[a-z][a-z0-9_]*$/

    false
  end

  # @api private
  def self.parse_range(range)
    SemanticPuppet::VersionRange.parse(range)
  end

  attr_reader :name, :environment, :path, :metadata
  attr_writer :environment

  attr_accessor :dependencies, :forge_name
  attr_accessor :source, :author, :version, :license, :summary, :description, :project_page

  def initialize(name, path, environment)
    @name = name
    @path = path
    @environment = environment

    assert_validity
    load_metadata

    @absolute_path_to_manifests = Puppet::FileSystem::PathPattern.absolute(manifests)
  end

  # @deprecated The puppetversion module metadata field is no longer used.
  def puppetversion
    nil
  end

  # @deprecated The puppetversion module metadata field is no longer used.
  def puppetversion=(something)
  end

  # @deprecated The puppetversion module metadata field is no longer used.
  def validate_puppet_version
    nil
  end

  def has_metadata?
    load_metadata
    @metadata.is_a?(Hash) && !@metadata.empty?
  rescue Puppet::Module::MissingMetadata
    false
  end

  FILETYPES.each do |type, location|
    # A boolean method to let external callers determine if
    # we have files of a given type.
    define_method(type + '?') do
      type_subpath = subpath(location)
      unless Puppet::FileSystem.exist?(type_subpath)
        Puppet.debug { "No #{type} found in subpath '#{type_subpath}' (file / directory does not exist)" }
        return false
      end

      true
    end

    # A method for returning a given file of a given type.
    # e.g., file = mod.manifest("my/manifest.pp")
    #
    # If the file name is nil, then the base directory for the
    # file type is passed; this is used for fileserving.
    define_method(type.sub(/s$/, '')) do |file|
      # If 'file' is nil then they're asking for the base path.
      # This is used for things like fileserving.
      if file
        full_path = File.join(subpath(location), file)
      else
        full_path = subpath(location)
      end

      return nil unless Puppet::FileSystem.exist?(full_path)

      full_path
    end

    # Return the base directory for the given type
    define_method(type) do
      subpath(location)
    end
  end

  def tasks_directory
    subpath("tasks")
  end

  def tasks
    return @tasks if instance_variable_defined?(:@tasks)

    if Puppet::FileSystem.exist?(tasks_directory)
      @tasks = Puppet::Module::Task.tasks_in_module(self)
    else
      @tasks = []
    end
  end

  # This is a re-implementation of the Filetypes singular type method (e.g.
  # `manifest('my/manifest.pp')`. We don't implement the full filetype "API" for
  # tasks since tasks don't map 1:1 onto files.
  def task_file(name)
    # If 'file' is nil then they're asking for the base path.
    # This is used for things like fileserving.
    if name
      full_path = File.join(tasks_directory, name)
    else
      full_path = tasks_directory
    end

    if Puppet::FileSystem.exist?(full_path)
      full_path
    else
      nil
    end
  end

  def plans_directory
    subpath("plans")
  end

  def plans
    return @plans if instance_variable_defined?(:@plans)

    if Puppet::FileSystem.exist?(plans_directory)
      @plans = Puppet::Module::Plan.plans_in_module(self)
    else
      @plans = []
    end
  end

  # This is a re-implementation of the Filetypes singular type method (e.g.
  # `manifest('my/manifest.pp')`. We don't implement the full filetype "API" for
  # plans.
  def plan_file(name)
    # If 'file' is nil then they're asking for the base path.
    # This is used for things like fileserving.
    if name
      full_path = File.join(plans_directory, name)
    else
      full_path = plans_directory
    end

    if Puppet::FileSystem.exist?(full_path)
      full_path
    else
      nil
    end
  end

  def license_file
    return @license_file if defined?(@license_file)

    return @license_file = nil unless path

    @license_file = File.join(path, "License")
  end

  def read_metadata
    md_file = metadata_file
    return {} if md_file.nil?

    content = File.read(md_file, :encoding => 'utf-8')
    content.empty? ? {} : Puppet::Util::Json.load(content)
  rescue Errno::ENOENT
    {}
  rescue Puppet::Util::Json::ParseError => e
    # TRANSLATORS 'metadata.json' is a specific file name and should not be translated.
    msg = _("%{name} has an invalid and unparsable metadata.json file. The parse error: %{error}") % { name: name, error: e.message }
    case Puppet[:strict]
    when :off
      Puppet.debug(msg)
    when :warning
      Puppet.warning(msg)
    when :error
      raise FaultyMetadata, msg
    end
    {}
  end

  def load_metadata
    return if instance_variable_defined?(:@metadata)

    @metadata = data = read_metadata
    return if data.empty?

    @forge_name = data['name'].tr('-', '/') if data['name']

    [:source, :author, :version, :license, :dependencies].each do |attr|
      value = data[attr.to_s]
      raise MissingMetadata, "No #{attr} module metadata provided for #{name}" if value.nil?

      if attr == :dependencies
        unless value.is_a?(Array)
          raise MissingMetadata, "The value for the key dependencies in the file metadata.json of the module #{name} must be an array, not: '#{value}'"
        end

        value.each do |dep|
          name = dep['name']
          dep['name'] = name.tr('-', '/') unless name.nil?
          dep['version_requirement'] ||= '>= 0.0.0'
        end
      end

      send(attr.to_s + "=", value)
    end
  end

  # Return the list of manifests matching the given glob pattern,
  # defaulting to 'init.pp' for empty modules.
  def match_manifests(rest)
    if rest
      wanted_manifests = wanted_manifests_from(rest)
      searched_manifests = wanted_manifests.glob.reject { |f| FileTest.directory?(f) }
    else
      searched_manifests = []
    end

    # (#4220) Always ensure init.pp in case class is defined there.
    init_manifest = manifest("init.pp")
    if !init_manifest.nil? && !searched_manifests.include?(init_manifest)
      searched_manifests.unshift(init_manifest)
    end
    searched_manifests
  end

  def all_manifests
    return [] unless Puppet::FileSystem.exist?(manifests)

    Dir.glob(File.join(manifests, '**', '*.pp'))
  end

  def metadata_file
    return @metadata_file if defined?(@metadata_file)

    return @metadata_file = nil unless path

    @metadata_file = File.join(path, "metadata.json")
  end

  def hiera_conf_file
    unless defined?(@hiera_conf_file)
      @hiera_conf_file = path.nil? ? nil : File.join(path, Puppet::Pops::Lookup::HieraConfig::CONFIG_FILE_NAME)
    end
    @hiera_conf_file
  end

  def has_hiera_conf?
    hiera_conf_file.nil? ? false : Puppet::FileSystem.exist?(hiera_conf_file)
  end

  def modulepath
    File.dirname(path) if path
  end

  # Find all plugin directories.  This is used by the Plugins fileserving mount.
  def plugin_directory
    subpath("lib")
  end

  def plugin_fact_directory
    subpath("facts.d")
  end

  # @return [String]
  def locale_directory
    subpath("locales")
  end

  # Returns true if the module has translation files for the
  # given locale.
  # @param [String] locale the two-letter language code to check
  #        for translations
  # @return true if the module has a directory for the locale, false
  #         false otherwise
  def has_translations?(locale)
    Puppet::FileSystem.exist?(File.join(locale_directory, locale))
  end

  def has_external_facts?
    File.directory?(plugin_fact_directory)
  end

  def supports(name, version = nil)
    @supports ||= []
    @supports << [name, version]
  end

  def to_s
    result = "Module #{name}"
    result += "(#{path})" if path
    result
  end

  def dependencies_as_modules
    dependent_modules = []
    dependencies and dependencies.each do |dep|
      _, dep_name = dep["name"].split('/')
      found_module = environment.module(dep_name)
      dependent_modules << found_module if found_module
    end

    dependent_modules
  end

  def required_by
    environment.module_requirements[forge_name] || {}
  end

  # Identify and mark unmet dependencies.  A dependency will be marked unmet
  # for the following reasons:
  #
  #   * not installed and is thus considered missing
  #   * installed and does not meet the version requirements for this module
  #   * installed and doesn't use semantic versioning
  #
  # Returns a list of hashes representing the details of an unmet dependency.
  #
  # Example:
  #
  #   [
  #     {
  #       :reason => :missing,
  #       :name   => 'puppetlabs-mysql',
  #       :version_constraint => 'v0.0.1',
  #       :mod_details => {
  #         :installed_version => '0.0.1'
  #       }
  #       :parent => {
  #         :name    => 'puppetlabs-bacula',
  #         :version => 'v1.0.0'
  #       }
  #     }
  #   ]
  #
  def unmet_dependencies
    unmet_dependencies = []
    return unmet_dependencies unless dependencies

    dependencies.each do |dependency|
      name = dependency['name']
      version_string = dependency['version_requirement'] || '>= 0.0.0'

      dep_mod = begin
        environment.module_by_forge_name(name)
      rescue
        nil
      end

      error_details = {
        :name => name,
        :version_constraint => version_string.gsub(/^(?=\d)/, "v"),
        :parent => {
          :name => forge_name,
          :version => version.gsub(/^(?=\d)/, "v")
        },
        :mod_details => {
          :installed_version => dep_mod.nil? ? nil : dep_mod.version
        }
      }

      unless dep_mod
        error_details[:reason] = :missing
        unmet_dependencies << error_details
        next
      end

      next unless version_string

      begin
        required_version_semver_range = self.class.parse_range(version_string)
        actual_version_semver = SemanticPuppet::Version.parse(dep_mod.version)
      rescue ArgumentError
        error_details[:reason] = :non_semantic_version
        unmet_dependencies << error_details
        next
      end

      next if required_version_semver_range.include? actual_version_semver

      error_details[:reason] = :version_mismatch
      unmet_dependencies << error_details
      next
    end

    unmet_dependencies
  end

  def ==(other)
    name == other.name &&
      version == other.version &&
      path == other.path &&
      environment == other.environment
  end

  private

  def wanted_manifests_from(pattern)
    begin
      extended = File.extname(pattern).empty? ? "#{pattern}.pp" : pattern
      relative_pattern = Puppet::FileSystem::PathPattern.relative(extended)
    rescue Puppet::FileSystem::PathPattern::InvalidPattern => error
      raise Puppet::Module::InvalidFilePattern.new(
        "The pattern \"#{pattern}\" to find manifests in the module \"#{name}\" " \
        "is invalid and potentially unsafe.", error
      )
    end

    relative_pattern.prefix_with(@absolute_path_to_manifests)
  end

  def subpath(type)
    File.join(path, type)
  end

  def assert_validity
    if !Puppet::Module.is_module_directory_name?(@name) && !Puppet::Module.is_module_namespaced_name?(@name)
      raise InvalidName, _(<<-ERROR_STRING).chomp % { name: @name }
        Invalid module name '%{name}'; module names must match either:
        An installed module name (ex. modulename) matching the expression /^[a-z][a-z0-9_]*$/ -or-
        A namespaced module name (ex. author-modulename) matching the expression /^[a-zA-Z0-9]+[-][a-z][a-z0-9_]*$/
      ERROR_STRING
    end
  end
end
