<?php

namespace ZM;
require_once('config.php');

class Logger {
  private static $instance;

  const DEBUG = 1;
  const INFO = 0;
  const WARNING = -1;
  const ERROR = -2;
  const FATAL = -3;
  const PANIC = -4;
  const AUDIT = -5;
  const NOLOG = -6;   // Special artificial level to prevent logging

  private $initialised = false;

  private $id = 'web';
  private $idRoot = 'web';
  private $idArgs = '';
  private $useErrorLog = true;

  private $level = self::INFO;
  private $termLevel = self::NOLOG;
  private $databaseLevel = self::NOLOG;
  private $fileLevel = self::NOLOG;
  private $weblogLevel = self::NOLOG;
  private $syslogLevel = self::NOLOG;
  private $effectiveLevel = self::NOLOG;

  private $hasTerm = false;

  private $logPath = ZM_PATH_LOGS;
  private $logFile = '';
  private $logFd = NULL;

  private $savedLogErrors = null;
  private $savedErrorReporting = null;
  private $savedDisplayErrors = null;

  public static $codes = array(
    self::DEBUG => 'DBG',
    self::INFO => 'INF',
    self::WARNING => 'WAR',
    self::ERROR => 'ERR',
    self::FATAL => 'FAT',
    self::PANIC => 'PNC',
    self::AUDIT => 'AUD',
    self::NOLOG => 'OFF',
  );
  private static $syslogPriorities = array(
    self::DEBUG   => LOG_DEBUG,
    self::INFO    => LOG_INFO,
    self::WARNING => LOG_WARNING,
    self::ERROR   => LOG_ERR,
    self::FATAL   => LOG_ERR,
    self::PANIC   => LOG_ERR,
    self::AUDIT   => LOG_NOTICE,
  );
  private static $phpErrorLevels = array(
    self::DEBUG   => E_USER_NOTICE,
    self::INFO    => E_USER_NOTICE,
    self::WARNING => E_USER_WARNING,
    self::ERROR   => E_USER_WARNING,
    self::FATAL   => E_USER_ERROR,
    self::PANIC   => E_USER_ERROR,
    self::AUDIT   => E_USER_NOTICE,
  );

  private function __construct() {
    $this->hasTerm = (php_sapi_name() == 'cli' && empty($_SERVER['REMOTE_ADDR']));
    $this->logFile = $this->id.'.log';
  }

  public function __destruct() {
    $this->terminate();
  }

