require 'logger'
require 'stringio'

require 'madvertise/logging/improved_io'

module Madvertise
  module Logging

    ##
    # ImprovedLogger is an enhanced version of DaemonKits AbstractLogger class
    # with token support, buffer backend and more.
    #
    class ImprovedLogger < ImprovedIO

      # Program name prefix. Used as ident for syslog backends.
      attr_accessor :progname

      # Arbitrary token to prefix log messages with.
      attr_accessor :token

      @severities = {
        :debug   => Logger::DEBUG,
        :info    => Logger::INFO,
        :warn    => Logger::WARN,
        :error   => Logger::ERROR,
        :fatal   => Logger::FATAL,
        :unknown => Logger::UNKNOWN
      }

      @silencer = true

      class << self
        # Hash of Symbol/Fixnum pairs to map Logger levels.
        attr_reader :severities

        # Enable/disable the silencer on a global basis. Useful for debugging
        # otherwise silenced code blocks.
        attr_accessor :silencer
      end

      def initialize(logfile = nil, progname = nil)
        @progname = progname || File.basename($0)
        self.logger = logfile
      end

      # Get the backend logger.
      #
      # @return [Logger] The currently active backend logger object.
      def logger
        @logger ||= create_backend
      end

      # Set a different backend.
      #
      # @param [Symbol, String, Logger] value  The new logger backend. Either a
      #   Logger object, a String containing the logfile path or a Symbol to
      #   create a default backend for :syslog or :buffer
      # @return [Logger] The newly created backend logger object.
      def logger=(value)
        @logger.close rescue nil
        @logfile = value.is_a?(String) ? value : nil
        @backend = value.is_a?(Symbol) ? value : :logger
        @logger = value.is_a?(Logger) ? value : create_backend
      end

      # Close any connections/descriptors that may have been opened by the
      # current backend.
      def close
        logger.close rescue nil
        @logger = nil
      end

      # Retrieve the current buffer in case this instance is a buffered logger.
      #
      # @return [String] Contents of the buffer.
      def buffer
        @logfile.string if @backend == :buffer
      end

      # Get the current logging level.
      #
      # @return [Symbol] Current logging level.
      def level
        self.class.severities.invert[@logger.level]
      end

      # Set the logging level.
      #
      # @param [Symbol, Fixnum] level  New level as Symbol or Fixnum from Logger class.
      # @return [Fixnum] New level converted to Fixnum from Logger class.
      def level=(level)
        level = level.is_a?(Symbol) ? self.class.severities[level] : level
        logger.level = level
      end

      # Log a debug level message.
      def debug(msg)
        add(:debug, msg)
      end

      # Log an info level message.
      def info(msg)
        add(:info, msg)
      end

      # Log a warning level message.
      def warn(msg)
        add(:warn, msg)
      end

      # Log an error level message.
      def error(msg)
        add(:error, msg)
      end

      # Log a fatal level message.
      def fatal(msg)
        add(:fatal, msg)
      end

      # Log a message with unknown level.
      def unknown(msg)
        add(:unknown, msg)
      end

      # Log an info level message
      def <<(msg)
        add(:info, msg)
      end

      alias write <<

      # Log an exception with error level.
      #
      # @param [Exception, String] exc  The exception to log. If exc is a
      #   String no backtrace will be generated.
      def exception(exc)
        exc = "EXCEPTION: #{exc.message}: #{clean_trace(exc.backtrace)}" if exc.is_a?(::Exception)
        add(:error, exc, true)
      end

      # Save the current token and associate it with obj#object_id.
      def save_token(obj)
        if @token
          @tokens ||= {}
          @tokens[obj.object_id] = @token
        end
      end

      # Restore the token that has been associated with obj#object_id.
      def restore_token(obj)
        @tokens ||= {}
        @token = @tokens.delete(obj.object_id)
      end

      # Silence the logger for the duration of the block.
      def silence(temporary_level = :error)
        if self.class.silencer
          begin
            old_level, self.level = self.level, temporary_level
            yield self
          ensure
            self.level = old_level
          end
        else
          yield self
        end
      end

      # Remove references to the madvertise-logging gem from exception
      # backtraces.
      #
      # @private
      def clean_trace(trace)
        trace.reject do |line|
          line =~ /(gems|vendor)\/madvertise-logging/
        end
      end

      private

      # Return the first callee outside the madvertise-logging gem. Used in add
      # to figure out where in the source code a message has been produced.
      def called_from
        location = caller.detect('unknown:0') do |line|
          line.match(/(improved_logger|multi_logger)\.rb/).nil?
        end

        file, num, discard = location.split(':')
        [ File.basename(file), num ].join(':')
      end

      def add(severity, message, skip_caller = false)
        severity = self.class.severities[severity]
        message = "#{called_from}: #{message}" unless skip_caller
        message = "[#{@token}] #{message}" if @token

        logger.add(severity) { message }
      end

      def create_backend
        case @backend
        when :buffer
          create_buffering_backend
        when :syslog
          create_syslog_backend
        else
          create_standard_backend
        end
      end

      def create_buffering_backend
        @logfile = StringIO.new
        create_logger
      end

      def create_standard_backend
        begin
          FileUtils.mkdir_p(File.dirname(@logfile))
        rescue
          $stderr.puts "#{@logfile} not writable, using stderr for logging" if @logfile
          @logfile = $stderr
        end

        create_logger
      end

      def create_logger
        Logger.new(@logfile).tap do |logger|
          logger.formatter = Formatter.new
          logger.progname = progname
        end
      end

      def create_syslog_backend
        begin
          require 'syslogger'
          Syslogger.new(progname, Syslog::LOG_PID, Syslog::LOG_LOCAL1)
        rescue LoadError
          self.logger = :logger
          self.error("Couldn't load syslogger gem, reverting to standard logger")
        end
      end

      ##
      # The Formatter class is responsible for formatting log messages. The
      # default format is:
      #
      #   YYYY:MM:DD HH:MM:SS.MS daemon_name(pid) level: message
      #
      class Formatter

        @format = "%s %s(%d) [%s] %s\n"

        class << self
          # Format string for log messages.
          attr_accessor :format
        end

        # @private
        def call(severity, time, progname, msg)
          # this is so ugly because ruby 1.8 does not support %N in strftime
          time = time.strftime("%Y-%m-%d %H:%M:%S.") + sprintf('%.6f', time.usec.to_f/1000/1000)[2..-1]
          self.class.format % [time, progname, $$, severity, msg.to_s]
        end
      end

      module IOCompat
        def close_read
          nil
        end

        def close_write
          close
        end

        def closed?
          raise NotImplementedError
        end

        def sync
          @backend != :buffer
        end

        def sync=(value)
          raise NotImplementedError, "#{self} cannot change sync mode"
        end

        # ImprovedLogger is write-only
        def _raise_write_only
          raise IOError, "#{self} is a buffer-less, write-only, non-seekable stream."
        end

        [
          :bytes,
          :chars,
          :codepoints,
          :lines,
          :eof?,
          :getbyte,
          :getc,
          :gets,
          :pos,
          :pos=,
          :read,
          :readlines,
          :readpartial,
          :rewind,
          :seek,
          :ungetbyte,
          :ungetc
        ].each do |meth|
          alias_method meth, :_raise_write_only
        end
      end

      include IOCompat
    end
  end
end