#
#   Copyright 2011 Bryan Kearney <bkearney@redhat.com>
#
#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at
#
#       https://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.

require 'augeas' if Puppet.features.augeas?
require 'strscan'
require 'puppet/util'
require 'puppet/util/diff'
require 'puppet/util/package'

Puppet::Type.type(:augeas).provide(:augeas) do
  include Puppet::Util
  include Puppet::Util::Diff
  include Puppet::Util::Package

  confine :feature => :augeas

  has_features :parse_commands, :need_to_run?,:execute_changes

  SAVE_NOOP = "noop"
  SAVE_OVERWRITE = "overwrite"
  SAVE_NEWFILE = "newfile"
  SAVE_BACKUP = "backup"

  COMMANDS = {
    "set" => [ :path, :string ],
    "setm" => [ :path, :string, :string ],
    "rm" => [ :path ],
    "clear" => [ :path ],
    "clearm" => [ :path, :string ],
    "touch" => [ :path ],
    "mv" => [ :path, :path ],
    "rename" => [ :path, :string ],
    "insert" => [ :string, :string, :path ],
    "get" => [ :path, :comparator, :string ],
    "values" => [ :path, :glob ],
    "defvar" => [ :string, :path ],
    "defnode" => [ :string, :path, :string ],
    "match" => [ :path, :glob ],
    "size" => [:comparator, :int],
    "include" => [:string],
    "not_include" => [:string],
    "==" => [:glob],
    "!=" => [:glob]
  }

  COMMANDS["ins"] = COMMANDS["insert"]
  COMMANDS["remove"] = COMMANDS["rm"]
  COMMANDS["move"] = COMMANDS["mv"]

  attr_accessor :aug

  # Extracts an 2 dimensional array of commands which are in the
  # form of command path value.
  # The input can be
  # - A string with one command
  # - A string with many commands per line
  # - An array of strings.
  def parse_commands(data)
    context = resource[:context]
    # Add a trailing / if it is not there
    if (context.length > 0)
      context << "/" if context[-1, 1] != "/"
    end

    data = data.split($/) if data.is_a?(String)
    data = data.flatten
    args = []
    data.each do |line|
      line.strip!
      next if line.nil? || line.empty?
      argline = []
      sc = StringScanner.new(line)
      cmd = sc.scan(/\w+|==|!=/)
      formals = COMMANDS[cmd]
      fail(_("Unknown command %{cmd}") % { cmd: cmd }) unless formals
      argline << cmd
      narg = 0
      formals.each do |f|
        sc.skip(/\s+/)
        narg += 1
        if f == :path
          start = sc.pos
          nbracket = 0
          inSingleTick = false
          inDoubleTick = false
          begin
            sc.skip(/([^\]\[\s\\'"]|\\.)+/)
            ch = sc.getch
            nbracket += 1 if ch == "["
            nbracket -= 1 if ch == "]"
            inSingleTick = !inSingleTick if ch == "'"
            inDoubleTick = !inDoubleTick if ch == "\""
            fail(_("unmatched [")) if nbracket < 0
          end until ((nbracket == 0 && !inSingleTick && !inDoubleTick && (ch =~ /\s/)) || sc.eos?) # rubocop:disable Lint/Loop
            len = sc.pos - start
            len -= 1 unless sc.eos?
          unless p = sc.string[start, len]
            fail(_("missing path argument %{narg} for %{cmd}") % { narg: narg, cmd: cmd })
          end
          # Rip off any ticks if they are there.
          p = p[1, (p.size - 2)] if p[0,1] == "'" || p[0,1] == "\""
          p.chomp!("/")
          if p[0,1] != '$' && p[0,1] != "/"
            argline << context + p
          else
            argline << p
          end
        elsif f == :string
          delim = sc.peek(1)
          if delim == "'" || delim == "\""
            sc.getch
            argline << sc.scan(/([^\\#{delim}]|(\\.))*/)
            # Unescape the delimiter so it's actually possible to have a
            # literal delim inside the string. We only unescape the
            # delimeter and not every backslash-escaped character so that
            # things like escaped spaces '\ ' get passed through because
            # Augeas needs to see them. If we unescaped them, too, users
            # would be forced to double-escape them
            argline.last.gsub!(/\\(#{delim})/, '\1')
            sc.getch
          else
            argline << sc.scan(/[^\s]+/)
          end
          fail(_("missing string argument %{narg} for %{cmd}") % { narg: narg, cmd: cmd }) unless argline[-1]
        elsif f == :comparator
          argline << sc.scan(/(==|!=|=~|<=|>=|<|>)/)
          unless argline[-1]
            puts sc.rest
            fail(_("invalid comparator for command %{cmd}") % { cmd: cmd })
          end
        elsif f == :int
          argline << sc.scan(/\d+/).to_i
        elsif f== :glob
          argline << sc.rest
        end
      end
      args << argline
    end
    args
  end


  def open_augeas
    unless @aug
      flags = Augeas::NONE
      flags = Augeas::TYPE_CHECK if resource[:type_check] == :true

      if resource[:incl]
        flags |= Augeas::NO_MODL_AUTOLOAD
      else
        flags |= Augeas::NO_LOAD
      end

      root = resource[:root]
      load_path = get_load_path(resource)
      debug("Opening augeas with root #{root}, lens path #{load_path}, flags #{flags}")
      @aug = Augeas::open(root, load_path,flags)

      debug("Augeas version #{get_augeas_version} is installed") if versioncmp(get_augeas_version, "0.3.6") >= 0

      # Optimize loading if the context is given and it's a simple path,
      # requires the glob function from Augeas 0.8.2 or up
      glob_avail = !aug.match("/augeas/version/pathx/functions/glob").empty?
      opt_ctx = resource[:context].match("^/files/[^'\"\\[\\]]+$") if resource[:context]

      if resource[:incl]
        aug.set("/augeas/load/Xfm/lens", resource[:lens])
        aug.set("/augeas/load/Xfm/incl", resource[:incl])
        restricted_metadata = "/augeas//error"
      elsif glob_avail and opt_ctx
        # Optimize loading if the context is given, requires the glob function
        # from Augeas 0.8.2 or up
        ctx_path = resource[:context].sub(/^\/files(.*?)\/?$/, '\1/')
        load_path = "/augeas/load/*['%s' !~ glob(incl) + regexp('/.*')]" % ctx_path

        if aug.match(load_path).size < aug.match("/augeas/load/*").size
          aug.rm(load_path)
          restricted_metadata = "/augeas/files#{ctx_path}/error"
        else
          # This will occur if the context is less specific than any glob
          debug("Unable to optimize files loaded by context path, no glob matches")
        end
      end
      aug.load
      print_load_errors(restricted_metadata)
    end
    @aug
  end

  def close_augeas
    if @aug
      @aug.close
      debug("Closed the augeas connection")
      @aug = nil
    end
  end

  def is_numeric?(s)
    case s
    when Integer
      true
    when String
      s.match(/\A[+-]?\d+?(\.\d+)?\Z/n) == nil ? false : true
    else
      false
    end
  end

  # Used by the need_to_run? method to process get filters. Returns
  # true if there is a match, false if otherwise
  # Assumes a syntax of get /files/path [COMPARATOR] value
  def process_get(cmd_array)
    return_value = false

    #validate and tear apart the command
    fail (_("Invalid command: %{cmd}") % { cmd: cmd_array.join(" ") }) if cmd_array.length < 4
    _ = cmd_array.shift
    path = cmd_array.shift
    comparator = cmd_array.shift
    arg = cmd_array.join(" ")

    #check the value in augeas
    result = @aug.get(path) || ''

    if ['<', '<=', '>=', '>'].include? comparator and is_numeric?(result) and
                                                      is_numeric?(arg)
      resultf = result.to_f
      argf = arg.to_f
      return_value = (resultf.send(comparator, argf))
    elsif comparator == "!="
      return_value = (result != arg)
    elsif comparator == "=~"
      regex = Regexp.new(arg)
      return_value = (result =~ regex)
    else
      return_value = (result.send(comparator, arg))
    end
    !!return_value
  end

  # Used by the need_to_run? method to process values filters. Returns
  # true if there is a matched value, false if otherwise
  def process_values(cmd_array)
    return_value = false

    #validate and tear apart the command
    fail(_("Invalid command: %{cmd}") % { cmd: cmd_array.join(" ") }) if cmd_array.length < 3
    _ = cmd_array.shift
    path = cmd_array.shift

    # Need to break apart the clause
    clause_array = parse_commands(cmd_array.shift)[0]
    verb = clause_array.shift

    #Get the match paths from augeas
    result = @aug.match(path) || []
    fail(_("Error trying to get path '%{path}'") % { path: path }) if (result == -1)

    #Get the values of the match paths from augeas
    values = result.collect{|r| @aug.get(r)}

    case verb
    when "include"
      arg = clause_array.shift
      return_value = values.include?(arg)
    when "not_include"
      arg = clause_array.shift
      return_value = !values.include?(arg)
    when "=="
      begin
        arg = clause_array.shift
        new_array = to_array(arg)
        return_value = (values == new_array)
      rescue
        fail(_("Invalid array in command: %{cmd}") % { cmd: cmd_array.join(" ") })
      end
    when "!="
      begin
        arg = clause_array.shift
        new_array = to_array(arg)
        return_value = (values != new_array)
      rescue
        fail(_("Invalid array in command: %{cmd}") % { cmd: cmd_array.join(" ") })
      end
    end
    !!return_value
  end

  # Used by the need_to_run? method to process match filters. Returns
  # true if there is a match, false if otherwise
  def process_match(cmd_array)
    return_value = false

    #validate and tear apart the command
    fail(_("Invalid command: %{cmd}") % { cmd: cmd_array.join(" ") }) if cmd_array.length < 3
    _ = cmd_array.shift
    path = cmd_array.shift

    # Need to break apart the clause
    clause_array = parse_commands(cmd_array.shift)[0]
    verb = clause_array.shift

    #Get the values from augeas
    result = @aug.match(path) || []
    fail(_("Error trying to match path '%{path}'") % { path: path }) if (result == -1)

    # Now do the work
    case verb
    when "size"
      fail(_("Invalid command: %{cmd}") % { cmd: cmd_array.join(" ") }) if clause_array.length != 2
      comparator = clause_array.shift
      arg = clause_array.shift
      case comparator
      when "!="
        return_value = !(result.size.send(:==, arg))
      else
        return_value = (result.size.send(comparator, arg))
      end
    when "include"
      arg = clause_array.shift
      return_value = result.include?(arg)
    when "not_include"
      arg = clause_array.shift
      return_value = !result.include?(arg)
    when "=="
      begin
        arg = clause_array.shift
        new_array = to_array(arg)
        return_value = (result == new_array)
      rescue
        fail(_("Invalid array in command: %{cmd}") % { cmd: cmd_array.join(" ") })
      end
    when "!="
      begin
        arg = clause_array.shift
        new_array = to_array(arg)
        return_value = (result != new_array)
      rescue
        fail(_("Invalid array in command: %{cmd}") % { cmd: cmd_array.join(" ") })
      end
    end
    !!return_value
  end

  # Generate lens load paths from user given paths and local pluginsync dir
  def get_load_path(resource)
    load_path = []

    # Permits colon separated strings or arrays
    if resource[:load_path]
      load_path = [resource[:load_path]].flatten
      load_path.map! { |path| path.split(/:/) }
      load_path.flatten!
    end

    if Puppet::FileSystem.exist?("#{Puppet[:libdir]}/augeas/lenses")
      load_path << "#{Puppet[:libdir]}/augeas/lenses"
    end

    load_path.join(":")
  end

  def get_augeas_version
    @aug.get("/augeas/version") || ""
  end

  def set_augeas_save_mode(mode)
    @aug.set("/augeas/save", mode)
  end

  def print_load_errors(path)
    errors = @aug.match("/augeas//error")
    unless errors.empty?
      if path && !@aug.match(path).empty?
        warning(_("Loading failed for one or more files, see debug for /augeas//error output"))
      else
        debug("Loading failed for one or more files, output from /augeas//error:")
      end
    end
    print_errors(errors)
  end

  def print_put_errors
    errors = @aug.match("/augeas//error[. = 'put_failed']")
    debug("Put failed on one or more files, output from /augeas//error:") unless errors.empty?
    print_errors(errors)
  end

  def print_errors(errors)
    errors.each do |errnode|
      error = @aug.get(errnode)
      debug("#{errnode} = #{error}") unless error.nil?
      @aug.match("#{errnode}/*").each do |subnode|
        subvalue = @aug.get(subnode)
        debug("#{subnode} = #{subvalue}")
      end
    end
  end

  # Determines if augeas actually needs to run.
  def need_to_run?
    force = resource[:force]
    return_value = true
    begin
      open_augeas
      filter = resource[:onlyif]
      unless filter == ""
        cmd_array = parse_commands(filter)[0]
        command = cmd_array[0];
        begin
          case command
          when "get"; return_value = process_get(cmd_array)
          when "values"; return_value = process_values(cmd_array)
          when "match"; return_value = process_match(cmd_array)
          end
        rescue StandardError => e
          fail(_("Error sending command '%{command}' with params %{param}/%{message}") % { command: command, param: cmd_array[1..-1].inspect, message: e.message })
        end
      end

      unless force
        # If we have a version of augeas which is at least 0.3.6 then we
        # can make the changes now and see if changes were made.
        if return_value and versioncmp(get_augeas_version, "0.3.6") >= 0
          debug("Will attempt to save and only run if files changed")
          # Execute in NEWFILE mode so we can show a diff
          set_augeas_save_mode(SAVE_NEWFILE)
          do_execute_changes
          save_result = @aug.save
          unless save_result
            print_put_errors
            fail(_("Saving failed, see debug"))
          end

          saved_files = @aug.match("/augeas/events/saved")
          if saved_files.size > 0
            root = resource[:root].sub(/^\/$/, "")
            saved_files.map! {|key| @aug.get(key).sub(/^\/files/, root) }
            saved_files.uniq.each do |saved_file|
              if Puppet[:show_diff] && @resource[:show_diff]
                self.send(@resource[:loglevel], "\n" + diff(saved_file, saved_file + ".augnew"))
              end
              File.delete(saved_file + ".augnew")
            end
            debug("Files changed, should execute")
            return_value = true
          else
            debug("Skipping because no files were changed")
            return_value = false
          end
        end
      end
    ensure
      if not return_value or resource.noop? or not save_result
        close_augeas
      end
    end
    return_value
  end

  def execute_changes
    # Workaround Augeas bug where changing the save mode doesn't trigger a
    # reload of the previously saved file(s) when we call Augeas#load
    @aug.match("/augeas/events/saved").each do |file|
      @aug.rm("/augeas#{@aug.get(file)}/mtime")
    end

    # Reload augeas, and execute the changes for real
    set_augeas_save_mode(SAVE_OVERWRITE) if versioncmp(get_augeas_version, "0.3.6") >= 0
    @aug.load
    do_execute_changes
    unless @aug.save
      print_put_errors
      fail(_("Save failed, see debug"))
    end

    :executed
  ensure
    close_augeas
  end

  # Actually execute the augeas changes.
  def do_execute_changes
    commands = parse_commands(resource[:changes])
    commands.each do |cmd_array|
      fail(_("invalid command %{cmd}") % { value0: cmd_array.join[" "] }) if cmd_array.length < 2
      command = cmd_array[0]
      cmd_array.shift
      begin
        case command
          when "set"
            debug("sending command '#{command}' with params #{cmd_array.inspect}")
            rv = aug.set(cmd_array[0], cmd_array[1])
            fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (!rv)
          when "setm"
            if aug.respond_to?(command)
              debug("sending command '#{command}' with params #{cmd_array.inspect}")
              rv = aug.setm(cmd_array[0], cmd_array[1], cmd_array[2])
              fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (rv == -1)
            else
              fail(_("command '%{command}' not supported in installed version of ruby-augeas") % { command: command })
            end
          when "rm", "remove"
            debug("sending command '#{command}' with params #{cmd_array.inspect}")
            rv = aug.rm(cmd_array[0])
            fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (rv == -1)
          when "clear"
            debug("sending command '#{command}' with params #{cmd_array.inspect}")
            rv = aug.clear(cmd_array[0])
            fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (!rv)
          when "clearm"
            # Check command exists ... doesn't currently in ruby-augeas 0.4.1
            if aug.respond_to?(command)
              debug("sending command '#{command}' with params #{cmd_array.inspect}")
              rv = aug.clearm(cmd_array[0], cmd_array[1])
              fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (!rv)
            else
              fail(_("command '%{command}' not supported in installed version of ruby-augeas") % { command: command })
            end
          when "touch"
            debug("sending command '#{command}' (match, set) with params #{cmd_array.inspect}")
            if aug.match(cmd_array[0]).empty?
              rv = aug.clear(cmd_array[0])
              fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (!rv)
            end
          when "insert", "ins"
            label = cmd_array[0]
            where = cmd_array[1]
            path = cmd_array[2]
            case where
              when "before"; before = true
              when "after"; before = false
              else fail(_("Invalid value '%{where}' for where param") % { where: where })
            end
            debug("sending command '#{command}' with params #{[label, where, path].inspect}")
            rv = aug.insert(path, label, before)
            fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (rv == -1)
          when "defvar"
            debug("sending command '#{command}' with params #{cmd_array.inspect}")
            rv = aug.defvar(cmd_array[0], cmd_array[1])
            fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (!rv)
          when "defnode"
            debug("sending command '#{command}' with params #{cmd_array.inspect}")
            rv = aug.defnode(cmd_array[0], cmd_array[1], cmd_array[2])
            fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (!rv)
          when "mv", "move"
            debug("sending command '#{command}' with params #{cmd_array.inspect}")
            rv = aug.mv(cmd_array[0], cmd_array[1])
            fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (rv == -1)
          when "rename"
            debug("sending command '#{command}' with params #{cmd_array.inspect}")
            rv = aug.rename(cmd_array[0], cmd_array[1])
            fail(_("Error sending command '%{command}' with params %{params}") % { command: command, params: cmd_array.inspect }) if (rv == -1)
          else fail(_("Command '%{command}' is not supported") % { command: command })
        end
      rescue StandardError => e
        fail(_("Error sending command '%{command}' with params %{params}/%{message}") % { command: command, params: cmd_array.inspect, message: e.message })
      end
    end
  end

  def to_array(string)
    s = StringScanner.new(string)
    match = array_open(s)
    raise "Unable to parse array. Unexpected character at: #{s.rest}" if match.nil?

    array_content = array_values(s)

    match = array_close(s)
    raise "Unable to parse array. Unexpected character at: #{s.rest}" if match.nil?

    array_content
  end
  private :to_array

  def array_open(scanner)
    scanner.scan(/\s*\[\s*/)
  end
  private :array_open

  def array_close(scanner)
    scanner.scan(/\s*\]\s*/)
  end
  private :array_close

  def array_separator(scanner)
    scanner.scan(/\s*,\s*/)
  end
  private :array_separator

  def single_quote_unescaped_char(scanner)
    scanner.scan(/[^'\\]/)
  end
  private :single_quote_unescaped_char

  def single_quote_escaped_char(scanner)
    scanner.scan(/\\(['\\])/) && scanner[1]
  end
  private :single_quote_escaped_char

  def single_quote_char(scanner)
    single_quote_escaped_char(scanner) || single_quote_unescaped_char(scanner)
  end
  private :single_quote_char

  def double_quote_unescaped_char(scanner)
    scanner.scan(/[^"\\]/)
  end
  private :double_quote_unescaped_char

  # This handles the possible Ruby escape sequences in double-quoted strings,
  # except for \M-x, \M-\C-x, \M-\cx, \c\M-x, \c?, and \C-?. The full list of
  # escape sequences, and their meanings is taken from:
  # https://github.com/ruby/ruby/blob/90fdfec11a4a42653722e2ce2a672d6e87a57b8e/doc/syntax/literals.rdoc#strings
  def double_quote_escaped_char(scanner)
    match = scanner.scan(/\\(["\\abtnvfres0-7xu])/)
    return nil if match.nil?

    case scanner[1]
    when '\\' then return '\\'
    when '"'  then return '"'
    when 'a'  then return "\a"
    when 'b'  then return "\b"
    when 't'  then return "\t"
    when 'n'  then return "\n"
    when 'v'  then return "\v"
    when 'f'  then return "\f"
    when 'r'  then return "\r"
    when 'e'  then return "\e"
    when 's'  then return "\s"
    when /[0-7]/
      octal_character = scanner[1]
      other_digits = scanner.scan(/[0-7]{1,2}/)
      octal_character << other_digits unless other_digits.nil?

      return octal_character.to_i(8).chr
    when 'x'
      hex_character = scanner.scan(/[0-9a-fA-F]{1,2}/)
      if hex_character.nil?
        return nil
      else
        return hex_character.to_i(16).chr
      end
    when 'u'
      return unicode_short_hex_character(scanner) || unicode_long_hex_characters(scanner)
    else
      # Not a valid escape sequence as far as we're concerned.
      return nil
    end
  end
  private :double_quote_escaped_char

  def unicode_short_hex_character(scanner)
    unicode_character = scanner.scan(/[0-9a-fA-F]{4}/)
    if unicode_character.nil?
      return nil
    else
      return [unicode_character.hex].pack 'U'
    end
  end
  private :unicode_short_hex_character

  def unicode_long_hex_characters(scanner)
    unicode_string = ''
    return nil unless scanner.scan(/{/)

    loop do
      char = scanner.scan(/[0-9a-fA-F]{1,6}/)
      break if char.nil?
      unicode_string << [char.hex].pack('U')

      separator = scanner.scan(/\s/)
      break if separator.nil?
    end

    return nil unless scanner.scan(/}/)

    if unicode_string.empty?
      return nil
    else
      return unicode_string
    end
  end
  private :unicode_long_hex_characters

  def single_quoted_string(scanner)
    quoted_string = ''

    match = scanner.scan(/'/)
    return nil if match.nil?

    loop do
      match = single_quote_char(scanner)
      break if match.nil?

      quoted_string << match
    end

    match = scanner.scan(/'/)
    if match
      return quoted_string
    else
      return nil
    end
  end
  private :single_quoted_string

  def double_quote_char(scanner)
    double_quote_escaped_char(scanner) || double_quote_unescaped_char(scanner)
  end
  private :double_quote_char

  def double_quoted_string(scanner)
    quoted_string = ''

    match = scanner.scan(/"/)
    return nil if match.nil?

    loop do
      match = double_quote_char(scanner)
      break if match.nil?

      quoted_string << match
    end

    match = scanner.scan(/"/)
    if match
      return quoted_string
    else
      return nil
    end
  end
  private :double_quoted_string

  def quoted_string(scanner)
    single_quoted_string(scanner) || double_quoted_string(scanner)
  end
  private :quoted_string

  def array_values(scanner)
    values = []

    loop do
      match = quoted_string(scanner)
      break if match.nil?
      values << match

      match = array_separator(scanner)
      break if match.nil?
    end

    values
  end
  private :array_values
end