  public function initialise($options=array()) {
    if ( !empty($options['id']) )
      $this->id = $options['id'];

    //if ( isset($options['useErrorLog']) )
    //$this->useErrorLog = $options['useErrorLog'];
    if ( isset($options['logPath']) ) {
      $this->logPath = $options['logPath'];
      $tempLogFile = $this->logPath.'/'.$this->id.'.log';
    }
    if ( isset($options['logFile']) )
      $tempLogFile = basename($options['logFile']);
    else
      $tempLogFile = $this->id.'.log';
    if ( !is_null($logFile = $this->getTargettedEnv('LOG_FILE')) )
      $tempLogFile = basename($logFile);

    $tempLevel = self::INFO;
    $tempTermLevel = $this->termLevel;
    $tempDatabaseLevel = $this->databaseLevel;
    $tempFileLevel = $this->fileLevel;
    $tempSyslogLevel = $this->syslogLevel;
    $tempWeblogLevel = $this->weblogLevel;

    if ( isset($options['termLevel']) )
      $tempTermLevel = $options['termLevel'];
    if ( isset($options['databaseLevel']) )
      $tempDatabaseLevel = $options['databaseLevel'];
    else
      $tempDatabaseLevel = ZM_LOG_LEVEL_DATABASE;

    if ( isset($options['fileLevel']) )
      $tempFileLevel = $options['fileLevel'];
    else
      $tempFileLevel = ZM_LOG_LEVEL_FILE;

    if ( isset($options['weblogLevel']) )
      $tempWeblogLevel = $options['weblogLevel'];
    else
      $tempWeblogLevel = ZM_LOG_LEVEL_WEBLOG;

    if ( isset($options['syslogLevel']) )
      $tempSyslogLevel = $options['syslogLevel'];
    else
      $tempSyslogLevel = ZM_LOG_LEVEL_SYSLOG;

    if ( $value = getenv('LOG_PRINT') )
      $tempTermLevel = $value ? self::DEBUG : self::NOLOG;

    if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL')) )
      $tempLevel = $level;

    if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL_TERM')) )
      $tempTermLevel = $level;
    if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL_DATABASE')) )
      $tempDatabaseLevel = $level;
    if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL_FILE')) )
      $tempFileLevel = $level;
    if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL_SYSLOG')) )
      $tempSyslogLevel = $level;
    if ( !is_null($level = $this->getTargettedEnv('LOG_LEVEL_WEBLOG')) )
      $tempWeblogLevel = $level;

    if ( ZM_LOG_DEBUG ) {
      foreach ( explode( '|', ZM_LOG_DEBUG_TARGET ) as $target ) {
        if ( $target == $this->id || $target == '_'.$this->id || $target == $this->idRoot || $target == '_'.$this->idRoot || $target == '' ) {
          if ( ZM_LOG_DEBUG_LEVEL > self::NOLOG ) {
            $tempLevel = $this->limit( ZM_LOG_DEBUG_LEVEL );
            if ( ZM_LOG_DEBUG_FILE != '' ) {
              $tempLogFile = basename(ZM_LOG_DEBUG_FILE);
              $tempFileLevel = $tempLevel;
            }
          }
        }
      } // end foreach target
    } // end if DEBUG

    $this->logFile( $tempLogFile );
    $this->termLevel( $tempTermLevel );
    $this->databaseLevel( $tempDatabaseLevel );
    $this->fileLevel( $tempFileLevel );
    $this->syslogLevel( $tempSyslogLevel );
    $this->weblogLevel( $tempWeblogLevel );

    $this->level( $tempLevel );

    $this->initialised = true;

    //Debug( "LogOpts: level=".self::$codes[$this->level]."/".self::$codes[$this->effectiveLevel].", screen=".self::$codes[$this->termLevel].", database=".self::$codes[$this->databaseLevel].", logfile=".self::$codes[$this->fileLevel]."->".$this->logFile.", weblog=".self::$codes[$this->weblogLevel].", syslog=".self::$codes[$this->syslogLevel] );
  }

  private function terminate() {
    if ( $this->initialised ) {
      if ( $this->fileLevel > self::NOLOG )
        $this->closeFile();
      if ( $this->syslogLevel > self::NOLOG )
        $this->closeSyslog();
    }
    $this->initialised = false;
  }

  private function limit( $level ) {
    if ( $level > self::DEBUG )
      return( self::DEBUG );
    if ( $level < self::NOLOG )
      return( self::NOLOG );
    return( $level );
  }

  private function getTargettedEnv( $name ) {
    $envName = $name.'_'.$this->id;
    $value = getenv( $envName );
    if ( $value === false && $this->id != $this->idRoot )
      $value = getenv( $name.'_'.$this->idRoot );
    if ( $value === false )
      $value = getenv( $name );
    return( $value !== false ? $value : NULL );
  }

  public static function fetch( $initialise=true ) {
    if ( !isset(self::$instance) ) {
      $class = __CLASS__;
      self::$instance = new $class;
      if ( $initialise )
        self::$instance->initialise( array( 'id'=>'web_php', 'syslogLevel'=>self::INFO, 'weblogLevel'=>self::INFO ) );
    }
    return self::$instance;
  }


  public function id( $id=NULL ) {
    if ( isset($id) && $this->id != $id ) {
      // Remove whitespace
      $id = preg_replace( '/\S/', '', $id );
      // Replace non-alphanum with underscore
      $id = preg_replace( '/[^a-zA-Z_]/', '_', $id );

      if ( $this->id != $id ) {
        $this->id = $this->idRoot = $id;
        if ( preg_match( '/^([^_]+)_(.+)$/', $id, $matches ) ) {
          $this->idRoot = $matches[1];
          $this->idArgs = $matches[2];
        }
      }
    }
    return $this->id;
  }

