<?php

namespace EesyPHP;

use Throwable;


class Log {

  /*
   * Log file path
   * @var string|null
   */
  protected static $filepath = null;

  /*
   * Log file descriptor
   * @var resource|null
   */
  protected static $file_fd = null;

  /**
   * PHP error_log() fallback if no filepath configured or fail to open log file
   * @var bool
   */
  protected static $error_log_fallback = true;

  /*
   * Log Levels
   * @var array(string,int)
   */
  protected static $levels = array(
    'TRACE' => 0,
    'DEBUG' => 1,
    'INFO' => 2,
    'WARNING' => 3,
    'ERROR' => 4,
    'FATAL' => 5,
  );

  /*
   * Current log level
   * @var string|null
   */
  protected static $level = null;

  /*
   * Log PHP errors levels (as specified to set_error_handler())
   * Default (set in self::init() method):
   * - In TRACE or DEBUG: E_ALL & ~E_STRICT
   * - Otherwise: E_ALL & ~E_NOTICE & ~E_STRICT & ~E_DEPRECATED
   */
  protected static $php_errors_levels = null;

  // Custom fatal error handler
  protected static $fatal_error_handler = null;

  /**
   * Initialization
   * @return void
   */
  public static function init() {
    // Set config default values
    App :: set_default(
      'log',
      array(
        'level' => 'WARNING',
        'file_path' => null,
        'cli_file_path' => '${log.file_path}',
        'cli_console' => false,
        'default_locale' => null,
        'error_log_fallback' => true,
        'php_errors_levels' => array(),  // Depend of effective log level, see below
      )
    );
    self :: $filepath = App::get(
      php_sapi_name() == 'cli'?'log.cli_file_path':'log.file_path'
    );

    // PHP error_log() fallback
    self :: $error_log_fallback = App::get('log.error_log_fallback', null, 'bool');

    // Set log level:
    // Note: in Phpstan context, force FATAL level
    if (defined('__PHPSTAN_RUNNING__') && constant('__PHPSTAN_RUNNING__'))  // @phpstan-ignore-line
      self :: set_level('FATAL');
    else
      self :: set_level(App::get('log.level', null, 'string'));

    App :: set_default(
      'log.php_errors_levels',
      in_array(self :: $level, array('DEBUG', 'TRACE'))?
      array('E_ALL', '~E_STRICT'):
      array('E_ALL', '~E_STRICT', '~E_NOTICE', '~E_DEPRECATED')
    );

    // Log PHP errors
    $levels = App::get('log.php_errors_levels', array(), 'array');
    if ($levels) {
      self :: $php_errors_levels = E_ALL;
      $code = 'self :: $php_errors_levels = ';
      while($level = array_shift($levels)) {
        if (!is_string($level)) continue;
        if (!defined($level) || !is_int(constant($level))) continue;
        $code .= $level;
        break;
      }
      foreach($levels as $level) {
        if (!is_string($level)) continue;
        $combine_operator = '&';
        if (in_array($level[0], array('|', '^', '&'))) {
          $combine_operator = $level[0];
          $level = substr($level, 1);
        }
        $not = false;
        if (in_array($level[0], array('!', '~'))) {
          $not = $level[0];
          $level = substr($level, 1);
        }
        if (!defined($level) || !is_int(constant($level))) continue;
        $code .= " $combine_operator ";
        if ($not) $code .= "$not";
        $code .= $level;
      }
      $code .= ";";
      eval($code);
      set_error_handler(array('EesyPHP\\Log', 'on_php_error'), self :: $php_errors_levels);
    }

    // Log uncatched exceptions
    set_exception_handler(array('EesyPHP\\Log', 'exception'));
  }

  /**
   * Get remote IP address
   * @return string
   */
  public static function get_remote_addr(){
    if (array_key_exists('HTTP_X_FORWARDED_FOR', $_SERVER))
      return  $_SERVER["HTTP_X_FORWARDED_FOR"];
    if (array_key_exists('REMOTE_ADDR', $_SERVER))
      return $_SERVER["REMOTE_ADDR"];
    if (array_key_exists('HTTP_CLIENT_IP', $_SERVER))
      return $_SERVER["HTTP_CLIENT_IP"];
    return '';
  }

