# frozen_string_literal: true

# Module Puppet::IniConfig
# A generic way to parse .ini style files and manipulate them in memory
# One 'file' can be made up of several physical files. Changes to sections
# on the file are tracked so that only the physical files in which
# something has changed are written back to disk
# Great care is taken to preserve comments and blank lines from the original
# files
#
# The parsing tries to stay close to python's ConfigParser

require_relative '../../puppet/util/filetype'
require_relative '../../puppet/error'

module Puppet::Util::IniConfig
  # A section in a .ini file
  class Section
    attr_reader :name, :file, :entries
    attr_writer :destroy

    def initialize(name, file)
      @name = name
      @file = file
      @dirty = false
      @entries = []
      @destroy = false
    end

    # Does this section need to be updated in/removed from the associated file?
    #
    # @note This section is dirty if a key has been modified _or_ if the
    #   section has been modified so the associated file can be rewritten
    #   without this section.
    def dirty?
      @dirty or @destroy
    end

    def mark_dirty
      @dirty = true
    end

    # Should only be used internally
    def mark_clean
      @dirty = false
    end

    # Should the file be destroyed?
    def destroy?
      @destroy
    end

    # Add a line of text (e.g., a comment) Such lines
    # will be written back out in exactly the same
    # place they were read in
    def add_line(line)
      @entries << line
    end

    # Set the entry 'key=value'. If no entry with the
    # given key exists, one is appended to the end of the section
    def []=(key, value)
      entry = find_entry(key)
      @dirty = true
      if entry.nil?
        @entries << [key, value]
      else
        entry[1] = value
      end
    end

    # Return the value associated with KEY. If no such entry
    # exists, return nil
    def [](key)
      entry = find_entry(key)
      (entry.nil? ? nil : entry[1])
    end

    # Format the section as text in the way it should be
    # written to file
    def format
      if @destroy
        text = ''.dup
      else
        text = "[#{name}]\n"
        @entries.each do |entry|
          if entry.is_a?(Array)
            key, value = entry
            text << "#{key}=#{value}\n" unless value.nil?
          else
            text << entry
          end
        end
      end
      text
    end

    private

    def find_entry(key)
      @entries.each do |entry|
        return entry if entry.is_a?(Array) && entry[0] == key
      end
      nil
    end
  end

  class PhysicalFile
    # @!attribute [r] filetype
    #   @api private
    #   @return [Puppet::Util::FileType::FileTypeFlat]
    attr_reader :filetype

    # @!attribute [r] contents
    #   @api private
    #   @return [Array<String, Puppet::Util::IniConfig::Section>]
    attr_reader :contents

    # @!attribute [rw] destroy_empty
    #   Whether empty files should be removed if no sections are defined.
    #   Defaults to false
    attr_accessor :destroy_empty

    # @!attribute [rw] file_collection
    #   @return [Puppet::Util::IniConfig::FileCollection]
    attr_accessor :file_collection

    def initialize(file, options = {})
      @file = file
      @contents = []
      @filetype = Puppet::Util::FileType.filetype(:flat).new(file)

      @destroy_empty = options.fetch(:destroy_empty, false)
    end

    # Read and parse the on-disk file associated with this object
    def read
      text = @filetype.read
      if text.nil?
        raise IniParseError, _("Cannot read nonexistent file %{file}") % { file: @file.inspect }
      end

      parse(text)
    end

    INI_COMMENT = Regexp.union(
      /^\s*$/,
      /^[#;]/,
      /^\s*rem\s/i
    )
    INI_CONTINUATION = /^[ \t\r\n\f]/
    INI_SECTION_NAME = /^\[([^\]]+)\]/
    INI_PROPERTY     = /^\s*([^\s=]+)\s*=\s*(.*)$/

    # @api private
    def parse(text)
      section = nil   # The name of the current section
      optname = nil   # The name of the last option in section
      line_num = 0

      text.each_line do |l|
        line_num += 1
        if l.match(INI_COMMENT)
          # Whitespace or comment
          if section.nil?
            @contents << l
          else
            section.add_line(l)
          end
        elsif l.match(INI_CONTINUATION) && section && optname
          # continuation line
          section[optname] += "\n#{l.chomp}"
        elsif (match = l.match(INI_SECTION_NAME))
          # section heading
          section.mark_clean if section

          section_name = match[1]

          section = add_section(section_name)
          optname = nil
        elsif (match = l.match(INI_PROPERTY))
          # the regex strips leading white space from the value, and here we strip the trailing white space as well
          key = match[1]
          val = match[2].rstrip

          if section.nil?
            raise IniParseError, _("Property with key %{key} outside of a section") % { key: key.inspect }
          end

          section[key] = val
          optname = key
        else
          raise IniParseError.new(_("Can't parse line '%{line}'") % { line: l.chomp }, @file, line_num)
        end
      end
      section.mark_clean unless section.nil?
    end

    # @return [Array<Puppet::Util::IniConfig::Section>] All sections defined in
    #   this file.
    def sections
      @contents.select { |entry| entry.is_a? Section }
    end

    # @return [Puppet::Util::IniConfig::Section, nil] The section with the
    #   given name if it exists, else nil.
    def get_section(name)
      @contents.find { |entry| entry.is_a? Section and entry.name == name }
    end

    def format
      text = ''.dup

      @contents.each do |content|
        if content.is_a? Section
          text << content.format
        else
          text << content
        end
      end

      text
    end

    def store
      if @destroy_empty and (sections.empty? or sections.all?(&:destroy?))
        ::File.unlink(@file)
      elsif sections.any?(&:dirty?)
        text = self.format
        @filetype.write(text)
      end
      sections.each(&:mark_clean)
    end

    # Create a new section and store it in the file contents
    #
    # @api private
    # @param name [String] The name of the section to create
    # @return [Puppet::Util::IniConfig::Section]
    def add_section(name)
      if section_exists?(name)
        raise IniParseError.new(_("Section %{name} is already defined, cannot redefine") % { name: name.inspect }, @file)
      end

      section = Section.new(name, @file)
      @contents << section

      section
    end

    private

    def section_exists?(name)
      if get_section(name)
        true
      elsif @file_collection and @file_collection.get_section(name)
        true
      else
        false
      end
    end
  end

  class FileCollection
    attr_reader :files

    def initialize
      @files = {}
    end

    # Read and parse a file and store it in the collection. If the file has
    # already been read it will be destroyed and re-read.
    def read(file)
      new_physical_file(file).read
    end

    def store
      @files.values.each(&:store)
    end

    def each_section(&block)
      @files.values.each do |file|
        file.sections.each do |section|
          yield section
        end
      end
    end

    def each_file(&block)
      @files.keys.each do |path|
        yield path
      end
    end

    def get_section(name)
      sect = nil
      @files.values.each do |file|
        if (current = file.get_section(name))
          sect = current
        end
      end
      sect
    end
    alias [] get_section

    def include?(name)
      !!get_section(name)
    end

    def add_section(name, file)
      get_physical_file(file).add_section(name)
    end

    private

    # Return a file if it's already been defined, create a new file if it hasn't
    # been defined.
    def get_physical_file(file)
      @files[file] || new_physical_file(file)
    end

    # Create a new physical file and set required attributes on that file.
    def new_physical_file(file)
      @files[file] = PhysicalFile.new(file)
      @files[file].file_collection = self
      @files[file]
    end
  end

  File = FileCollection

  class IniParseError < Puppet::Error
    include Puppet::ExternalFileError
  end
end