  public function level( $level ) {
    if ( isset($level) ) {
      $lastLevel = $this->level;
      $this->level = $this->limit($level);
      $this->effectiveLevel = self::NOLOG;
      if ( $this->termLevel > $this->effectiveLevel )
        $this->effectiveLevel = $this->termLevel;
      if ( $this->databaseLevel > $this->effectiveLevel )
        $this->effectiveLevel = $this->databaseLevel;
      if ( $this->fileLevel > $this->effectiveLevel )
        $this->effectiveLevel = $this->fileLevel;
      if ( $this->weblogLevel > $this->effectiveLevel )
        $this->effectiveLevel = $this->weblogLevel;
      if ( $this->syslogLevel > $this->effectiveLevel )
        $this->effectiveLevel = $this->syslogLevel;
      if ( $this->effectiveLevel > $this->level )
        $this->effectiveLevel = $this->level;
      if ( !$this->hasTerm ) {
        if ( $lastLevel < self::DEBUG && $this->level >= self::DEBUG ) {
          $this->savedErrorReporting = error_reporting(E_ALL);
          $this->savedDisplayErrors = ini_set('display_errors', false);
        } elseif ( $lastLevel >= self::DEBUG && $this->level < self::DEBUG ) {
          error_reporting($this->savedErrorReporting);
          ini_set('display_errors', $this->savedDisplayErrors);
        }
      }
    }
    return $this->level;
  }

  public function debugOn() {
    return( $this->effectiveLevel >= self::DEBUG );
  }

  public function termLevel($termLevel) {
    if ( isset($termLevel) ) {
      $termLevel = $this->limit($termLevel);
      if ( $this->termLevel != $termLevel )
        $this->termLevel = $termLevel;
    }
    return $this->termLevel;
  }

  public function databaseLevel($databaseLevel=NULL) {
    if ( !is_null($databaseLevel) ) {
      $databaseLevel = $this->limit($databaseLevel);
      if ( $this->databaseLevel != $databaseLevel ) {
        $this->databaseLevel = $databaseLevel;
        if ( $this->databaseLevel > self::NOLOG ) {
          if ( (include_once 'database.php') === FALSE ) {
            $this->databaseLevel = self::NOLOG;
            Warning('Unable to write log entries to DB, database.php not found');
          }
        }
      }
    }
    return $this->databaseLevel;
  }

  public function fileLevel($fileLevel) {
    if ( isset($fileLevel) ) {
      $fileLevel = $this->limit($fileLevel);
      if ( $this->fileLevel != $fileLevel ) {
        if ( $this->fileLevel > self::NOLOG )
          $this->closeFile();
        $this->fileLevel = $fileLevel;
        if ( $this->fileLevel > self::NOLOG )
          $this->openFile();
      }
    }
    return $this->fileLevel;
  }

  public function weblogLevel($weblogLevel) {
    if ( isset($weblogLevel) ) {
      $weblogLevel = $this->limit($weblogLevel);
      if ( $this->weblogLevel != $weblogLevel ) {
        if ( $weblogLevel > self::NOLOG && $this->weblogLevel <= self::NOLOG ) {
          $this->savedLogErrors = ini_set('log_errors', true);
        } elseif ( $weblogLevel <= self::NOLOG && $this->weblogLevel > self::NOLOG ) {
          ini_set('log_errors', $this->savedLogErrors);
        }
        $this->weblogLevel = $weblogLevel;
      }
    }
    return $this->weblogLevel;
  }

  public function syslogLevel($syslogLevel) {
    if ( isset($syslogLevel) ) {
      $syslogLevel = $this->limit($syslogLevel);
      if ( $this->syslogLevel != $syslogLevel ) {
        if ( $this->syslogLevel > self::NOLOG )
          $this->closeSyslog();
        $this->syslogLevel = $syslogLevel;
        if ( $this->syslogLevel > self::NOLOG )
          $this->openSyslog();
      }
    }
    return $this->syslogLevel;
  }