  /**
   * Log a message
   * @param string $level The message level (key of self :: $levels)
   * @param string $message The message to log
   * @param array $extra_args Extra arguments to use to compute message using sprintf
   * @return ($level is "FATAL" ? never : true)
   */
  public static function log($level, $message, ...$extra_args) {
    global $argv;

    if (!array_key_exists($level, self :: $levels)) {
      self :: warning(
        "Invalid log level specified logging the message %s, use 'WARNING':\n%s",
        $message, self :: get_debug_backtrace_context()
      );
      $level = 'WARNING';
    }
    if (!self :: $level)
      self :: $level = (
        App::initialized()?
        App::get('log.level', 'WARNING', 'string'):
        'WARNING'
      );
    if (self :: $levels[$level] < self :: $levels[self :: $level]) return true;
    if(self :: $filepath && is_null(self :: $file_fd)) {
      self :: $file_fd = fopen(self :: $filepath, 'a');
      if (self :: $file_fd === false)
        self :: error('Fail to open log file (%s)', self :: $filepath);
    }

    // Extra arguments passed, format message using sprintf
    if ($extra_args) {
      $message = call_user_func_array(
        'sprintf',
        array_merge(array($message), $extra_args)
      );
    }

    if (php_sapi_name() == "cli") {
      $msg = implode(' - ', array(
        date('Y/m/d H:i:s'),
        basename($argv[0]),
        $level,
        $message
      ))."\n";
    }
    else {
      $msg = array(
        date('Y/m/d H:i:s'),
        $_SERVER['REQUEST_URI'],
        self :: get_remote_addr(),
      );
      if (Auth::enabled())
        $msg[] = (Auth::user()?Auth::user():'anonymous');
      $msg[] = $level;
      $msg[] = $message;
      $msg = implode(' - ', $msg)."\n";
    }
    if (!self :: $file_fd || php_sapi_name() == 'cli-server')
      error_log(rtrim($msg, "\n"));
    else
      fwrite(self :: $file_fd, $msg);

    if ($level == 'FATAL')
      if (!is_null(self :: $fatal_error_handler))
        call_user_func(self :: $fatal_error_handler, $message);
      elseif (function_exists('fatal_error'))
        fatal_error($message);
      else
        die("\n$message\n\n");
    elseif (
      self :: $file_fd  // When no log file, message already printed on console by error_log()
      && php_sapi_name() == "cli"
      && App::initialized()
      && App::get('log.cli_console', null, 'bool')
    )
      echo $msg;

    return true;
  }

  /**
   * Log a trace message
   * @param string $message The message to log
   * @param array $extra_args Extra arguments to use to compute message using sprintf
   * @return true
   */
  public static function trace($message, ...$extra_args) {
    return call_user_func_array(
      array('EesyPHP\\Log', 'log'),
      array_merge(array('TRACE', $message), $extra_args)
    );
  }

  /**
   * Log a debug message
   * @param string $message The message to log
   * @param array $extra_args Extra arguments to use to compute message using sprintf
   * @return true
   */
  public static function debug($message, ...$extra_args) {
    return call_user_func_array(
      array('EesyPHP\\Log', 'log'),
      array_merge(array('DEBUG', $message), $extra_args)
    );
  }

  /**
   * Log an info message
   * @param string $message The message to log
   * @param array $extra_args Extra arguments to use to compute message using sprintf
   * @return true
   */
  public static function info($message, ...$extra_args) {
    return call_user_func_array(
      array('EesyPHP\\Log', 'log'),
      array_merge(array('INFO', $message), $extra_args)
    );
  }

  /**
   * Log an warning message
   * @param string $message The message to log
   * @param array $extra_args Extra arguments to use to compute message using sprintf
   * @return true
   */
  public static function warning($message, ...$extra_args) {
    return call_user_func_array(
      array('EesyPHP\\Log', 'log'),
      array_merge(array('WARNING', $message), $extra_args)
    );
  }

  /**
   * Log an error message
   * @param string $message The message to log
   * @param array $extra_args Extra arguments to use to compute message using sprintf
   * @return true
   */
  public static function error($message, ...$extra_args) {
    return call_user_func_array(
      array('EesyPHP\\Log', 'log'),
      array_merge(array('ERROR', $message), $extra_args)
    );
  }

  /**
   * Log an fatal message
   * @param string $message The message to log
   * @param array $extra_args Extra arguments to use to compute message using sprintf
   * @return never
   */
  public static function fatal($message, ...$extra_args) {
    // @phpstan-ignore-next-line
    call_user_func_array(
      array('EesyPHP\\Log', 'log'),
      array_merge(array('FATAL', $message), $extra_args)
    );
  }

