# frozen_string_literal: true

require 'fluent/config/error'

module Fluent
  module Plugin
    # A simple stand-alone configurable mutator for systemd journal entries.
    #
    # Note regarding field mapping:
    # The input `field_map` option is meant to have a structure that is
    # intuative or logical for humans when declaring a field map.
    # {
    #   "<source_field1>" => "<new_field1>",
    #   "<source_field2>" => ["<new_field1>", "<new_field2>"]
    # }
    # Internally the inverse of the human-friendly field_map is
    # computed (and cached) upon object creation and used as a "mapped model"
    # {
    #   "<new_field1>" => ["<source_field1>", "<source_field2>"],
    #   "<new_field2>" => ["<source_field2>"]
    # }
    class SystemdEntryMutator
      Options = Struct.new(
        :field_map,
        :field_map_strict,
        :fields_lowercase,
        :fields_strip_underscores
      )

      def self.default_opts
        Options.new({}, false, false, false)
      end

      # Constructor keyword options (all other kwargs are ignored):
      # field_map - hash describing the desired field mapping in the form:
      #             {"<source_field>" => "<new_field>", ...}
      #             where `new_field` is a string or array of strings
      # field_map_strict - boolean if true will only include new fields
      #                    defined in `field_map`
      # fields_strip_underscores - boolean if true will strip all leading
      #                            underscores from non-mapped fields
      # fields_lowercase - boolean if true lowercase all non-mapped fields
      #
      # raises `Fluent::ConfigError` for invalid options
      def initialize(**options)
        @opts = options_from_hash(options)
        validate_options(@opts)
        @map = invert_field_map(@opts.field_map)
        @map_src_fields = @opts.field_map.keys
        @no_transform = @opts == self.class.default_opts
      end

      # Expose config state as read-only instance properties of the mutator.
      def method_missing(sym, *args)
        return @opts[sym] if @opts.members.include?(sym)
        super
      end

      def respond_to_missing?(sym, include_private = false)
        @opts.members.include?(sym) || super
      end

      # The main run method that performs all configured mutations, if any,
      # against a single journal entry. Returns the mutated entry hash.
      # entry - hash or `Systemd::Journal:Entry`
      def run(entry)
        return entry.to_h if @no_transform
        return map_fields(entry) if @opts.field_map_strict
        format_fields(entry, map_fields(entry))
      end

      # Run field mapping against a single journal entry. Returns the mutated
      # entry hash.
      # entry - hash or `Systemd::Journal:Entry`
      def map_fields(entry)
        @map.each_with_object({}) do |(cstm, sysds), mapped|
          vals = sysds.collect { |fld| entry[fld] }.compact
          next if vals.empty? # systemd field does not exist in source entry
          mapped[cstm] = join_if_needed(vals)
        end
      end

      # Run field formatting (mutations applied to all non-mapped fields)
      # against a single journal entry. Returns the mutated entry hash.
      # entry - hash or `Systemd::Journal:Entry`
      # mapped - Optional hash that represents a previously mapped entry to
      #          which the formatted fields will be added
      def format_fields(entry, mapped = nil)
        entry.each_with_object(mapped || {}) do |(fld, val), formatted_entry|
          # don't mess with explicitly mapped fields
          next if @map_src_fields.include?(fld)
          fld = format_field_name(fld)
          # account for mapping (appending) to an existing systemd field
          formatted_entry[fld] = join_if_needed([val, mapped[fld]])
        end
      end

      def warnings
        return [] unless field_map_strict && field_map.empty?
        '`field_map_strict` set to true with empty `field_map`, expect no fields'
      end

      private

      def join_if_needed(values)
        values.compact!
        return values.first if values.length == 1
        values.join(' ')
      end

      def format_field_name(name)
        name = name.gsub(/\A_+/, '') if @opts.fields_strip_underscores
        name = name.downcase if @opts.fields_lowercase
        name
      end

      # Returns a `SystemdEntryMutator::Options` struct derived from the
      # elements in the supplied hash merged with the option defaults
      def options_from_hash(opts)
        merged = self.class.default_opts
        merged.each_pair do |k, _|
          merged[k] = opts[k] if opts.key?(k)
        end
        merged
      end

      def validate_options(opts)
        validate_all_strings opts[:field_map].keys, '`field_map` keys must be strings'
        validate_all_strings opts[:field_map].values, '`field_map` values must be strings or an array of strings', true
        %i[field_map_strict fields_strip_underscores fields_lowercase].each do |opt|
          validate_boolean opts[opt], opt
        end
      end

      def validate_all_strings(arr, message, allow_nesting = false)
        valid = arr.all? do |value|
          value.is_a?(String) || allow_nesting && value.is_a?(Array) && value.all? { |key| key.is_a?(String) }
        end
        raise Fluent::ConfigError, message unless valid
      end

      def validate_boolean(value, name)
        raise Fluent::ConfigError, "`#{name}` must be boolean" unless [true, false].include?(value)
      end

      # Compute the inverse of a human friendly field map `field_map` which is what
      # the mutator uses for the actual mapping. The resulting structure for
      # the inverse field map hash is:
      # {"<new_field_name>" => ["<source_field_name>", ...], ...}
      def invert_field_map(field_map)
        invs = {}
        field_map.values.flatten.uniq.each do |cstm|
          sysds = field_map.select { |_, v| (v == cstm || v.include?(cstm)) }
          invs[cstm] = sysds.keys
        end
        invs
      end
    end
  end
end