# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

require 'logger'
require 'ougai'
require 'singleton'

require 'contrast/components/interface'
require 'contrast/extension/module'
require 'contrast/logger/application'
require 'contrast/logger/format'
require 'contrast/logger/request'
require 'contrast/logger/time'

module Contrast
  module Logger
    # This class functions to serve as a wrapper around our logging, as we need
    # to be able to dynamically update level based on updates to TeamServer.
    class Log
      include Singleton
      include Contrast::Components::Interface
      access_component :config

      DEFAULT_NAME     = 'contrast.log'
      DEFAULT_LEVEL    = ::Ougai::Logging::Severity::INFO
      VALID_LEVELS     = ::Ougai::Logging::Severity::SEV_LABEL
      STDOUT_STR       = 'STDOUT'
      STDERR_STR       = 'STDERR'

      attr_reader :previous_path,
                  :previous_level

      def initialize
        update
      end

      # Given new settings from TeamServer, update our logging to use the new
      # file and level, assuming they weren't set by local configuration.
      #
      # @param log_file [String] the file to which to log
      # @param log_level [String] the level at which to log
      def update log_file = nil, log_level = nil
        config = CONFIG.root.agent.logger

        config_path  = config.path&.length.to_i.positive? ? config.path : nil
        config_level = config.level&.length&.positive? ? config.level : nil

        # config > settings > default
        path        = valid_path(config_path   || log_file)
        level_const = valid_level(config_level || log_level)

        path_change = path != previous_path
        level_change = level_const != previous_level

        # don't needlessly recreate logger
        return if @_logger && !(path_change || level_change)

        @previous_path  = path
        @previous_level = level_const

        @_logger = build(path: path, level_const: level_const)
        # If we're logging to a new path, then let's start it w/ our helpful
        # data gathering messages
        log_update if path_change
      rescue StandardError => e
        if logger
          logger.error('Unable to process update to LoggerManager.', e)
        else
          puts 'Unable to process update to LoggerManager.'
          puts e.backtrace.join("\n")
        end
      end

      def logger
        @_logger
      end

      # StringIO is a valid path because it logs directly to a string buffer
      def write_permission? path
        return false if path.nil?
        return true if path.is_a?(StringIO)
        return File.writable?(path) if File.exist?(path)

        dir_name = File.dirname(File.absolute_path(path))
        File.writable?(dir_name)
      end

      private

      def build path: STDOUT_STR, level_const: DEFAULT_LEVEL
        logger = case path
                 when STDOUT_STR, STDERR_STR
                   ::Ougai::Logger.new(Object.cs__const_get(path))
                 else
                   ::Ougai::Logger.new(path)
                 end
        add_contrast_loggers(logger)
        logger.progname = 'Contrast Agent'
        logger.level = level_const
        logger.formatter = Contrast::Logger::Format.new
        logger.formatter.datetime_format = '%Y-%m-%dT%H:%M:%S.%L%z'
        logger
      end

      def add_contrast_loggers logger
        logger.extend(Contrast::Logger::Application)
        logger.extend(Contrast::Logger::Request)
        logger.extend(Contrast::Logger::Time)
      end

      def valid_path path
        path = path.nil? ? Contrast::Utils::ObjectShare::EMPTY_STRING : path
        return path if path == STDOUT_STR
        return path if path == STDERR_STR

        path = DEFAULT_NAME if path.empty?
        if write_permission?(path)
          path
        elsif write_permission?(DEFAULT_NAME)
          # Log once when the path is invalid. We'll change to this path, so no
          # need to log again.
          puts "[!] Unable to write to '#{ path }'. Writing to default log '#{ DEFAULT_NAME }' instead." if previous_path != DEFAULT_NAME
          DEFAULT_NAME
        else
          # Log once when the path is invalid. We'll change to this path, so no
          # need to log again.
          puts "[!] Unable to write to '#{ path }'. Writing to standard out instead." if previous_path != STDOUT_STR
          STDOUT_STR
        end
      end

      def valid_level level
        level ||= DEFAULT_LEVEL
        level = level.upcase
        if VALID_LEVELS.include?(level)
          Object.cs__const_get("::Ougai::Logging::Severity::#{ level }")
        else
          DEFAULT_LEVEL
        end
      rescue StandardError
        DEFAULT_LEVEL
      end

      # Log that the Agent log has changed and include some default information
      # at the start of the log.
      def log_update
        logger.debug('Initialized new contrast agent logger')
        logger.debug_with_time('middleware: log environment') do
          logger.application_environment
          logger.application_configuration
          logger.application_libraries
        end
      end
    end
  end
end