  /**
   * Register a contextual fatal error handler
   * @param null|callable $handler The fatal error handler (set as null to reset)
   * @return void
   */
  public static function register_fatal_error_handler($handler) {
    // @phpstan-ignore-next-line
    if ($handler && !is_callable($handler))
      self :: fatal('Invalid fatal error handler provided: it is not callable !');
    self :: $fatal_error_handler = ($handler?$handler:null);
  }

  /**
   * Change of current log file
   * @param string $file The new log file path
   * @return bool
   */
  public static function change_filepath($file) {
    if ($file == self :: $filepath) return True;
    if (self :: $file_fd) {
      fclose(self :: $file_fd);
      self :: $file_fd = null;
    }
    self :: $filepath = $file;
    return True;
  }

  /**
   * Get of current log file
   * @return string|null Current log file path
   */
  public static function filepath() {
    return self :: $filepath;
  }

  /**
   * Set current log level
   * @param string $level The new log level to set
   * @return void
   */
  public static function set_level($level=null) {
    // Set default log level (if not defined or invalid)
    if (is_null($level)) {
      self :: $level = App::get_default('log.level', null, 'string');
    }
    elseif (!array_key_exists($level, self :: $levels)) {
      self :: $level = App::get_default('log.level', null, 'string');
      self :: warning(
        "Invalid log level value found in configuration (%s). ".
        "Set as default (%s).", vardump($level), self :: $level);
    }
    else {
      self :: $level = $level;
    }
  }

  /*
   *******************************************************************************
   *            Handle exception logging
   *******************************************************************************
   */

  /**
   * Get the current backtrace
   * @param int $ignore_last The number of last levels to ignore
   * @return string
   */
  public static function get_debug_backtrace_context($ignore_last=0) {
    $traces = debug_backtrace();

    // Also ignore this function it self
    $ignore_last++;

    if (!is_array($traces) || count($traces) <= $ignore_last)
      return "";

    $msg = array();
    for ($i=$ignore_last; $i < count($traces); $i++) {
      $trace = array("#$i");
      if (isset($traces[$i]['file']))
        $trace[] = $traces[$i]['file'].(isset($traces[$i]['line'])?":".$traces[$i]['line']:"");
      // @phpstan-ignore-next-line
      if (isset($traces[$i]['class']) && isset($traces[$i]['function']))
        $trace[] = implode(" ", array(
          $traces[$i]['class'],
          $traces[$i]['type'],
          $traces[$i]['function']. "()"));
      elseif (isset($traces[$i]['function']))
        $trace[] = $traces[$i]['function']. "()";
      $msg[] = implode(" - ", $trace);
    }

    return implode("\n", $msg);
  }

  /**
   * Log an exception
   * @param Throwable $exception
   * @param string|null $prefix The prefix of the log message
   *          (optional, default: "An exception occured")
   * @param array $extra_args Extra arguments to use to compute prefix using sprintf
   * @return void
   */
  public static function exception($exception, $prefix=null, ...$extra_args) {
    SentryIntegration :: log($exception);
    // If extra arguments passed, format prefix message using sprintf
    if ($prefix && $extra_args) {
      $prefix = call_user_func_array(
        'sprintf',
        array_merge(array($prefix), $extra_args)
      );
    }
    self :: error(
      "%s:\n%s\n## %s:%d : %s",
      ($prefix?$prefix:"An exception occured"),
      $exception->getTraceAsString(),
      $exception->getFile(), $exception->getLine(),
      $exception->getMessage());
  }

  /*
   *******************************************************************************
   *            Handle PHP error logging
   *******************************************************************************
   */

  /**
   * Convert PHP error number to the corresponding label
   * @param int $errno
   * @return string
   */
  public static function errno2type($errno) {
    $constants = get_defined_constants();
    if (is_array($constants))
      foreach($constants as $label => $value)
        if ($value == $errno && preg_match('/^E_(.*)$/', $label, $m))
          return $m[1];
    return 'UNKNOWN ERROR #'.$errno;
  }

  /**
   * Log a PHP error
   * Note: method design to be used as callable by set_error_handler()
   * @param int $errno The error number
   * @param string $errstr The error message
   * @param string $errfile The filename that the error was raised in
   * @param int $errline The line number where the error was raised
   * @return false Return false to let the normal error handler continues.
   */
  public static function on_php_error($errno, $errstr, $errfile, $errline) {
    self :: error(
      "A PHP error occured : [%s] %s\nFile : %s (line : %d)",
      self :: errno2type($errno), $errstr, $errfile, $errline
    );
    return False;
  }
}

# vim: tabstop=2 shiftwidth=2 softtabstop=2 expandtab
