# frozen_string_literal: true

# A module to make logging a bit easier.
require_relative '../../puppet/util/log'
require_relative '../../puppet/error'

module Puppet::Util
module Logging
  def send_log(level, message)
    Puppet::Util::Log.create({ :level => level, :source => log_source, :message => message }.merge(log_metadata))
  end

  # Create a method for each log level.
  Puppet::Util::Log.eachlevel do |level|
    # handle debug a special way for performance reasons
    next if level == :debug

    define_method(level) do |args|
      args = args.join(" ") if args.is_a?(Array)
      send_log(level, args)
    end
  end

  # Output a debug log message if debugging is on (but only then)
  # If the output is anything except a static string, give the debug
  # a block - it will be called with all other arguments, and is expected
  # to return the single string result.
  #
  # Use a block at all times for increased performance.
  #
  # @example This takes 40% of the time compared to not using a block
  #  Puppet.debug { "This is a string that interpolated #{x} and #{y} }"
  #
  def debug(*args)
    return nil unless Puppet::Util::Log.level == :debug

    if block_given?
      send_log(:debug, yield(*args))
    else
      send_log(:debug, args.join(" "))
    end
  end

  # Log an exception via Puppet.err.  Will also log the backtrace if Puppet[:trace] is set.
  # Parameters:
  # [exception] an Exception to log
  # [message] an optional String overriding the message to be logged; by default, we log Exception.message.
  #    If you pass a String here, your string will be logged instead.  You may also pass nil if you don't
  #    wish to log a message at all; in this case it is likely that you are only calling this method in order
  #    to take advantage of the backtrace logging.
  def log_exception(exception, message = :default, options = {})
    level = options[:level] || :err
    combined_trace = Puppet[:trace] || options[:trace]
    puppet_trace = Puppet[:puppet_trace] || options[:puppet_trace]

    if message == :default && exception.is_a?(Puppet::ParseErrorWithIssue)
      # Retain all detailed info and keep plain message and stacktrace separate
      backtrace = build_exception_trace(exception, combined_trace, puppet_trace)
      Puppet::Util::Log.create({
        :level => level,
        :source => log_source,
        :message => exception.basic_message,
        :issue_code => exception.issue_code,
        :backtrace => backtrace.empty? ? nil : backtrace,
        :file => exception.file,
        :line => exception.line,
        :pos => exception.pos,
        :environment => exception.environment,
        :node => exception.node
      }.merge(log_metadata))
    else
      send_log(level, format_exception(exception, message, combined_trace, puppet_trace))
    end
  end

  def build_exception_trace(exception, combined_trace = true, puppet_trace = false)
    built_trace = format_backtrace(exception, combined_trace, puppet_trace)

    if exception.respond_to?(:original)
      original = exception.original
      unless original.nil?
        built_trace << _('Wrapped exception:')
        built_trace << original.message
        built_trace += build_exception_trace(original, combined_trace, puppet_trace)
      end
    end

    built_trace
  end
  private :build_exception_trace

  def format_exception(exception, message = :default, combined_trace = true, puppet_trace = false)
    arr = []
    case message
    when :default
      arr << exception.message
    when nil
      # don't log anything if they passed a nil; they are just calling for the optional backtrace logging
    else
      arr << message
    end

    arr += format_backtrace(exception, combined_trace, puppet_trace)

    if exception.respond_to?(:original) and exception.original
      arr << _("Wrapped exception:")
      arr << format_exception(exception.original, :default, combined_trace, puppet_trace)
    end

    arr.flatten.join("\n")
  end

  def format_backtrace(exception, combined_trace, puppet_trace)
    puppetstack = exception.respond_to?(:puppetstack) ? exception.puppetstack : []

    if combined_trace and exception.backtrace
      Puppet::Util.format_backtrace_array(exception.backtrace, puppetstack)
    elsif puppet_trace && !puppetstack.empty?
      Puppet::Util.format_backtrace_array(puppetstack)
    else
      []
    end
  end

  def log_and_raise(exception, message)
    log_exception(exception, message)
    raise exception, message + "\n" + exception.to_s, exception.backtrace
  end

  class DeprecationWarning < Exception; end # rubocop:disable Lint/InheritException

  # Logs a warning indicating that the Ruby code path is deprecated.  Note that
  # this method keeps track of the offending lines of code that triggered the
  # deprecation warning, and will only log a warning once per offending line of
  # code.  It will also stop logging deprecation warnings altogether after 100
  # unique deprecation warnings have been logged.  Finally, if
  # Puppet[:disable_warnings] includes 'deprecations', it will squelch all
  # warning calls made via this method.
  #
  # @param message [String] The message to log (logs via warning)
  # @param key [String] Optional key to mark the message as unique. If not
  #   passed in, the originating call line will be used instead.
  def deprecation_warning(message, key = nil)
    issue_deprecation_warning(message, key, nil, nil, true)
  end

  # Logs a warning whose origin comes from Puppet source rather than somewhere
  # internal within Puppet.  Otherwise the same as deprecation_warning()
  #
  # @param message [String] The message to log (logs via warning)
  # @param options [Hash]
  # @option options [String] :file File we are warning from
  # @option options [Integer] :line Line number we are warning from
  # @option options [String] :key (:file + :line) Alternative key used to mark
  #   warning as unique
  #
  # Either :file and :line and/or :key must be passed.
  def puppet_deprecation_warning(message, options = {})
    key = options[:key]
    file = options[:file]
    line = options[:line]
    # TRANSLATORS the literals ":file", ":line", and ":key" should not be translated
    raise Puppet::DevError, _("Need either :file and :line, or :key") if key.nil? && (file.nil? || line.nil?)

    key ||= "#{file}:#{line}"
    issue_deprecation_warning(message, key, file, line, false)
  end

  # Logs a (non deprecation) warning once for a given key.
  #
  # @param kind [String] The kind of warning. The
  #   kind must be one of the defined kinds for the Puppet[:disable_warnings] setting.
  # @param message [String] The message to log (logs via warning)
  # @param key [String] Key used to make this warning unique
  # @param file [String,:default,nil] the File related to the warning
  # @param line [Integer,:default,nil] the Line number related to the warning
  #   warning as unique
  # @param level [Symbol] log level to use, defaults to :warning
  #
  # Either :file and :line and/or :key must be passed.
  def warn_once(kind, key, message, file = nil, line = nil, level = :warning)
    return if Puppet[:disable_warnings].include?(kind)

    $unique_warnings ||= {}
    if $unique_warnings.length < 100 then
      unless $unique_warnings.has_key?(key) then
        $unique_warnings[key] = message
        call_trace = if file == :default and line == :default
                       # Suppress the file and line number output
                       ''
                     else
                       error_location_str = Puppet::Util::Errors.error_location(file, line)
                       if error_location_str.empty?
                         "\n   " + _('(file & line not available)')
                       else
                         "\n   %{error_location}" % { error_location: error_location_str }
                       end
                     end
        send_log(level, "#{message}#{call_trace}")
      end
    end
  end

  def get_deprecation_offender
    # we have to put this in its own method to simplify testing; we need to be able to mock the offender results in
    # order to test this class, and our framework does not appear to enjoy it if you try to mock Kernel.caller
    #
    # let's find the offending line;  we need to jump back up the stack a few steps to find the method that called
    #  the deprecated method
    if Puppet[:trace]
      caller(3)
    else
      [caller(3, 1).first]
    end
  end

  def clear_deprecation_warnings
    $unique_warnings.clear if $unique_warnings
    $deprecation_warnings.clear if $deprecation_warnings
  end

  # TODO: determine whether there might be a potential use for adding a puppet configuration option that would
  # enable this deprecation logging.

  # utility method that can be called, e.g., from spec_helper config.after, when tracking down calls to deprecated
  # code.
  # Parameters:
  # [deprecations_file] relative or absolute path of a file to log the deprecations to
  # [pattern] (default nil) if specified, will only log deprecations whose message matches the provided pattern
  def log_deprecations_to_file(deprecations_file, pattern = nil)
    # this method may get called lots and lots of times (e.g., from spec_helper config.after) without the global
    # list of deprecation warnings being cleared out.  We don't want to keep logging the same offenders over and over,
    # so, we need to keep track of what we've logged.
    #
    # It'd be nice if we could just clear out the list of deprecation warnings, but then the very next spec might
    # find the same offender, and we'd end up logging it again.
    $logged_deprecation_warnings ||= {}

    # Deprecation messages are UTF-8 as they are produced by Ruby
    Puppet::FileSystem.open(deprecations_file, nil, "a:UTF-8") do |f|
      if $deprecation_warnings then
        $deprecation_warnings.each do |offender, message|
          next if $logged_deprecation_warnings.has_key?(offender)

          $logged_deprecation_warnings[offender] = true
          next unless pattern.nil? || (message =~ pattern)

          f.puts(message)
          f.puts(offender)
          f.puts()
        end
      end
    end
  end

  # Sets up Facter logging.
  # This method causes Facter output to be forwarded to Puppet.
  def self.setup_facter_logging!
    Puppet.runtime[:facter]
    true
  end

  private

  def issue_deprecation_warning(message, key, file, line, use_caller)
    return if Puppet[:disable_warnings].include?('deprecations')

    $deprecation_warnings ||= {}
    if $deprecation_warnings.length < 100
      key ||= (offender = get_deprecation_offender)
      unless $deprecation_warnings.has_key?(key)
        $deprecation_warnings[key] = message
        # split out to allow translation
        call_trace = if use_caller
                       _("(location: %{location})") % { location: (offender || get_deprecation_offender).join('; ') }
                     else
                       Puppet::Util::Errors.error_location_with_unknowns(file, line)
                     end
        warning("%{message}\n   %{call_trace}" % { message: message, call_trace: call_trace })
      end
    end
  end

  def is_resource?
    defined?(Puppet::Type) && is_a?(Puppet::Type)
  end

  def is_resource_parameter?
    defined?(Puppet::Parameter) && is_a?(Puppet::Parameter)
  end

  def log_metadata
    [:file, :line, :tags].each_with_object({}) do |attr, result|
      result[attr] = send(attr) if respond_to?(attr)
    end
  end

  def log_source
    # We need to guard the existence of the constants, since this module is used by the base Puppet module.
    (is_resource? or is_resource_parameter?) and respond_to?(:path) and return path.to_s
    to_s
  end
end
end
