module Logging::Appenders

  # Accessor / Factory for the RollingFile appender.
  def self.rolling_file( *args )
    fail ArgumentError, '::Logging::Appenders::RollingFile needs a name as first argument.' if args.empty?
    ::Logging::Appenders::RollingFile.new(*args)
  end

  # An appender that writes to a file and ensures that the file size or age
  # never exceeds some user specified level.
  #
  # The goal of this class is to write log messages to a file. When the file
  # age or size exceeds a given limit then the log file is copied and then
  # truncated. The name of the copy indicates it is an older log file.
  #
  # The name of the log file is changed by inserting the age of the log file
  # (as a single number) between the log file name and the extension. If the
  # file has no extension then the number is appended to the filename. Here
  # is a simple example:
  #
  #    /var/log/ruby.log   =>   /var/log/ruby.1.log
  #
  # New log messages will continue to be appended to the same log file
  # (`/var/log/ruby.log` in our example above). The age number for all older
  # log files is incremented when the log file is rolled. The number of older
  # log files to keep can be given, otherwise all the log files are kept.
  #
  # The actual process of rolling all the log file names can be expensive if
  # there are many, many older log files to process.
  #
  # If you do not wish to use numbered files when rolling, you can specify the
  # :roll_by option as 'date'. This will use a date/time stamp to
  # differentiate the older files from one another. If you configure your
  # rolling file appender to roll daily and ignore the file size:
  #
  #    /var/log/ruby.log   =>   /var/log/ruby.20091225.log
  #
  # Where the date is expressed as `%Y%m%d` in the Time#strftime format.
  #
  # NOTE: this class is not safe to use when log messages are written to files
  # on NFS mounts or other remote file system. It should only be used for log
  # files on the local file system. The exception to this is when a single
  # process is writing to the log file; remote file systems are safe to
  # use in this case but still not recommended.
  class RollingFile < ::Logging::Appenders::IO

    # call-seq:
    #    RollingFile.new( name, opts )
    #
    # Creates a new Rolling File Appender. The _name_ is the unique Appender
    # name used to retrieve this appender from the Appender hash. The only
    # required option is the filename to use for creating log files.
    #
    #  [:filename]  The base filename to use when constructing new log
    #               filenames.
    #
    # The "rolling" portion of the filename can be configured via some simple
    # pattern templates. For numbered rolling, you can use {{.%d}}
    #
    #   "logname{{.%d}}.log" => ["logname.log", "logname.1.log", "logname.2.log" ...]
    #   "logname.log{{-%d}}" => ["logname.log", "logname.log-1", "logname.log-2" ...]
    #
    # And for date rolling you can use `strftime` patterns:
    #
    #   "logname{{.%Y%m%d}}.log"            => ["logname.log, "logname.20130626.log" ...]
    #   "logname{{.%Y-%m-%dT%H:%M:%S}}.log" => ["logname.log, "logname.2013-06-26T22:03:31.log" ...]
    #
    # If the defaults suit you fine, just pass in the :roll_by option and use
    # your normal log filename without any pattern template.
    #
    # The following options are optional:
    #
    #  [:layout]    The Layout that will be used by this appender. The Basic
    #               layout will be used if none is given.
    #  [:truncate]  When set to true any existing log files will be rolled
    #               immediately and a new, empty log file will be created.
    #  [:size]      The maximum allowed size (in bytes) of a log file before
    #               it is rolled.
    #  [:age]       The maximum age (in seconds) of a log file before it is
    #               rolled. The age can also be given as 'daily', 'weekly',
    #               or 'monthly'.
    #  [:keep]      The number of rolled log files to keep.
    #  [:roll_by]   How to name the rolled log files. This can be 'number' or
    #               'date'.
    #
    def initialize( name, opts = {} )
      @roller = Roller.new(
        opts.fetch(:filename, name),
        age:     opts.fetch(:age, nil),
        size:    opts.fetch(:size, nil),
        roll_by: opts.fetch(:roll_by, nil),
        keep:    opts.fetch(:keep, nil)
      )

      # grab our options
      @size = opts.fetch(:size, nil)
      @size = Integer(@size) unless @size.nil?

      @age_fn = self.filename + '.age'
      @age_fn_mtime = nil
      @age = opts.fetch(:age, nil)

      # create our `sufficiently_aged?` method
      build_singleton_methods
      FileUtils.touch(@age_fn) if @age && !::File.file?(@age_fn)

      # we are opening the file in read/write mode so that a shared lock can
      # be used on the file descriptor => http://pubs.opengroup.org/onlinepubs/009695399/functions/fcntl.html
      self.encoding = opts.fetch(:encoding, self.encoding)

      io = open_file
      super(name, io, opts)

      # if the truncate flag was set to true, then roll
      roll_now = opts.fetch(:truncate, false)
      if roll_now
        copy_truncate
        @roller.roll_files
      end
    end

    # Returns the path to the logfile.
    def filename
      @roller.filename
    end

    # Reopen the connection to the underlying logging destination. If the
    # connection is currently closed then it will be opened. If the connection
    # is currently open then it will be closed and immediately opened.
    def reopen
      @mutex.synchronize {
        if defined? @io && @io
          flush
          @io.close rescue nil
        end
        @io = open_file
      }
      super
      self
    end


  private

    def open_file
      mode = ::File::RDWR | ::File::APPEND
      ::File.open(filename, mode: mode, external_encoding: encoding)
    rescue Errno::ENOENT
      create_file
    end

    def create_file
      mode = ::File::RDWR | ::File::APPEND | ::File::CREAT | ::File::EXCL
      ::File.open(filename, mode: mode, external_encoding: encoding)
    rescue Errno::EEXIST
      open_file
    end

    # Returns the file name to use as the temporary copy location. We are
    # using copy-and-truncate semantics for rolling files so that the IO
    # file descriptor remains valid during rolling.
    def copy_file
      @roller.copy_file
    end

    # Returns the modification time of the copy file if one exists. Otherwise
    # returns `nil`.
    def copy_file_mtime
      return nil unless ::File.exist?(copy_file)
      ::File.mtime(copy_file)
    rescue Errno::ENOENT
      nil
    end

    # Write the given _event_ to the log file. The log file will be rolled
    # if the maximum file size is exceeded or if the file is older than the
    # maximum age.
    def canonical_write( str )
      return self if @io.nil?

      str = str.force_encoding(encoding) if encoding && str.encoding != encoding
      @mutex.synchronize {
        @io.flock_sh { @io.write str }
      }

      if roll_required?
        @mutex.synchronize {
          @io.flock? {
            @age_fn_mtime = nil
            copy_truncate if roll_required?
          }
          @roller.roll_files
        }
      end
      self
    rescue StandardError => err
      self.level = :off
      ::Logging.log_internal {"appender #{name.inspect} has been disabled"}
      ::Logging.log_internal_error(err)
    end

    # Returns +true+ if the log file needs to be rolled.
    def roll_required?
      mtime = copy_file_mtime
      return false if mtime && (Time.now - mtime) < 180

      # check if max size has been exceeded
      s = @size ? ::File.size(filename) > @size : false

      # check if max age has been exceeded
      a = sufficiently_aged?

      return (s || a)
    end

    # Copy the contents of the logfile to another file. Truncate the logfile
    # to zero length. This method will set the roll flag so that all the
    # current logfiles will be rolled along with the copied file.
    def copy_truncate
      return unless ::File.exist?(filename)
      FileUtils.concat filename, copy_file
      @io.truncate(0)

      # touch the age file if needed
      if @age
        FileUtils.touch @age_fn
        @age_fn_mtime = nil
      end

      @roller.roll = true
    end

    # Returns the modification time of the age file.
    def age_fn_mtime
      @age_fn_mtime ||= ::File.mtime(@age_fn)
    end

    # We use meta-programming here to define the `sufficiently_aged?` method for
    # the rolling appender. The `sufficiently_aged?` method is responsible for
    # determining if the current log file is older than the rolling criteria -
    # daily, weekly, etc.
    #
    # Returns this rolling file appender instance
    def build_singleton_methods
      method =
        case @age
        when 'daily'
          -> {
            now = Time.now
            (now.day != age_fn_mtime.day) || (now - age_fn_mtime) > 86400
          }

        when 'weekly'
          -> { (Time.now - age_fn_mtime) > 604800 }

        when 'monthly'
          -> {
            now = Time.now
            (now.month != age_fn_mtime.month) || (now - age_fn_mtime) > 2678400
          }

        when Integer, String
          @age = Integer(@age)
          -> { (Time.now - age_fn_mtime) > @age }

        else
          -> { false }
        end

      self.define_singleton_method(:sufficiently_aged?, method)
    end

    # Not intended for general consumption, but the Roller class is used
    # internally by the RollingFile appender to roll dem log files according
    # to the user's desires.
    class Roller

      # The magic regex for finding user-defined roller patterns.
      RGXP = %r/{{(([^%]+)?.*?)}}/

      # Create a new roller. See the RollingFile#initialize documentation for
      # the list of options.
      #
      # filename - the name of the file to roll
      # age      - the age of the file at which it should be rolled
      # size     - the size of the file in bytes at which it should be rolled
      # roll_by  - roll either by 'number' or 'date'
      # keep     - the number of log files to keep when rolling
      #
      def initialize( filename, age: nil, size: nil, roll_by: nil, keep: nil )
        # raise an error if a filename was not given
        @fn = filename
        raise ArgumentError, 'no filename was given' if @fn.nil?

        if (m = RGXP.match @fn)
          @roll_by = ("#{m[2]}%d" == m[1]) ? :number : :date
        else
          @roll_by =
              case roll_by
              when 'number'; :number
              when 'date'; :date
              else
                (age && !size) ? :date : :number
              end

          ext = ::File.extname(@fn)
          bn  = ::File.join(::File.dirname(@fn), ::File.basename(@fn, ext))

          @fn = if :date == @roll_by && %w[daily weekly monthly].include?(age)
                  "#{bn}{{.%Y%m%d}}#{ext}"
                elsif :date == @roll_by
                  "#{bn}{{.%Y%m%d-%H%M%S}}#{ext}"
                else
                  "#{bn}{{.%d}}#{ext}"
                end
        end

        @fn = ::File.expand_path(@fn)
        ::Logging::Appenders::File.assert_valid_logfile(filename)

        @roll = false
        @keep = keep.nil? ? nil : Integer(keep)
      end

      attr_reader :keep, :roll_by
      attr_accessor :roll

      # Returns the regular log file name without any roller text.
      def filename
        return @filename if defined? @filename
        @filename = (@fn =~ RGXP ?  @fn.sub(RGXP, '') : @fn.dup)
        @filename.freeze
      end

      # Returns the file name to use as the temporary copy location. We are
      # using copy-and-truncate semantics for rolling files so that the IO
      # file descriptor remains valid during rolling.
      def copy_file
        return @copy_file if defined? @copy_file
        @copy_file = filename + '._copy_'
        @copy_file.freeze
      end

      # Returns the glob pattern used to find rolled log files. We use this
      # list for pruning older log files and doing the numbered rolling.
      def glob
        return @glob if defined? @glob
        m = RGXP.match @fn
        @glob = @fn.sub(RGXP, (m[2] ? "#{m[2]}*" : '*'))
        @glob.freeze
      end

      # Returns the format String used to generate rolled file names.
      # Depending upon the `roll_by` type (:date or :number), this String will
      # be processed by `sprintf` or `Time#strftime`.
      def format
        return @format if defined? @format
        m = RGXP.match @fn
        @format = @fn.sub(RGXP, m[1])
        @format.freeze
      end

      # Roll the log files. This method will collect the list of rolled files
      # and then pass that list to either `roll_by_number` or `roll_by_date`
      # to perform the actual rolling.
      #
      # Returns nil
      def roll_files
        return unless roll && ::File.exist?(copy_file)

        files = Dir.glob(glob)
        files.delete copy_file

        self.send "roll_by_#{roll_by}", files
        nil
      ensure
        self.roll = false
      end

      # Roll the list of log files optionally removing older files. The "older
      # files" are determined by extracting the number from the log file name
      # and order by the number.
      #
      # files - The Array of filename Strings
      #
      # Returns nil
      def roll_by_number( files )
        @number_rgxp ||= Regexp.new(@fn.sub(RGXP, '\2(\d+)'))

        # sort the files in reverse order based on their count number
        files = files.sort do |a,b|
                  a = Integer(@number_rgxp.match(a)[1])
                  b = Integer(@number_rgxp.match(b)[1])
                  b <=> a
                end

        # for each file, roll its count number one higher
        files.each do |fn|
          cnt = Integer(@number_rgxp.match(fn)[1])
          if keep && cnt >= keep
            ::File.delete fn
            next
          end
          ::File.rename fn, sprintf(format, cnt+1)
        end

        # finally rename the copied log file
        ::File.rename(copy_file, sprintf(format, 1))
      end

      # Roll the list of log files optionally removing older files. The "older
      # files" are determined by the mtime of the log files. So touching log
      # files or otherwise messing with them will screw this up.
      #
      # files - The Array of filename Strings
      #
      # Returns nil
      def roll_by_date( files )
        length = files.length

        if keep && length >= keep
          files = files.sort do |a,b|
                    a = ::File.mtime(a)
                    b = ::File.mtime(b)
                    b <=> a
                  end
          files.last(length-keep+1).each { |fn| ::File.delete fn }
        end

        # rename the copied log file
        ::File.rename(copy_file, Time.now.strftime(format))
      end
    end
  end
end