  private function openSyslog() {
    openlog($this->id, LOG_PID|LOG_NDELAY, LOG_LOCAL1);
  }

  private function closeSyslog() {
    closelog();
  }

  private function logFile($logFile) {
    if ( preg_match('/^(.+)\+$/', $logFile, $matches) ) {
      $this->logFile = $matches[1].'.'.getmypid();
    } else {
      $this->logFile = $logFile;
    }
  }

  private function openFile() {
    if ( !$this->useErrorLog ) {
      if ( $this->logFd = fopen($this->logPath.'/'.$this->logFile, 'a+') ) {
        if ( strnatcmp(phpversion(), '5.2.0') >= 0 ) {
          $error = error_get_last();
          trigger_error("Can't open log file '$logFile': ".$error['message'].' @ '.$error['file'].'/'.$error['line'], E_USER_ERROR);
        }
        $this->fileLevel = self::NOLOG;
      }
    }
  }

  private function closeFile() {
    if ( $this->logFd )
      fclose($this->logFd);
  }

  public function logPrint($level, $string, $file=NULL, $line=NULL) {
    if ( $level > $this->effectiveLevel ) {
      return;
    }

    $string = preg_replace('/[\r\n]+$/', '', $string);
    $string = substr($string, 0, 65535); # Message Column has max length
    $code = self::$codes[$level];

    global $dateTimeFormatter;
    $time = gettimeofday();
    $message = sprintf('%s.%06d %s[%d].%s [%s] [%s]',
      $dateTimeFormatter->format($time['sec']), $time['usec'],
      $this->id, getmypid(), $code, (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : $_SERVER['REMOTE_ADDR']), $string);

    if ( is_null($file) ) {
      if ( $this->useErrorLog || ($this->databaseLevel > self::NOLOG) ) {
        $backTrace = debug_backtrace();
        $file = $backTrace[1]['file'];
        $line = $backTrace[1]['line'];
        if ( $this->hasTerm )
          $rootPath = getcwd();
        else
          $rootPath = $_SERVER['DOCUMENT_ROOT'];
        $file = preg_replace('/^'.addcslashes($rootPath, '/').'\/?/', '', $file);
      }
    }

    if ( $this->useErrorLog ) {
      $message .= ' at '.$file.' line '.$line;
    }

    if ( $level <= $this->termLevel ) {
      if ( $this->hasTerm ) {
        print($message."\n");
      } else {
        // Didn't we already replace all newlines with spaces above?
        print(preg_replace('/\n/', '<br/>', htmlspecialchars($message)).'<br/>');
      }
    }

    if ( $level <= $this->fileLevel ) {
      if ( $this->useErrorLog ) {
        if ( !error_log($message."\n", 3, $this->logPath.'/'.$this->logFile) ) {
          if ( strnatcmp(phpversion(), '5.2.0') >= 0 ) {
            $error = error_get_last();
            $this->fileLevel = self::NOLOG;
            Error("Can't write to log file '".$this->logFile."': ".$error['message'].' @ '.$error['file'].'/'.$error['line'], E_USER_ERROR);
          }
        }
      } else if ( $this->logFd ) {
        fprintf($this->logFd, $message."\n");
      } else {
        $this->fileLevel = self::NOLOG;
        Error('No logFd but have fileLevel logging!?');
      }
    }

    if ( $level <= $this->syslogLevel )
      syslog(self::$syslogPriorities[$level], $message);

    $message = $code.' ['.$string.']';
    if ( $level <= $this->databaseLevel ) {
      if ( strlen($file) > 255 )
        $file = substr($file, 0, 255);

      try {
        global $dbConn;
        $sql = 'INSERT INTO `Logs` ( `TimeKey`, `Component`, `ServerId`, `Pid`, `Level`, `Code`, `Message`, `File`, `Line` ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ? )';
        $stmt = $dbConn->prepare($sql);
        $result = $stmt->execute(array(sprintf('%d.%06d', $time['sec'], $time['usec']), $this->id,
          (defined('ZM_SERVER_ID') ? ZM_SERVER_ID : null), getmypid(), $level, $code, $string, $file, $line));
      } catch(PDOException $ex) {
        $this->databaseLevel = self::NOLOG;
        Error("Can't write log entry '$sql': ". $ex->getMessage());
      }
    }
    // This has to be last as trigger_error can be fatal
    if ( $level <= $this->weblogLevel ) {
      if ( $this->useErrorLog ) {
        error_log($message, 0);
      } else {
        trigger_error($message, self::$phpErrorLevels[$level]);
      }
    }
  }
};

