<?php
/*
 * Copyright 2014 Google Inc.
 *
 * 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
 *
 *     http://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.
 */

if (!class_exists('Deconfin_Client')) {
  require_once dirname(__FILE__) . '/../autoload.php';
}

/**
 * Abstract logging class based on the PSR-3 standard.
 *
 * NOTE: We don't implement `Psr\Log\LoggerInterface` because we need to
 * maintain PHP 5.2 support.
 *
 * @see https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md
 */
abstract class Deconfin_Logger_Abstract
{
  /**
   * Default log format
   */
  const DEFAULT_LOG_FORMAT = "[%datetime%] %level%: %message% %context%\n";
  /**
   * Default date format
   *
   * Example: 16/Nov/2014:03:26:16 -0500
   */
  const DEFAULT_DATE_FORMAT = 'd/M/Y:H:i:s O';

  /**
   * System is unusable
   */
  const EMERGENCY = 'emergency';
  /**
   * Action must be taken immediately
   *
   * Example: Entire website down, database unavailable, etc. This should
   * trigger the SMS alerts and wake you up.
   */
  const ALERT = 'alert';
  /**
   * Critical conditions
   *
   * Example: Application component unavailable, unexpected exception.
   */
  const CRITICAL = 'critical';
  /**
   * Runtime errors that do not require immediate action but should typically
   * be logged and monitored.
   */
  const ERROR = 'error';
  /**
   * Exceptional occurrences that are not errors.
   *
   * Example: Use of deprecated APIs, poor use of an API, undesirable things
   * that are not necessarily wrong.
   */
  const WARNING = 'warning';
  /**
   * Normal but significant events.
   */
  const NOTICE = 'notice';
  /**
   * Interesting events.
   *
   * Example: User logs in, SQL logs.
   */
  const INFO = 'info';
  /**
   * Detailed debug information.
   */
  const DEBUG = 'debug';

  /**
   * @var array $levels Logging levels
   */
  protected static $levels = array(
      self::EMERGENCY  => 600,
      self::ALERT => 550,
      self::CRITICAL => 500,
      self::ERROR => 400,
      self::WARNING => 300,
      self::NOTICE => 250,
      self::INFO => 200,
      self::DEBUG => 100,
  );

  /**
   * @var integer $level The minimum logging level
   */
  protected $level = self::DEBUG;

  /**
   * @var string $logFormat The current log format
   */
  protected $logFormat = self::DEFAULT_LOG_FORMAT;
  /**
   * @var string $dateFormat The current date format
   */
  protected $dateFormat = self::DEFAULT_DATE_FORMAT;

  /**
   * @var boolean $allowNewLines If newlines are allowed
   */
  protected $allowNewLines = false;

  /**
   * @param Deconfin_Client $client  The current Google client
   */
  public function __construct(Deconfin_Client $client)
  {
    $this->setLevel(
        $client->getClassConfig('Deconfin_Logger_Abstract', 'level')
    );

    $format = $client->getClassConfig('Deconfin_Logger_Abstract', 'log_format');
    $this->logFormat = $format ? $format : self::DEFAULT_LOG_FORMAT;

    $format = $client->getClassConfig('Deconfin_Logger_Abstract', 'date_format');
    $this->dateFormat = $format ? $format : self::DEFAULT_DATE_FORMAT;

    $this->allowNewLines = (bool) $client->getClassConfig(
        'Deconfin_Logger_Abstract',
        'allow_newlines'
    );
  }

  /**
   * Sets the minimum logging level that this logger handles.
   *
   * @param integer $level
   */
  public function setLevel($level)
  {
    $this->level = $this->normalizeLevel($level);
  }

  /**
   * Checks if the logger should handle messages at the provided level.
   *
   * @param  integer $level
   * @return boolean
   */
  public function shouldHandle($level)
  {
    return $this->normalizeLevel($level) >= $this->level;
  }

  /**
   * System is unusable.
   *
   * @param string $message The log message
   * @param array $context  The log context
   */
  public function emergency($message, array $context = array())
  {
    $this->log(self::EMERGENCY, $message, $context);
  }

  /**
   * Action must be taken immediately.
   *
   * Example: Entire website down, database unavailable, etc. This should
   * trigger the SMS alerts and wake you up.
   *
   * @param string $message The log message
   * @param array $context  The log context
   */
  public function alert($message, array $context = array())
  {
    $this->log(self::ALERT, $message, $context);
  }

  /**
   * Critical conditions.
   *
   * Example: Application component unavailable, unexpected exception.
   *
   * @param string $message The log message
   * @param array $context  The log context
   */
  public function critical($message, array $context = array())
  {
    $this->log(self::CRITICAL, $message, $context);
  }