function logInit( $options=array() ) {
  $logger = Logger::fetch();
  $logger->initialise( $options );
  set_error_handler( 'ZM\ErrorHandler' );
}

function logToDatabase( $level=NULL ) {
  return( Logger::fetch()->databaseLevel( $level ) );
}

function Mark( $level=Logger::DEBUG, $tag='Mark' ) {
  Logger::fetch()->logPrint( $level, $tag );
}

function Dump( &$var, $label='VAR' ) {
  ob_start();
  print( $label.' => ' );
  print_r( $var );
  Logger::fetch()->logPrint( Logger::DEBUG, ob_get_clean() );
}

function Debug( $string ) {
  Logger::fetch()->logPrint( Logger::DEBUG, $string );
}

function Info( $string ) {
  Logger::fetch()->logPrint( Logger::INFO, $string );
}

function Warning( $string ) {
  Logger::fetch()->logPrint( Logger::WARNING, $string );
}

function Error( $string ) {
  Logger::fetch()->logPrint( Logger::ERROR, $string );
}

function Fatal( $string ) {
  Logger::fetch()->logPrint( Logger::FATAL, $string );
  if (Logger::fetch()->debugOn()) {
    echo(htmlentities($string));
  }
  exit(1);
}

function Panic( $string ) {
  if ( true ) {
    // Use builtin function
    ob_start();
    debug_print_backtrace();
    $backtrace = "\n".ob_get_clean();
  } else {
    // Roll your own
    $backtrace = '';
    $frames = debug_backtrace();
    for ( $i = 0; $i < count($frames); $i++ ) {
      $frame = $frames[$i];
      $backtrace .= sprintf( "\n#%d %s() at %s/%d", $i, $frame['function'], $frame['file'], $frame['line'] );
    }
  }
  Logger::fetch()->logPrint( Logger::PANIC, $string.$backtrace );
  if (Logger::fetch()->debugOn()) {
    echo $string;
  }
  exit(1);
}

function Audit($string) {
  Logger::fetch()->logPrint(Logger::AUDIT, $string);
}

function AuditAction($action, $target_type, $target_id, $details) {
  global $user;
  $username = $user ? $user->Username() : 'system';
  $ip = !empty($_SERVER['HTTP_X_FORWARDED_FOR'])
    ? $_SERVER['HTTP_X_FORWARDED_FOR']
    : ($_SERVER['REMOTE_ADDR'] ?? 'local');
  Audit("user=$username action=$action target=$target_type"
    ." id=$target_id details=\"$details\" from=$ip");
}

function ErrorHandler( $error, $string, $file, $line ) {
  if ( ! (error_reporting() & $error) ) {
    // This error code is not included in error_reporting
    return false;
  }

  switch ( $error ) {
  case E_USER_ERROR:
    Logger::fetch()->logPrint( Logger::FATAL, $string, $file, $line );
    break;

  case E_USER_WARNING:
    Logger::fetch()->logPrint( Logger::ERROR, $string, $file, $line );
    break;

  case E_USER_NOTICE:
    Logger::fetch()->logPrint( Logger::WARNING, $string, $file, $line );
    break;

  default:
    Panic( "Unknown error type: [$error] $string" );
    break;
  }
  return true;
}

?>