  /**
   * Runtime errors that do not require immediate action but should typically
   * be logged and monitored.
   *
   * @param string $message The log message
   * @param array $context  The log context
   */
  public function error($message, array $context = array())
  {
    $this->log(self::ERROR, $message, $context);
  }

  /**
   * Exceptional occurrences that are not errors.
   *
   * Example: Use of deprecated APIs, poor use of an API, undesirable things
   * that are not necessarily wrong.
   *
   * @param string $message The log message
   * @param array $context  The log context
   */
  public function warning($message, array $context = array())
  {
    $this->log(self::WARNING, $message, $context);
  }

  /**
   * Normal but significant events.
   *
   * @param string $message The log message
   * @param array $context  The log context
   */
  public function notice($message, array $context = array())
  {
    $this->log(self::NOTICE, $message, $context);
  }

  /**
   * Interesting events.
   *
   * Example: User logs in, SQL logs.
   *
   * @param string $message The log message
   * @param array $context  The log context
   */
  public function info($message, array $context = array())
  {
    $this->log(self::INFO, $message, $context);
  }

  /**
   * Detailed debug information.
   *
   * @param string $message The log message
   * @param array $context  The log context
   */
  public function debug($message, array $context = array())
  {
    $this->log(self::DEBUG, $message, $context);
  }

  /**
   * Logs with an arbitrary level.
   *
   * @param mixed $level    The log level
   * @param string $message The log message
   * @param array $context  The log context
   */
  public function log($level, $message, array $context = array())
  {
    if (!$this->shouldHandle($level)) {
      return false;
    }

    $levelName = is_int($level) ? array_search($level, self::$levels) : $level;
    $message = $this->interpolate(
        array(
            'message' => $message,
            'context' => $context,
            'level' => strtoupper($levelName),
            'datetime' => new DateTime(),
        )
    );

    $this->write($message);
  }

  /**
   * Interpolates log variables into the defined log format.
   *
   * @param  array $variables The log variables.
   * @return string
   */
  protected function interpolate(array $variables = array())
  {
    $template = $this->logFormat;

    if (!$variables['context']) {
      $template = str_replace('%context%', '', $template);
      unset($variables['context']);
    } else {
      $this->reverseJsonInContext($variables['context']);
    }

    foreach ($variables as $key => $value) {
      if (strpos($template, '%'. $key .'%') !== false) {
        $template = str_replace(
            '%' . $key . '%',
            $this->export($value),
            $template
        );
      }
    }

    return $template;
  }

  /**
   * Reverses JSON encoded PHP arrays and objects so that they log better.
   *
   * @param array $context The log context
   */
  protected function reverseJsonInContext(array &$context)
  {
    if (!$context) {
      return;
    }

    foreach ($context as $key => $val) {
      if (!$val || !is_string($val) || !($val[0] == '{' || $val[0] == '[')) {
        continue;
      }

      $json = @json_decode($val);
      if (is_object($json) || is_array($json)) {
        $context[$key] = $json;
      }
    }
  }

  /**
   * Exports a PHP value for logging to a string.
   *
   * @param mixed $value The value to
   */
  protected function export($value)
  {
    if (is_string($value)) {
      if ($this->allowNewLines) {
        return $value;
      }

      return preg_replace('/[\r\n]+/', ' ', $value);
    }

    if (is_resource($value)) {
      return sprintf(
          'resource(%d) of type (%s)',
          $value,
          get_resource_type($value)
      );
    }

    if ($value instanceof DateTime) {
      return $value->format($this->dateFormat);
    }

    if (version_compare(PHP_VERSION, '5.4.0', '>=')) {
      $options = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;

      if ($this->allowNewLines) {
        $options |= JSON_PRETTY_PRINT;
      }

      return @json_encode($value, $options);
    }

    return str_replace('\\/', '/', @json_encode($value));
  }

  /**
   * Converts a given log level to the integer form.
   *
   * @param  mixed $level   The logging level
   * @return integer $level The normalized level
   * @throws Deconfin_Logger_Exception If $level is invalid
   */
  protected function normalizeLevel($level)
  {
    if (is_int($level) && array_search($level, self::$levels) !== false) {
      return $level;
    }

    if (is_string($level) && isset(self::$levels[$level])) {
      return self::$levels[$level];
    }

    throw new Deconfin_Logger_Exception(
        sprintf("Unknown LogLevel: '%s'", $level)
    );
  }

  /**
   * Writes a message to the current log implementation.
   *
   * @param string $message The message
   */
  abstract protected function write($message);
}
