<?php
/**
 * This file is part of PHPDebugConsole
 *
 * @package   PHPDebugConsole
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2019 Brad Kent
 * @version   v2.1.0
 */

namespace bdk\Debug;

use bdk\Debug;

/**
 * Methods used store array/object/resource info
 */
class Abstracter
{

    public $debug;
    protected $cfg = array();
    protected $abstractArray;
    protected $abstractObject;

    const ABSTRACTION = "\x00debug\x00";
    const RECURSION = "\x00recursion\x00";  // ie, array recursion
    const UNDEFINED = "\x00undefined\x00";

    /**
     * Constructor
     *
     * @param Debug $debug debug instance
     * @param array $cfg   config options
     */
    public function __construct(Debug $debug, $cfg = array())
    {
        $this->debug = $debug;  // we need debug instance so we can bubble events up channels
        $this->cfg = array(
            'cacheMethods' => true,
            'collectConstants' => true,
            'collectMethods' => true,
            'objectsExclude' => array(
                __NAMESPACE__,
            ),
            'objectSort' => 'visibility',   // none, visibility, or name
            'useDebugInfo' => true,
        );
        $this->setCfg($cfg);
        $this->abstractArray = new AbstractArray($this);
        $this->abstractObject = new AbstractObject($this, new PhpDoc());
    }

    /**
     * Magic getter
     *
     * Used to get constants via -> operator
     * which is more friendly for dependency injection
     *
     * @param string $name name of property or constant
     *
     * @return mixed
     */
    public function __get($name)
    {
        if (\defined(__CLASS__.'::'.$name)) {
            return \constant(__CLASS__.'::'.$name);
        }
    }

    /**
     * Retrieve a config or data value
     *
     * @param string $path what to get
     *
     * @return mixed
     */
    public function getCfg($path = null)
    {
        if (!\strlen($path)) {
            return $this->cfg;
        }
        if (isset($this->cfg[$path])) {
            return $this->cfg[$path];
        }
        return null;
    }

    /**
     * Want to store a "snapshot" of arrays, objects, & resources
     * Remove any reference to an "external" variable
     *
     * Deep cloning objects = problematic
     *   + some objects are uncloneable & throw fatal error
     *   + difficult to maintain circular references
     * Instead of storing objects in log, store "abstraction" array containing
     *     type, methods, & properties
     *
     * @param mixed  $mixed  array, object, or resource to prep
     * @param string $method Method requesting abstraction
     * @param array  $hist   (@internal) array/object history (used to test for recursion)
     *
     * @return array
     */
    public function getAbstraction(&$mixed, $method = null, $hist = array())
    {
        if (\is_array($mixed)) {
            return $this->abstractArray->getAbstraction($mixed, $method, $hist);
        } elseif (\is_object($mixed)) {
            return $this->abstractObject->getAbstraction($mixed, $method, $hist);
        } elseif (\is_resource($mixed) || \strpos(\print_r($mixed, true), 'Resource') === 0) {
            return array(
                'debug' => self::ABSTRACTION,
                'type' => 'resource',
                'value' => \print_r($mixed, true).': '.\get_resource_type($mixed),
            );
        }
    }

    /**
     * Returns value's type
     *
     * @param mixed  $val      value
     * @param string $typeMore will be populated with additional type info ("numeric"/"binary")
     *
     * @return string
     */
    public static function getType($val, &$typeMore = null)
    {
        if (\is_string($val)) {
            $type = 'string';
            if (\is_numeric($val)) {
                $typeMore = 'numeric';
            } elseif ($val === self::UNDEFINED) {
                $type = 'undefined';    // not a native php type!
            } elseif ($val === self::RECURSION) {
                $type = 'recursion';    // not a native php type!
            }
        } elseif (\is_array($val)) {
            if (\in_array(self::ABSTRACTION, $val, true)) {
                $type = $val['type'];
                $typeMore = 'abstraction';
            } elseif (AbstractArray::isCallable($val)) {
                $type = 'callable';
                $typeMore = 'raw';  // needs abstracted
            } else {
                $type = 'array';
                $typeMore = 'raw';  // needs abstracted
            }
        } elseif (\is_bool($val)) {
            $type = 'bool';
            $typeMore = \json_encode($val);
        } elseif (\is_float($val)) {
            $type = 'float';
        } elseif (\is_int($val)) {
            $type = 'int';
        } elseif (\is_null($val)) {
            $type = 'null';
        } elseif (\is_object($val)) {
            $type = 'object';
            $typeMore = 'raw';  // needs abstracted
        } elseif (\is_resource($val) || \strpos(\print_r($val, true), 'Resource') === 0) {
            // is_resource() returns false for a closed resource
            // (it's also not a string)
            $type = 'resource';
            $typeMore = 'raw';  // needs abstracted
        }
        return $type;
    }

    /**
     * Is the passed value an abstraction
     *
     * @param mixed $mixed value to check
     *
     * @return boolean
     */
    public static function isAbstraction($mixed)
    {
        return \is_array($mixed) && isset($mixed['debug']) && $mixed['debug'] === self::ABSTRACTION;
    }

    /**
     * Is the passed value an array, object, or resource that needs abstracted?
     *
     * @param mixed $val value to check
     *
     * @return boolean
     */
    public static function needsAbstraction($val)
    {
        self::getType($val, $typeMore);
        return $typeMore === 'raw';
    }

    /**
     * Set one or more config values
     *
     *    setCfg('key', 'value')
     *    setCfg(array('k1'=>'v1', 'k2'=>'v2'))
     *
     * @param string $mixed  key=>value array or key
     * @param mixed  $newVal value
     *
     * @return mixed old value(s)
     */
    public function setCfg($mixed, $newVal = null)
    {
        $ret = null;
        if (\is_string($mixed)) {
            $ret = isset($this->cfg[$mixed])
                ? $this->cfg[$mixed]
                : null;
            $this->cfg[$mixed] = $newVal;
        } elseif (\is_array($mixed)) {
            $ret = \array_intersect_key($this->cfg, $mixed);
            $this->cfg = \array_merge($this->cfg, $mixed);
        }
        if (!\in_array(__NAMESPACE__, $this->cfg['objectsExclude'])) {
            $this->cfg['objectsExclude'][] = __NAMESPACE__;
        }
        return $ret;
    }
}


<?php
/**
 * This file is part of PHPDebugConsole
 *
 * @package   PHPDebugConsole
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2019 Brad Kent
 * @version   v2.3
 *
 * @link http://www.github.com/bkdotcom/PHPDebugConsole
 * @link https://developer.mozilla.org/en-US/docs/Web/API/console
 */

namespace bdk;

use bdk\ErrorHandler;
use bdk\ErrorHandler\ErrorEmailer;
use bdk\PubSub\SubscriberInterface;
use bdk\PubSub\Event;
use bdk\PubSub\Manager as EventManager;
use ReflectionMethod;

/**
 * Web-browser/javascript like console class for PHP
 *
 * @property Abstracter    $abstracter    lazy-loaded Abstracter instance
 * @property ErrorEmailer  $errorEmailer  lazy-loaded ErrorEmailer instance
 * @property ErrorHandler  $errorHandler  lazy-loaded ErrorHandler instance
 * @property EventManager  $eventManager  lazy-loaded EventManager instance
 * @property Internal      $internal      lazy-loaded Internal instance
 * @property Logger        $logger        lazy-loaded PSR-3 instance
 * @property MethodClear   $methodClear   lazy-loaded MethodClear instance
 * @property MethodProfile $methodProfile lazy-loaded MethodProfile instance
 * @property MethodTable   $methodTable   lazy-loaded MethodTable instance
 * @property Output        $output        lazy-loaded Output instance
 * @property Utf8          $utf8          lazy-loaded Utf8 instance
 * @property Utilities     $utilities     lazy-loaded Utilities instance
 */
class Debug
{

    private static $instance;
    private $channels = array();
    protected $cfg = array();
    protected $data = array();
    protected $groupStackRef;   // points to $this->groupStacks[x] (where ex = 'main' or (int) priority)
    protected $logRef;          // points to either log or logSummary[priority]
    protected $config;          // config instance
    protected $parentInstance;
    protected $rootInstance;
    protected static $methodDefaultArgs = array();

    const CLEAR_ALERTS = 1;
    const CLEAR_LOG = 2;
    const CLEAR_LOG_ERRORS = 4;
    const CLEAR_SUMMARY = 8;
    const CLEAR_SUMMARY_ERRORS = 16;
    const CLEAR_ALL = 31;
    const CLEAR_SILENT = 32;
    const COUNT_NO_INC = 1;
    const COUNT_NO_OUT = 2;
    const META = "\x00meta\x00";
    const VERSION = '2.3';

    /**
     * Constructor
     *
     * @param array        $cfg          config
     * @param EventManager $eventManager optional - specify EventManager instance
     *                                      will use new instance if not specified
     * @param ErrorHandler $errorHandler optional - specify ErrorHandler instance
     *                                      if not specified, will use singleton or new instance
     */
    public function __construct($cfg = array(), EventManager $eventManager = null, ErrorHandler $errorHandler = null)
    {
        $this->cfg = array(
            'collect'   => false,
            'file'      => null,            // if a filepath, will receive log data
            'key'       => null,
            'output'    => false,           // output the log?
            'channel'   => 'general',
            'enableProfiling' => false,
            // which error types appear as "error" in debug console... all other errors are "warn"
            'errorMask' => E_ERROR | E_PARSE | E_COMPILE_ERROR | E_CORE_ERROR
                            | E_WARNING | E_USER_ERROR | E_RECOVERABLE_ERROR,
            'emailFrom' => null,    // null = use php's default (php.ini: sendmail_from)
            'emailFunc' => 'mail',  // callable
            'emailLog'  => false,   // Whether to email a debug log.  (requires 'collect' to also be true)
                                    //
                                    //   false:   email will not be sent
                                    //   true or 'onError':   email will be sent (if log is not output)
                                    //   'always':  email sent regardless of whether error occured or log output
            'emailTo'   => !empty($_SERVER['SERVER_ADMIN'])
                ? $_SERVER['SERVER_ADMIN']
                : null,
            'logEnvInfo' => array(
                'cookies' => true,
                'headers' => true,
                'phpInfo' => true,
                'post' => true,
                'serverVals' => true,
            ),
            'logServerKeys' => array('REMOTE_ADDR','REQUEST_TIME','REQUEST_URI','SERVER_ADDR','SERVER_NAME'),
            'onLog' => null,    // callable
            'factories' => $this->getDefaultFactories(),
            'services' => $this->getDefaultServices(),
        );
        if (!isset(self::$instance)) {
            /*
               self::getInstance() will always return initial/first instance
            */
            self::$instance = $this;
            /*
                Only register autloader:
                  a. on initial instance (even though re-registering function does't re-register)
                  b. if we're unable to to find our Config class (must not be using Composer)
            */
            if (!\class_exists('\\bdk\\Debug\\Config')) {
                \spl_autoload_register(array($this, 'autoloader'));
            }
        }
        if ($eventManager) {
            $cfg['services']['eventManager'] = $eventManager;
        }
        if ($errorHandler) {
            $cfg['services']['errorHandler'] = $errorHandler;
        }
        $this->internal;
        $this->__get('config')->setCfg($cfg);   // since is defined (albeit null), we need to call __get to initialize
        if (isset($this->cfg['parent'])) {
            $this->parentInstance = $this->cfg['parent'];
            unset($this->cfg['parent']);
        }
        $this->data = array(
            'alerts'            => array(), // alert entries.  alerts will be shown at top of output when possible
            'counts'            => array(), // count method
            'entryCountInitial' => 0,       // store number of log entries created during init
            'groupStacks' => array(
                'main' => array(),  // array('channel' => xxx, 'collect' => xxx)[]
            ),
            'groupPriorityStack' => array(), // array of priorities
                                            //   used to return to the previous summary when groupEnd()ing out of a summary
                                            //   this allows calling groupSummary() while in a groupSummary
            'headers'           => array(), // headers that need to be output (ie chromeLogger & firePhp)
            'log'               => array(),
            'logSummary'        => array(), // summary log entries subgrouped by priority
            'outputSent'        => false,
            'profileAutoInc'    => 1,
            'profileInstances'  => array(),
            'requestId'         => $this->utilities->requestId(),
            'runtime'           => array(
                // memoryPeakUsage, memoryLimit, & memoryLimit get stored here
            ),
            'timers' => array(      // timer method
                'labels' => array(
                    // label => array(accumulatedTime, lastStartedTime|null)
                    'debugInit' => array(
                        0,
                        isset($_SERVER['REQUEST_TIME_FLOAT'])
                            ? $_SERVER['REQUEST_TIME_FLOAT']
                            : \microtime(true)
                    ),
                ),
                'stack' => array(),
            ),
        );
        $this->rootInstance = $this;
        if (!$this->parentInstance) {
            $this->setLogDest();
            $this->data['entryCountInitial'] = \count($this->data['log']);
        } else {
            while ($this->rootInstance->parentInstance) {
                $this->rootInstance = $this->rootInstance->parentInstance;
            }
            $this->data = &$this->rootInstance->data;
        }
        /*
            Publish bootstrap event
        */
        $this->eventManager->publish('debug.bootstrap', $this);
    }

    /**
     * Magic method... inaccessible method called.
     *
     * If method not found in internal class, treat as a custom method.
     *
     * @param string $methodName Inaccessible method name
     * @param array  $args       Arguments passed to method
     *
     * @return mixed
     */
    public function __call($methodName, $args)
    {
        if (\method_exists($this->internal, $methodName)) {
            \call_user_func_array(array($this->internal, $methodName), $args);
        }
        return $this->appendLog(
            $methodName,
            $args,
            array('isCustomMethod' => true)
        );
    }

    /**
     * Magic method to allow us to call instance methods statically
     *
     * Prefix the instance method with an underscore ie
     *    \bdk\Debug::_log('logged via static method');
     *
     * @param string $methodName Inaccessible method name
     * @param array  $args       Arguments passed to method
     *
     * @return mixed
     */
    public static function __callStatic($methodName, $args)
    {
        $methodName = \ltrim($methodName, '_');
        if (!self::$instance) {
            new static();
        }
        /*
            Add 'statically' meta arg
            Not all methods expect meta args... so make sure it comes after expected args
        */
        $defaultArgs = self::getMethodDefaultArgs($methodName);
        $args = \array_replace($defaultArgs, $args);
        $args[] = self::meta('statically');
        return \call_user_func_array(array(self::$instance, $methodName), $args);
    }

    /**
     * Magic method to get inaccessible / undefined properties
     * Lazy load child classes
     *
     * @param string $property property name
     *
     * @return property value
     */
    public function __get($property)
    {
        if (isset($this->cfg['services'][$property])) {
            $val = $this->cfg['services'][$property];
            if (\is_object($val) && \method_exists($val, '__invoke')) {
                $val = $val($this);
            }
            $this->{$property} = $val;
            return $val;
        } elseif (isset($this->cfg['factories'][$property])) {
            $val = $this->cfg['factories'][$property];
            if (\is_object($val) && \method_exists($val, '__invoke')) {
                $val = $val($this);
            }
            return $val;
        }
        if (isset($this->{$property})) {
            return $this->{$property};
        }
        $getter = 'get'.\ucfirst($property);
        if (\method_exists($this, $getter)) {
            return $this->{$getter}();
        }
        return null;
    }

    /*
        Debugging Methods
    */

    /**
     * Add an alert to top of log
     *
     * @param string  $message     message
     * @param string  $class       (danger), info, success, warning
     * @param boolean $dismissible (false)
     *
     * @return void
     */
    public function alert($message, $class = 'danger', $dismissible = false)
    {
        // "use" our function params so things don't complain
        array($class, $dismissible);
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel']),
            array(
                'message' => null,
                'class' => 'danger',
                'dismissible' => false,
            ),
            array('class','dismissible')
        );
        \extract($args);
        $this->setLogDest('alerts');
        $this->appendLog(
            'alert',
            array($message),
            $meta
        );
        $this->setLogDest('auto');
    }

    /**
     * Log a message and stack trace to console if first argument is false.
     *
     * Only appends log when assertion fails
     *
     * Supports styling/substitutions
     *
     * @param boolean $assertion argument checked for truthyness
     * @param mixed   $msg,...   (optional) variable num of addititional values to output
     *
     * @return void
     */
    public function assert($assertion, $msg = null)
    {
        // "use" our function params so things don't complain
        array($msg);
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals($args);
        $assertion = \array_shift($args);
        if (!$assertion) {
            if (!$args) {
                $callerInfo = $this->utilities->getCallerInfo();
                $args[] = 'Assertion failed in '.$callerInfo['file'].' on line '.$callerInfo['line'];
            }
            $this->appendLog('assert', $args, $meta);
        }
    }

    /**
     * Clear the log
     *
     * This method executes even if `collect` is false
     *
     * @param integer $flags (self::CLEAR_LOG) specify what to clear (bitmask)
     *                         CLEAR_ALERTS
     *                         CLEAR_LOG (excluding warn & error)
     *                         CLEAR_LOG_ERRORS
     *                         CLEAR_SUMMARY (excluding warn & error)
     *                         CLEAR_SUMMARY_ERRORS
     *                         CLEAR_ALL
     *                         CLEAR_SILENT (don't add log entry)
     *
     * @return void
     */
    public function clear($flags = self::CLEAR_LOG)
    {
        // "use" our function params so things don't complain
        array($flags);
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel']),
            array('flags' => self::CLEAR_LOG),
            array('flags' => 'bitmask')
        );
        $event = $this->methodClear->onLog(new Event($this, array(
            'method' => __FUNCTION__,
            'args' => array(),
            'meta' => $meta,
        )));
        // even if cleared from within summary, let's log this in primary log
        $this->setLogDest('log');
        $collect = $this->cfg['collect'];
        $this->cfg['collect'] = true;
        if ($event['log']) {
            $this->appendLog(
                $event['method'],
                $event['args'],
                $event['meta']
            );
        } elseif ($event['publish']) {
            /*
                Publish the debug.log event (regardless of cfg.collect)
                don't actually log
            */
            $this->internal->publishBubbleEvent('debug.log', $event);
        }
        $this->cfg['collect'] = $collect;
        $this->setLogDest('auto');
    }

    /**
     * Log the number of times this has been called with the given label.
     *
     * If `label` is omitted, logs the number of times `count()` has been called at this particular line.
     *
     * Count is maintained even when `collect` is false
     *
     * @param mixed   $label label
     * @param integer $flags (optional)
     *                          A bitmask of
     *                          \bdk\Debug::COUNT_NO_INC : don't increment the counter
     *                                                       (ie, just get the current count)
     *                          \bdk\Debug::COUNT_NO_OUT : don't output/log
     *
     * @return integer The count
     */
    public function count($label = null, $flags = 0)
    {
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel'])
        );
        // label may be ommitted and only flags passed as a single argument
        //   (excluding potential meta argument)
        if (\count($args) == 1 && \is_int($args[0])) {
            $label = null;
            $flags = $args[0];
        } else {
            $args = \array_combine(
                array('label', 'flags'),
                \array_replace(array(null, 0), $args)
            );
            \extract($args);
        }
        if (isset($label)) {
            $dataLabel = (string) $label;
        } else {
            // determine calling file & line
            $callerInfo = $this->utilities->getCallerInfo();
            $meta = \array_merge(array(
                'file' => $callerInfo['file'],
                'line' => $callerInfo['line'],
            ), $meta);
            $label = 'count';
            $dataLabel = $meta['file'].': '.$meta['line'];
        }
        if (!isset($this->data['counts'][$dataLabel])) {
            $this->data['counts'][$dataLabel] = 0;
        }
        if (!($flags & self::COUNT_NO_INC)) {
            $this->data['counts'][$dataLabel]++;
        }
        $count = $this->data['counts'][$dataLabel];
        if (!($flags & self::COUNT_NO_OUT)) {
            $this->appendLog(
                'count',
                array(
                    (string) $label,
                    $count,
                ),
                $meta
            );
        }
        return $count;
    }

    /**
     * Resets the counter.
     *
     * @param mixed   $label label
     * @param integer $flags (optional)
     *                          currently only one option
     *                          \bdk\Debug::COUNT_NO_OUT : don't output/log
     *
     * @return void
     */
    public function countReset($label = 'default', $flags = null)
    {
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel'])
        );
        // label may be ommitted and only flags passed as a single argument
        //   (excluding potential meta argument)
        if (\count($args) == 1 && \is_int($args[0])) {
            $label = 'default';
            $flags = $args[0];
        } else {
            $args = \array_combine(
                array('label', 'flags'),
                \array_replace(array('default', 0), $args)
            );
            \extract($args);
        }
        if (isset($this->data['counts'][$label])) {
            $this->data['counts'][$label] = 0;
            $args = array(
                (string) $label,
                0,
            );
        } else {
            $args = array('Counter \''.$label.'\' doesn\'t exist.');
        }
        if (!($flags & self::COUNT_NO_OUT)) {
            $this->appendLog(
                'countReset',
                $args,
                $meta
            );
        }
    }

    /**
     * Log an error message.
     *
     * Supports styling/substitutions
     *
     * @param mixed $label,... error message / values
     *
     * @return void
     */
    public function error()
    {
        $this->appendLog(
            'error',
            \func_get_args(),
            $this->internal->getErrorCaller()
        );
    }

    /**
     * Create a new inline group
     *
     * @param mixed $label,... label / values
     *
     * @return void
     */
    public function group()
    {
        $this->doGroup('group', \func_get_args());
    }

    /**
     * Create a new inline group
     *
     * @param mixed $label,... label / values
     *
     * @return void
     */
    public function groupCollapsed()
    {
        $this->doGroup('groupCollapsed', \func_get_args());
    }

    /**
     * Close current group
     *
     * @param mixed $value Value
     *
     * @return void
     */
    public function groupEnd($value = \bdk\Debug\Abstracter::UNDEFINED)
    {
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel']),
            array('value' => \bdk\Debug\Abstracter::UNDEFINED)
        );
        \extract($args);
        $groupStackWas = $this->rootInstance->groupStackRef;
        $appendLog = false;
        if ($groupStackWas && \end($groupStackWas)['collect'] == $this->cfg['collect']) {
            \array_pop($this->rootInstance->groupStackRef);
            $appendLog = $this->cfg['collect'];
        }
        if ($appendLog && $value !== \bdk\Debug\Abstracter::UNDEFINED) {
            $this->appendLog(
                'groupEndValue',
                array('return', $value),
                $meta
            );
        }
        if ($this->data['groupPriorityStack'] && !$groupStackWas) {
            // we're closing a summary group
            $priorityClosing = \array_pop($this->data['groupPriorityStack']);
            // not really necessary to remove this empty placeholder, but lets keep things tidy
            unset($this->data['groupStacks'][$priorityClosing]);
            $this->setLogDest('auto');
            /*
                Publish the debug.log event (regardless of cfg.collect)
                don't actually log
            */
            $meta['closesSummary'] = true;
            $this->internal->publishBubbleEvent(
                'debug.log',
                $this,
                array(
                    'method' => __FUNCTION__,
                    'args' => array(),
                    'meta' => $meta,
                )
            );
        } elseif ($appendLog) {
            $this->appendLog('groupEnd', array(), $meta);
        }
        $errorCaller = $this->errorHandler->get('errorCaller');
        if ($errorCaller && isset($errorCaller['groupDepth']) && $this->getGroupDepth() < $errorCaller['groupDepth']) {
            $this->errorHandler->setErrorCaller(false);
        }
    }

    /**
     * Initiate the beginning of "summary" log entries
     *
     * Debug methods called while a groupSummary is open will appear at the top of the log
     * call groupEnd() to close summary
     *
     * groupSummary can be used multiple times
     * All groupSummary groups will appear together in a single group
     *
     * @param integer $priority (0) The higher the priority, the ealier it will appear.
     *
     * @return void
     */
    public function groupSummary($priority = 0)
    {
        // "use" our function params so things don't complain
        array($priority);
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel']),
            array('priority' => 0),
            array('priority')
        );
        $this->data['groupPriorityStack'][] = $meta['priority'];
        $this->setLogDest('summary');
        /*
            Publish the debug.log event (regardless of cfg.collect)
            don't actually log
        */
        $this->internal->publishBubbleEvent(
            'debug.log',
            $this,
            array(
                'method' => __FUNCTION__,
                'args' => array(),
                'meta' => $meta,
            )
        );
    }

    /**
     * Set ancestor groups to uncollapsed
     *
     * This will only occur if `cfg['collect']` is currently true
     *
     * @return void
     */
    public function groupUncollapse()
    {
        if (!$this->cfg['collect']) {
            return;
        }
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel'])
        );
        $curDepth = 0;
        foreach ($this->rootInstance->groupStackRef as $group) {
            $curDepth += (int) $group['collect'];
        }
        $entryKeys = \array_keys($this->internal->getCurrentGroups($this->rootInstance->logRef, $curDepth));
        foreach ($entryKeys as $key) {
            $this->rootInstance->logRef[$key][0] = 'group';
        }
        /*
            Publish the debug.log event (regardless of cfg.collect)
            don't actually log
        */
        $this->internal->publishBubbleEvent(
            'debug.log',
            $this,
            array(
                'method' => __FUNCTION__,
                'args' => array(),
                'meta' => $meta,
            )
        );
    }

    /**
     * Log some informative information
     *
     * Supports styling/substitutions
     *
     * @return void
     */
    public function info()
    {
        $this->appendLog('info', \func_get_args());
    }

    /**
     * Log general information
     *
     * Supports styling/substitutions
     *
     * @return void
     */
    public function log()
    {
        $this->appendLog('log', \func_get_args());
    }

    /**
     * Starts recording a performance profile
     *
     * @param string $name Optional Profile name
     *
     * @return void
     */
    public function profile($name = null)
    {
        if (!$this->cfg['collect']) {
            return;
        }
        if (!$this->cfg['enableProfiling']) {
            $callerInfo = $this->utilities->getCallerInfo();
            $this->appendLog(
                __FUNCTION__,
                array('Profile: Unable to start - enableProfiling opt not set.  ' . $callerInfo['file'] .' on line ' . $callerInfo['line'] . '.')
            );
            return;
        }
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel']),
            array('name' => null),
            array('name')   // move name to meta
        );
        if ($meta['name'] === null) {
            $meta['name'] = 'Profile '.$this->data['profileAutoInc'];
            $this->data['profileAutoInc']++;
        }
        $name = $meta['name'];
        $message = '';
        if (isset($this->data['profileInstances'][$name])) {
            $instance = $this->data['profileInstances'][$name];
            $instance->end();
            $instance->start();
            // move it to end (last started)
            unset($this->data['profileInstances'][$name]);
            $this->data['profileInstances'][$name] = $instance;
            $message = 'Profile \''.$name.'\' restarted';
        } else {
            $this->data['profileInstances'][$name] = $this->methodProfile; // factory
            $message = 'Profile \''.$name.'\' started';
        }
        $this->appendLog(
            __FUNCTION__,
            array(
                $message,
            ),
            $meta
        );
    }

    /**
     * Stops recording profile info & adds info to the log
     *
     *  * if name is passed and it matches the name of a profile being recorded, then that profile is stopped.
     *  * if name is passed and it does not match the name of a profile being recorded, nothing will be done
     *  * if name is not passed, the most recently started profile is stopped (named, or non-named).
     *
     * @param string $name Optional Profile name
     *
     * @return void
     */
    public function profileEnd($name = null)
    {
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel']),
            array('name' => null),
            array('name')
        );
        if ($meta['name'] === null) {
            \end($this->data['profileInstances']);
            $meta['name'] = \key($this->data['profileInstances']);
        }
        $name = $meta['name'];
        if (isset($this->data['profileInstances'][$name])) {
            $instance = $this->data['profileInstances'][$name];
            $data = $instance->end();
            $caption = 'Profile \''.$name.'\' Results';
            if ($data) {
                $args = array( $data );
                $meta['sortable'] = true;
                $meta['caption'] = $caption;
                $meta['totalCols'] = array('ownTime');
                $meta['columns'] = array();
            } else {
                $args = array($caption, 'no data');
            }
            unset($this->data['profileInstances'][$name]);
        } else {
            $args = array( $name !== null
                ? 'profileEnd: No such Profile: '.$name
                : 'profileEnd: Not currently profiling'
            );
        }
        $this->appendLog(
            __FUNCTION__,
            $args,
            $meta
        );
    }

    /**
     * Output array as a table
     *
     * Accepts array of arrays or array of objects
     *
     * Arguments:
     *   1st encountered array (or traversable) is the data
     *   2nd encountered array (optional) specifies columns to output
     *   1st encountered string is a label/caption
     *
     * @return void
     */
    public function table()
    {
        if (!$this->cfg['collect']) {
            return;
        }
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel'])
        );
        $event = $this->methodTable->onLog(new Event($this, array(
            'method' => __FUNCTION__,
            'args' => $args,
            'meta' => $meta,
        )));
        $this->appendLog(
            $event['method'],
            $event['args'],
            $event['meta']
        );
    }

    /**
     * Start a timer identified by label
     *
     * Label passed
     *    if doesn't exist: starts timer
     *    if does exist: unpauses (does not reset)
     * Label not passed
     *    timer will be added to a no-label stack
     *
     * Does not append log.  Use timeEnd or timeGet to get time
     *
     * @param string $label unique label
     *
     * @return void
     */
    public function time($label = null)
    {
        $args = \func_get_args();
        $this->internal->getMetaVals(
            $args,
            array(),
            array('label' => null)
        );
        \extract($args);
        if (isset($label)) {
            $timers = &$this->data['timers']['labels'];
            if (!isset($timers[$label])) {
                // new label
                $timers[$label] = array(0, \microtime(true));
            } elseif (!isset($timers[$label][1])) {
                // no microtime -> the timer is currently paused -> unpause
                $timers[$label][1] = \microtime(true);
            }
        } else {
            $this->data['timers']['stack'][] = \microtime(true);
        }
    }

    /**
     * Behaves like a stopwatch.. returns running time
     *    If label is passed, timer is "paused"
     *    If label is not passed, timer is removed from timer stack
     *
     * @param string         $label            unique label
     * @param string|boolean $returnOrTemplate string: "%label: %time"
     *                                         boolean:  If true, only return time, rather than log it
     * @param integer        $precision        rounding precision (pass null for no rounding)
     *
     * @return float|string (numeric)
     */
    public function timeEnd($label = null, $returnOrTemplate = false, $precision = 4)
    {
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel']),
            array(
                'label' => null,
                'returnOrTemplate' => false,
                'precision' => 4,
            )
        );
        \extract($args);
        if (\is_bool($label) || \strpos($label, '%time') !== false) {
            if (\is_numeric($returnOrTemplate)) {
                $precision = $returnOrTemplate;
            }
            $returnOrTemplate = $label;
            $label = null;
        }
        $ret = $this->timeGet($label, true, null); // get non-rounded running time
        if (isset($label)) {
            if (isset($this->data['timers']['labels'][$label])) {
                $this->data['timers']['labels'][$label] = array(
                    $ret,  // store the new "running" time
                    null,  // "pause" the timer
                );
            }
        } else {
            $label = 'time';
            \array_pop($this->data['timers']['stack']);
        }
        if (\is_int($precision)) {
            // use number_format rather than round(), which may still run decimals-a-plenty
            $ret = \number_format($ret, $precision, '.', '');
        }
        $this->doTime($ret, $returnOrTemplate, $label, $meta);
        return $ret;
    }

    /**
     * Log/get the running time without stopping/pausing the timer
     *
     * This method does not have a web console API equivalent
     *
     * @param string         $label            (optional) unique label
     * @param string|boolean $returnOrTemplate string: "%label: %time"
     *                                         boolean:  If true, only return time, rather than log it
     * @param integer        $precision        rounding precision (pass null for no rounding)
     *
     * @return float|string|false returns false if specified label does not exist
     */
    public function timeGet($label = null, $returnOrTemplate = false, $precision = 4)
    {
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel']),
            array(
                'label' => null,
                'returnOrTemplate' => false,
                'precision' => 4,
            )
        );
        \extract($args);
        if (\is_bool($label) || \strpos($label, '%time') !== false) {
            if (\is_numeric($returnOrTemplate)) {
                $precision = $returnOrTemplate;
            }
            $returnOrTemplate = $label;
            $label = null;
        }
        $microT = 0;
        $ellapsed = 0;
        if (!isset($label)) {
            $label = 'time';
            if (!$this->data['timers']['stack']) {
                list($ellapsed, $microT) = $this->data['timers']['labels']['debugInit'];
            } else {
                $microT = \end($this->data['timers']['stack']);
            }
        } elseif (isset($this->data['timers']['labels'][$label])) {
            list($ellapsed, $microT) = $this->data['timers']['labels'][$label];
        } else {
            if ($returnOrTemplate !== true) {
                $this->appendLog(
                    'time',
                    array('Timer \''.$label.'\' does not exist'),
                    $meta
                );
            }
            return false;
        }
        if ($microT) {
            $ellapsed += \microtime(true) - $microT;
        }
        if (\is_int($precision)) {
            // use number_format rather than round(), which may still run decimals-a-plenty
            $ellapsed = \number_format($ellapsed, $precision, '.', '');
        }
        $this->doTime($ellapsed, $returnOrTemplate, $label, $meta);
        return $ellapsed;
    }

    /**
     * Log the running time without stopping/pausing the timer
     * also logs additional arguments
     *
     * Added to web console api in Firefox 62
     * Added to PHPDebugConsole in v2.3
     *
     * @param string $label   (optional) unique label
     * @param mixed  $arg,... (optional) additional values to be logged with time
     *
     * @return void
     */
    public function timeLog($label = null, $args = null)
    {
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel'])
        );
        $microT = 0;
        $ellapsed = 0;
        if (\count($args) === 0) {
            $args[0] = 'time';
            if (!$this->data['timers']['stack']) {
                list($ellapsed, $microT) = $this->data['timers']['labels']['debugInit'];
            } else {
                $microT = \end($this->data['timers']['stack']);
            }
        } elseif (isset($this->data['timers']['labels'][$label])) {
            list($ellapsed, $microT) = $this->data['timers']['labels'][$label];
        } else {
            $args = array('Timer \''.$label.'\' does not exist');
        }
        if ($microT) {
            $args[0] .= ': ';
            $ellapsed += \microtime(true) - $microT;
            $ellapsed = \number_format($ellapsed, 4, '.', '');
            \array_splice($args, 1, 0, $ellapsed.' sec');
        }
        $this->appendLog(
            'timeLog',
            $args,
            $meta
        );
    }

    /**
     * Log a stack trace
     *
     * @return void
     */
    public function trace()
    {
        if (!$this->cfg['collect']) {
            return;
        }
        $args = \func_get_args();
        $meta = $this->internal->getMetaVals(
            $args,
            array(
                'caption' => 'trace',
                'channel' => $this->cfg['channel'],
                'columns' => array('file','line','function'),
            )
        );
        $backtrace = $this->errorHandler->backtrace();
        // toss "internal" frames
        for ($i = 1, $count = \count($backtrace)-1; $i < $count; $i++) {
            $frame = $backtrace[$i];
            $function = isset($frame['function']) ? $frame['function'] : '';
            if (!\preg_match('/^'.\preg_quote(__CLASS__).'(::|->)/', $function)) {
                break;
            }
        }
        $backtrace = \array_slice($backtrace, $i-1);
        // keep the calling file & line, but toss ->trace or ::_trace
        unset($backtrace[0]['function']);
        $this->appendLog('trace', array($backtrace), $meta);
    }

    /**
     * Log a warning
     *
     * Supports styling/substitutions
     *
     * @return void
     */
    public function warn()
    {
        $this->appendLog(
            'warn',
            \func_get_args(),
            $this->internal->getErrorCaller()
        );
    }

    /*
        "Non-Console" Methods
    */

    /**
     * Extend debug with a plugin
     *
     * @param SubscriberInterface $plugin object implementing SubscriberInterface
     *
     * @return void
     */
    public function addPlugin(SubscriberInterface $plugin)
    {
        $this->eventManager->addSubscriberInterface($plugin);
    }

    /**
     * Retrieve a configuration value
     *
     * @param string $path what to get
     *
     * @return mixed
     */
    public function getCfg($path = null)
    {
        return $this->config->getCfg($path);
    }

    /**
     * Return a named subinstance... if channel does not exist, it will be created
     *
     * Channels can be used to categorize log data... for example, may have a framework channel, database channel, library-x channel, etc
     * Channels may have subchannels
     *
     * @param string $channelName channel name
     * @param array  $config      channel specific configuration
     *
     * @return Debug
     */
    public function getChannel($channelName, $config = array())
    {
        if (\strpos($channelName, '.') !== false) {
            $this->error('getChannel(): channelName should not contain period (.)');
            return $this;
        }
        if (!isset($this->channels[$channelName])) {
            // get inherited config
            $cfg = $this->getCfg();
            // remove config values that channel should not inherit
            $cfg = \array_diff_key($cfg, \array_flip(array(
                'errorEmailer',
                'errorHandler',
                'output',
            )));
            unset($cfg['debug']['onBootstrap']);
            // set channel values
            $cfg['debug']['channel'] = $this->parentInstance
                ? $this->cfg['channel'].'.'.$channelName
                : $channelName;
            $cfg['debug']['parent'] = $this;
            // instantiate channel
            $this->channels[$channelName] = new static($cfg);
            // now update config with passed config
            //   since passed config not yet "normalized", merging above not possible
            if ($config) {
                $this->channels[$channelName]->setCfg($config);
            }
        }
        return $this->channels[$channelName];
    }

    /**
     * Return array of channels
     *
     * If $allDescendants == true :  key = "fully qualified" channel name
     *
     * Does not return self
     *
     * @param boolean $allDescendants (false) include all descendants?
     *
     * @return array
     */
    public function getChannels($allDescendants = false)
    {
        if ($allDescendants) {
            $channels = array();
            foreach ($this->channels as $channel) {
                $channels = \array_merge(
                    $channels,
                    array($channel->getCfg('channel') => $channel),
                    $channel->getChannels(true)
                );
            }
            return $channels;
        }
        return $this->channels;
    }

    /**
     * Advanced usage
     *
     * @param string $path path
     *
     * @return mixed
     */
    public function getData($path = null)
    {
        $data = $this->utilities->arrayPathGet($this->data, $path);
        /*
            some array nodes may be references
            this is only a concern when calling getData externally
            serialize/unserialize is expensive.. only do so when requesting the below
        */
        return \in_array($path, array('logSummary','groupStacks'))
            ? \unserialize(\serialize($data))
            : $data;
    }

    /**
     * Get and clear headers that need to be output
     *
     * @return [name, value][]
     */
    public function getHeaders()
    {
        $headers = $this->data['headers'];
        $this->data['headers'] = array();
        return $headers;
    }

    /**
     * Returns the *Singleton* instance of this class.
     *
     * @param array $cfg optional config
     *
     * @return object
     */
    public static function getInstance($cfg = array())
    {
        if (!isset(self::$instance)) {
            // self::$instance set in __construct
            new static($cfg);
        } elseif ($cfg) {
            self::$instance->setCfg($cfg);
        }
        return self::$instance;
    }

    /**
     * "metafy" value/values
     *
     * accepts
     *   array()
     *   'cfg', option, value  (shortcut for setting single config value)
     *   key, value
     *   key                   (value defaults to true)
     *
     * @param mixed $args,... arguments
     *
     * @return array
     */
    public static function meta()
    {
        $args = \func_get_args();
        $count = \count($args);
        $args = \array_replace(array(null, null, null), $args);
        if (\is_array($args[0])) {
            $args[0]['debug'] = self::META;
            return $args[0];
        }
        if (!\is_string($args[0])) {
            return array('debug' => self::META);
        }
        if ($args[0] === 'cfg') {
            if (\is_array($args[1])) {
                return array(
                    'cfg' => $args[1],
                    'debug' => self::META,
                );
            }
            if (!\is_string($args[1])) {
                // invalid cfg key
                return array('debug' => self::META);
            }
            return array(
                'cfg' => array(
                    $args[1] => $count > 2
                        ? $args[2]
                        : true,
                ),
                'debug' => self::META,
            );
        }
        return array(
            $args[0] => $count > 1
                ? $args[1]
                : true,
            'debug' => self::META,
        );
    }

    /**
     * Publishes debug.output event and returns result
     *
     * @param array $options Override any output options
     *
     * @return string|null
     */
    public function output($options = array())
    {
        $cfgRestore = $this->config->setCfg($options);
        if (!$this->cfg['output']) {
            $this->config->setCfg($cfgRestore);
            return null;
        }
        /*
            I'd like to put this outputAs setting bit inside Output::onOutput
            but, adding a debug.output subscriber from within a debug.output subscriber = fail
        */
        $outputAs = $this->output->getCfg('outputAs');
        if (\is_string($outputAs)) {
            $this->output->setCfg('outputAs', $outputAs);
        }
        /*
            Publish debug.output on all descendant channels and then ourself
        */
        $channels = $this->getChannels(true);
        $channels[] = $this;
        $headers = array();
        foreach ($channels as $channel) {
            $event = $channel->eventManager->publish(
                'debug.output',
                $channel,
                array(
                    'headers' => array(),
                    'return' => '',
                    'isTarget' => $channel === $this,
                )
            );
            $headers = \array_merge($headers, $event['headers']);
        }
        if (!$this->getCfg('outputHeaders') || !$headers) {
            $this->data['headers'] = \array_merge($this->data['headers'], $event['headers']);
        } elseif (\headers_sent($file, $line)) {
            \trigger_error('PHPDebugConsole: headers already sent: '.$file.', line '.$line, E_USER_NOTICE);
        } else {
            foreach ($headers as $nameVal) {
                \header($nameVal[0].': '.$nameVal[1]);
            }
        }
        if (!$this->parentInstance) {
            $this->data['outputSent'] = true;
        }
        $this->config->setCfg($cfgRestore);
        return $event['return'];
    }

    /**
     * Remove plugin
     *
     * @param SubscriberInterface $plugin object implementing SubscriberInterface
     *
     * @return void
     */
    public function removePlugin(SubscriberInterface $plugin)
    {
        $this->eventManager->RemoveSubscriberInterface($plugin);
    }

    /**
     * Set one or more config values
     *
     * If setting a value via method a or b, old value is returned
     *
     * Setting/updating 'key' will also set 'collect' and 'output'
     *
     *    setCfg('key', 'value')
     *    setCfg('level1.level2', 'value')
     *    setCfg(array('k1'=>'v1', 'k2'=>'v2'))
     *
     * @param string|array $path   path
     * @param mixed        $newVal value
     *
     * @return mixed
     */
    public function setCfg($path, $newVal = null)
    {
        return $this->config->setCfg($path, $newVal);
    }

    /**
     * Advanced usage
     *
     *    setCfg('key', 'value')
     *    setCfg('level1.level2', 'value')
     *    setCfg(array('k1'=>'v1', 'k2'=>'v2'))
     *
     * @param string|array $path  path
     * @param mixed        $value value
     *
     * @return void
     */
    public function setData($path, $value = null)
    {
        if (\is_string($path)) {
            $path = \preg_split('#[\./]#', $path);
            $ref = &$this->data;
            foreach ($path as $k) {
                $ref = &$ref[$k];
            }
            $ref = $value;
        } else {
            $this->data = \array_merge($this->data, $path);
        }
        if (!$this->data['log']) {
            $this->data['groupStacks']['main'] = array();
        }
        if (!$this->data['logSummary']) {
            $this->data['groupStacks'] = \array_intersect_key(
                $this->data['groupStacks'],
                array('main' => true)
            );
            $this->data['groupPriorityStack'] = array();
        }
        $this->setLogDest();
    }

    /**
     * A wrapper for errorHandler->setErrorCaller
     *
     * @param array $caller (optional) null (default) determine automatically
     *                      empty value (false, "", 0, array()) clear
     *                      array manually set
     *
     * @return void
     */
    public function setErrorCaller($caller = null)
    {
        if ($caller === null) {
            $caller = $this->utilities->getCallerInfo(1);
            $caller = array(
                'file' => $caller['file'],
                'line' => $caller['line'],
            );
        }
        if ($caller) {
            // groupEnd will check depth and potentially clear errorCaller
            $caller['groupDepth'] = $this->getGroupDepth();
        }
        $this->errorHandler->setErrorCaller($caller);
    }

    /*
        Non-Public methods
    */

    /**
     * Debug class autoloader
     *
     * @param string $className classname to attempt to load
     *
     * @return void
     */
    protected function autoloader($className)
    {
        $className = \ltrim($className, '\\'); // leading backslash _shouldn't_ have been passed
        if (!\strpos($className, '\\')) {
            // className is not namespaced
            return;
        }
        $psr4Map = array(
            'bdk\\Debug\\' => __DIR__,
            'bdk\\PubSub\\' => __DIR__.'/../PubSub',
            'bdk\\ErrorHandler\\' => __DIR__.'/../ErrorHandler',
        );
        foreach ($psr4Map as $namespace => $dir) {
            if (\strpos($className, $namespace) === 0) {
                $rel = \substr($className, \strlen($namespace));
                $rel = \str_replace('\\', '/', $rel);
                require $dir.'/'.$rel.'.php';
                return;
            }
        }
        $classMap = array(
            'bdk\\ErrorHandler' => __DIR__.'/../ErrorHandler/ErrorHandler.php',
        );
        if (isset($classMap[$className])) {
            require $classMap[$className];
        }
    }

    /**
     * Store the arguments
     * if collect is false -> does nothing
     * otherwise:
     *   + abstracts values
     *   + publishes debug.log event
     *   + appends log (if event propagation not stopped)
     *
     * @param string $method error, info, log, warn, etc
     * @param array  $args   arguments passed to method
     * @param array  $meta   meta data
     *
     * @return void
     */
    protected function appendLog($method, $args = array(), $meta = array())
    {
        if (!$this->cfg['collect']) {
            return;
        }
        $cfgRestore = array();
        $meta = \array_merge(
            array('channel' => $this->cfg['channel']),
            $meta,
            $this->internal->getMetaVals($args)
        );
        if (isset($meta['cfg'])) {
            $cfgRestore = $this->config->setCfg($meta['cfg']);
            unset($meta['cfg']);
        }
        foreach ($args as $i => $v) {
            if ($this->abstracter->needsAbstraction($v)) {
                $args[$i] = $this->abstracter->getAbstraction($v, $method);
            }
        }
        $event = $this->eventManager->publish(
            'debug.log',
            $this,
            array(
                'method' => $method,
                'args' => $args,
                'meta' => $meta,
                'appendLog' => true,
                'return' => null,
            )
        );
        if ($cfgRestore) {
            $this->config->setCfg($cfgRestore);
        }
        if (!$event->getValue('appendLog')) {
            return $event->getValue('return');
        }
        if ($this->parentInstance) {
            return $this->parentInstance->appendLog(
                $event->getValue('method'),
                $event->getValue('args'),
                $event->getValue('meta')
            );
        }
        $this->rootInstance->logRef[] = array(
            $event->getValue('method'),
            $event->getValue('args'),
            \array_diff_assoc($event->getValue('meta'), array(
                'channel' => $this->cfg['channel'],
            )),
        );
        return $event->getValue('return');
    }

    /**
     * Append group or groupCollapsed to log
     *
     * @param string $method 'group' or 'groupCollapsed'
     * @param array  $args   arguments passed to group or groupCollapsed
     *
     * @return void
     */
    private function doGroup($method, $args)
    {
        $meta = $this->internal->getMetaVals(
            $args,
            array('channel' => $this->cfg['channel'])
        );
        $this->rootInstance->groupStackRef[] = array(
            'channel' => $meta['channel'],
            'collect' => $this->cfg['collect'],
        );
        if (!$this->cfg['collect']) {
            return;
        }
        if (empty($args)) {
            // give a default label
            $caller = $this->utilities->getCallerInfo();
            if (isset($caller['class'])) {
                $args[] = $caller['class'].$caller['type'].$caller['function'];
                $meta['isMethodName'] = true;
            } elseif (isset($caller['function'])) {
                $args[] = $caller['function'];
            } else {
                $args[] = 'group';
            }
        }
        $this->appendLog($method, $args, $meta);
    }

    /**
     * Log timeEnd() and timeGet()
     *
     * @param float  $seconds          seconds
     * @param mixed  $returnOrTemplate false: log the time with default template (default)
     *                                  true: do not log
     *                                  string: log using passed template
     * @param string $label            label
     * @param array  $meta             meta values
     *
     * @return void
     */
    protected function doTime($seconds, $returnOrTemplate = false, $label = 'time', $meta = array())
    {
        if (\is_string($returnOrTemplate)) {
            $str = $returnOrTemplate;
            $str = \str_replace('%label', $label, $str);
            $str = \str_replace('%time', $seconds, $str);
        } elseif ($returnOrTemplate === true) {
            return;
        } else {
            $str = $label.': '.$seconds.' sec';
        }
        $this->appendLog('time', array($str), $meta);
    }

    /**
     * Calculate total group depth
     *
     * @return integer
     */
    protected function getGroupDepth()
    {
        $depth = 0;
        foreach ($this->data['groupStacks'] as $stack) {
            $depth += \count($stack);
        }
        $depth += \count($this->data['groupPriorityStack']);
        return $depth;
    }

    /**
     * Set "container" factories
     *
     * @return array
     */
    private function getDefaultFactories()
    {
        return array(
            'methodProfile' => function () {
                return new Debug\MethodProfile();
            },
        );
    }

    /**
     * Set "container" services
     *
     * @return array
     */
    private function getDefaultServices()
    {
        return array(
            'abstracter' => function (Debug $debug) {
                return new Debug\Abstracter($debug, $debug->config->getCfgLazy('abstracter'));
            },
            'config' => function (Debug $debug) {
                return new Debug\Config($debug, $debug->cfg);    // cfg is passed by reference
            },
            'errorEmailer' => function (Debug $debug) {
                return new ErrorEmailer($debug->config->getCfgLazy('errorEmailer'));
            },
            'errorHandler' => function (Debug $debug) {
                if (ErrorHandler::getInstance()) {
                    return ErrorHandler::getInstance();
                } else {
                    $errorHandler = new ErrorHandler($debug->eventManager);
                    /*
                        log E_USER_ERROR to system_log without halting script
                    */
                    $errorHandler->setCfg('onEUserError', 'log');
                    return $errorHandler;
                }
            },
            'eventManager' => function () {
                return new EventManager();
            },
            'internal' => function (Debug $debug) {
                return new Debug\Internal($debug);
            },
            'logger' => function (Debug $debug) {
                return new Debug\Logger($debug);
            },
            'methodClear' => function (Debug $debug) {
                return new Debug\MethodClear($debug, $debug->data);
            },
            'methodTable' => function () {
                return new Debug\MethodTable();
            },
            'output' => function (Debug $debug) {
                $output = new Debug\Output($debug, $debug->config->getCfgLazy('output'));
                $debug->eventManager->addSubscriberInterface($output);
                return $output;
            },
            'utf8' => function () {
                return new Debug\Utf8();
            },
            'utilities' => function () {
                return new Debug\Utilities();
            },
        );
    }

    /**
     * Get Method's default argument list
     *
     * @param string $methodName Name of the method
     *
     * @return array
     */
    private static function getMethodDefaultArgs($methodName)
    {
        $defaultArgs = array();
        if (isset(self::$methodDefaultArgs[$methodName])) {
            $defaultArgs = self::$methodDefaultArgs[$methodName];
        } elseif (\method_exists(self::$instance, $methodName)) {
            $reflectionMethod = new ReflectionMethod(self::$instance, $methodName);
            $params = $reflectionMethod->getParameters();
            foreach ($params as $reflectionParameter) {
                $defaultArgs[] = $reflectionParameter->isOptional()
                    ? $reflectionParameter->getDefaultValue()
                    : null;
            }
            self::$methodDefaultArgs[$methodName] = $defaultArgs;
        }
        return $defaultArgs;
    }

    /**
     * Set where appendLog appends to
     *
     * @param string $where ('auto'), 'alerts', log', or 'summary'
     *
     * @return void
     */
    private function setLogDest($where = 'auto')
    {
        if ($where == 'auto') {
            $where = $this->data['groupPriorityStack']
                ? 'summary'
                : 'log';
        }
        if ($where == 'log') {
            $this->rootInstance->logRef = &$this->rootInstance->data['log'];
            $this->rootInstance->groupStackRef = &$this->rootInstance->data['groupStacks']['main'];
        } elseif ($where == 'alerts') {
            $this->rootInstance->logRef = &$this->rootInstance->data['alerts'];
        } else {
            $priority = \end($this->data['groupPriorityStack']);
            if (!isset($this->data['logSummary'][$priority])) {
                $this->data['logSummary'][$priority] = array();
                $this->data['groupStacks'][$priority] = array();
            }
            $this->rootInstance->logRef = &$this->rootInstance->data['logSummary'][$priority];
            $this->rootInstance->groupStackRef = &$this->rootInstance->data['groupStacks'][$priority];
        }
    }
}


<?php
/**
 * This file is part of PHPDebugConsole
 *
 * @package   PHPDebugConsole
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2019 Brad Kent
 * @version   v2.3
 */

namespace bdk\Debug;

use bdk\Debug;
use Psr\Log\AbstractLogger;
use Psr\Log\InvalidArgumentException;
use Psr\Log\LogLevel;

/**
 * PSR-3 Logger implementation
 */
class Logger extends AbstractLogger
{

    public $debug;

    /**
     * Constructor
     *
     * @param Debug $debug Debug instance
     */
    public function __construct(Debug $debug)
    {
        $this->debug = $debug;
    }

    /**
     * Logs with an arbitrary level.
     *
     * @param string        $level   debug, info, notice, warning, error, critical, alert, emergency
     * @param string|object $message message
     * @param array         $context array
     *
     * @return void
     * @throws InvalidArgumentException If invalid level.
     */
    public function log($level, $message, array $context = array())
    {
        if (!$this->isValidLevel($level)) {
            throw new InvalidArgumentException();
        }
        $str = $this->interpolate($message, $context);
        $meta = $this->getMeta($level, $context);
        if (\in_array($level, array('emergency','critical','error'))) {
            $this->debug->error($str, $context, $meta);
        } elseif (\in_array($level, array('warning','notice'))) {
            $this->debug->warn($str, $context, $meta);
        } elseif ($level == 'alert') {
            $this->debug->alert($str);
        } elseif ($level == 'info') {
            $this->debug->info($str, $context);
        } else {
            $this->debug->log($str, $context);
        }
    }

    /**
     * Exctract potential meta values from $context
     *
     * @param string $level   log level
     * @param array  $context context array
     *                          meta values get removed
     *
     * @return array meta
     */
    protected function getMeta($level, &$context)
    {
        $haveException = isset($context['exception']) &&
            ($context['exception'] instanceof \Exception
                || PHP_VERSION_ID >= 70000 && $context['exception'] instanceof \Throwable);
        $isError = \in_array($level, array('emergency','critical','error','warning','notice'));
        $metaVals = \array_intersect_key($context, \array_flip(array('file','line')));
        $metaVals['psr3level'] = $level;
        $context = \array_diff_key($context, $metaVals);
        if ($haveException) {
            $exception = $context['exception'];
            $metaVals = \array_merge(array(
                'backtrace' => $this->debug->errorHandler->backtrace($exception),
                'file' => $exception->getFile(),
                'line' => $exception->getLine(),
            ), $metaVals);
        } elseif ($isError && \count($metaVals) < 2) {
            $callerInfo = $this->debug->utilities->getCallerInfo(1);
            $metaVals = \array_merge(array(
                'file' => $callerInfo['file'],
                'line' => $callerInfo['line'],
            ), $metaVals);
        }
        if (!$context) {
            $context = $this->debug->meta();
        }
        return $this->debug->meta($metaVals);
    }

    /**
     * Interpolates context values into the message placeholders.
     *
     * @param string $message message
     * @param array  $context optional array of key/values
     *                                    interpolated values get removed
     *
     * @return string
     */
    protected function interpolate($message, array &$context = array())
    {
        // build a replacement array with braces around the context keys
        \preg_match_all('/\{([a-z0-9_.]+)\}/', $message, $matches);
        $placeholders = \array_unique($matches[1]);
        $replace = array();
        foreach ($placeholders as $key) {
            if (!isset($context[$key])) {
                continue;
            }
            $val = $context[$key];
            if (!\is_array($val) && (!\is_object($val) || \method_exists($val, '__toString'))) {
                $replace['{' . $key . '}'] = $val;
            }
        }
        $context = \array_diff_key($context, \array_flip($placeholders));
        if (!$context) {
            $context = $this->debug->meta();
        }
        return \strtr($message, $replace);
    }

    /**
     * Check if level is valid
     *
     * @param string $level debug level
     *
     * @return boolean
     */
    protected function isValidLevel($level)
    {
        return \in_array($level, $this->validLevels());
    }

    /**
     * Get list of valid levels
     *
     * @return array list of levels
     */
    protected function validLevels()
    {
        return array(
            LogLevel::EMERGENCY,
            LogLevel::ALERT,
            LogLevel::CRITICAL,
            LogLevel::ERROR,
            LogLevel::WARNING,
            LogLevel::NOTICE,
            LogLevel::INFO,
            LogLevel::DEBUG,
        );
    }
}


<?php
/**
 * This file is part of PHPDebugConsole
 *
 * @package   PHPDebugConsole
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2019 Brad Kent
 * @version   v2.3
 */

namespace bdk\Debug;

/**
 * Utility methods
 */
class Utilities
{

    /**
     * self closing / empty / void html tags
     *
     * Not including 'command' (obsolete) and 'keygen' (deprecated),
     *
     * @var array
     */
    public static $htmlEmptyTags = array('area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr');

    /**
     * Used by parseAttribString
     *
     * @var array
     */
    public static $htmlBoolAttr = array(
        // GLOBAL
        'contenteditable', 'hidden', 'itemscope',
        // 'spellcheck', // enum : true|false
        // 'translate', // enum : yes|no

        // FORM / INPUT
        // 'autocomplete', // enum : on|off
        'autofocus', 'checked', 'disabled', 'formnovalidate', 'multiple', 'novalidate', 'readonly', 'required', 'selected',

        // AUDIO / VIDEO / TRACK
        'autoplay', 'controls', 'default', 'loop', 'muted',

        // DETAILS
        'open',

        // IFRAME
        'frameborder',

        // IMG
        'ismap',

        // OBJECT
        'typemustmatch',

        // OL
        'reversed',

        // SCRIPT
        'async', 'defer', 'nomodule',

        // STYLE
        'scoped',

        // OBSOLETE / DEPRECATED / NEVER-A-THING
        // "allowfullscreen",
        // "allowpaymentrequest",
        'compact',  // <dir> and

        'nohref',   // <area>
        'noresize', // <frame>
        'noshade',  //

        'nowrap',   // dt, dd, td, th
        'scrolling',//
        'seamless', //  - removed from draft
        'sortable', //
 - removed from draft
    );

    /**
     * Recursively merge two arrays
     *
     * @param array $arrayDef default array
     * @param array $array2   array 2
     *
     * @return array
     */
    public static function arrayMergeDeep($arrayDef, $array2)
    {
        if (!\is_array($arrayDef) || self::isCallable($arrayDef)) {
            // not array or appears to be a callable
            return $array2;
        }
        if (!\is_array($array2) || self::isCallable($array2)) {
            // not array or appears to be a callable
            return $array2;
        }
        foreach ($array2 as $k2 => $v2) {
            if (\is_int($k2)) {
                if (!\in_array($v2, $arrayDef)) {
                    $arrayDef[] = $v2;
                }
            } elseif (!isset($arrayDef[$k2])) {
                $arrayDef[$k2] = $v2;
            } else {
                $arrayDef[$k2] = self::arrayMergeDeep($arrayDef[$k2], $v2);
            }
        }
        return $arrayDef;
    }

    /**
     * Get value from array
     *
     * @param array        $array array to traverse
     * @param array|string $path  key path
     *                               path may contain special keys:
     *                                 * __count__ : return count() (traversal will cease)
     *                                 * __end__ : last value
     *                                 * __reset__ : first value
     *
     * @return mixed
     */
    public static function arrayPathGet($array, $path)
    {
        if (!\is_array($path)) {
            $path = \array_filter(\preg_split('#[\./]#', $path), 'strlen');
        }
        $path = \array_reverse($path);
        while ($path) {
            $key = \array_pop($path);
            $arrayAccess = \is_array($array) || $array instanceof \ArrayAccess;
            if (!$arrayAccess) {
                return null;
            } elseif (isset($array[$key])) {
                $array = $array[$key];
            } elseif ($key == '__count__') {
                return \count($array);
            } elseif ($key == '__end__') {
                \end($array);
                $path[] = \key($array);
            } elseif ($key == '__reset__') {
                \reset($array);
                $path[] = \key($array);
            } else {
                return null;
            }
        }
        return $array;
    }

    /**
     * Build attribute string
     *
     * Attributes will be sorted by name
     * class & style attributes may be provided as arrays
     * data-* attributes will be property json-encoded
     *
     * @param array $attribs key/values
     *
     * @return string
     * @see    https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute
     */
    public static function buildAttribString($attribs)
    {
        if (\is_string($attribs)) {
            return \rtrim(' '.\trim($attribs));
        }
        $attribPairs = array();
        foreach ($attribs as $k => $v) {
            if (\is_int($k)) {
                $k = $v;
                $v = true;
            }
            $k = \strtolower($k);
            if (\strpos($k, 'data-') === 0) {
                $v = \json_encode($v);
                $v = \trim($v, '"');
            } elseif (\is_bool($v)) {
                $v = self::buildAttribBoolVal($k, $v);
            } elseif (\is_array($v) || $k === 'class') {
                $v = self::buildAttribArrayVal($k, $v);
            }
            if (\array_filter(array(
                $v === null,
                $v === '' && \in_array($k, array('class', 'style'))
            ))) {
                // don't include
                continue;
            }
            $v = \trim($v);
            $attribPairs[] = $k.'="'.\htmlspecialchars($v).'"';
        }
        \sort($attribPairs);
        return \rtrim(' '.\implode(' ', $attribPairs));
    }

    /**
     * Build an html tag
     *
     * @param string       $tagName   tag name (ie "div" or "input")
     * @param array|string $attribs   key/value attributes
     * @param string       $innerhtml inner HTML if applicable
     *
     * @return string
     */
    public static function buildTag($tagName, $attribs = array(), $innerhtml = '')
    {
        $tagName = \strtolower($tagName);
        $attribStr = self::buildAttribString($attribs);
        return \in_array($tagName, self::$htmlEmptyTags)
            ? '<'.$tagName.$attribStr.' />'
            : '<'.$tagName.$attribStr.'>'.$innerhtml.'</'.$tagName.'>';
    }

    /**
     * Get all HTTP header key/values as an associative array for the current request.
     *
     * @return string[string] The HTTP header key/value pairs.
     */
    public static function getAllHeaders()
    {
        if (\function_exists('getallheaders')) {
            return \getallheaders();
        }
        $headers = array();
        $copyServer = array(
            'CONTENT_TYPE'   => 'Content-Type',
            'CONTENT_LENGTH' => 'Content-Length',
            'CONTENT_MD5'    => 'Content-Md5',
        );
        foreach ($_SERVER as $key => $value) {
            if (\substr($key, 0, 5) === 'HTTP_') {
                $key = \substr($key, 5);
                if (!isset($copyServer[$key]) || !isset($_SERVER[$key])) {
                    $key = \str_replace(' ', '-', \ucwords(\strtolower(\str_replace('_', ' ', $key))));
                    $headers[$key] = $value;
                }
            } elseif (isset($copyServer[$key])) {
                $headers[$copyServer[$key]] = $value;
            }
        }
        if (!isset($headers['Authorization'])) {
            if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
                $headers['Authorization'] = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
            } elseif (isset($_SERVER['PHP_AUTH_USER'])) {
                $basicPass = isset($_SERVER['PHP_AUTH_PW']) ? $_SERVER['PHP_AUTH_PW'] : '';
                $headers['Authorization'] = 'Basic ' . \base64_encode($_SERVER['PHP_AUTH_USER'] . ':' . $basicPass);
            } elseif (isset($_SERVER['PHP_AUTH_DIGEST'])) {
                $headers['Authorization'] = $_SERVER['PHP_AUTH_DIGEST'];
            }
        }
        return $headers;
    }

    /**
     * Convert size int into "1.23 kB"
     *
     * @param integer|string $size bytes or similar to "1.23M"
     *
     * @return string
     */
    public static function getBytes($size)
    {
        if (\is_string($size) && \preg_match('/^([\d,.]+)\s?([kmgtp])b?$/i', $size, $matches)) {
            $size = \str_replace(',', '', $matches[1]);
            switch (\strtolower($matches[2])) {
                case 'p':
                    $size *= 1024;
                    // no break
                case 't':
                    $size *= 1024;
                    // no break
                case 'g':
                    $size *= 1024;
                    // no break
                case 'm':
                    $size *= 1024;
                    // no break
                case 'k':
                    $size *= 1024;
            }
        }
        $units = array('B','kB','MB','GB','TB','PB');
        $pow = \pow(1024, ($i=\floor(\log($size, 1024))));
        $size = $pow == 0
            ? '0 B'
            : \round($size/$pow, 2).' '.$units[$i];
        return $size;
    }

    /**
     * Returns information regarding previous call stack position
     * call_user_func and call_user_func_array are skipped
     *
     * Information returned:
     *     function : function/method name
     *     class :    fully qualified classname
     *     file :     file
     *     line :     line number
     *     type :     "->": instance call, "::": static call, null: not object oriented
     *
     * If a method is defined as static:
     *    the class value will always be the class in which the method was defined,
     *    type will always be "::", even if called with an ->
     *
     * @param integer $offset Adjust how far to go back
     *
     * @return array
     */
    public static function getCallerInfo($offset = 0)
    {
        $return = array(
            'file' => null,
            'line' => null,
            'function' => null,
            'class' => null,
            'type' => null,
        );
        $backtrace = \debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS | DEBUG_BACKTRACE_PROVIDE_OBJECT, 8);
        $numFrames = \count($backtrace);
        $regexInternal = '/^'.\preg_quote(__NAMESPACE__).'\b/';
        if (isset($backtrace[1]['class']) && \preg_match($regexInternal, $backtrace[1]['class'])) {
            // called from within
            // find the frame that called/triggered a debug function
            for ($i = $numFrames - 1; $i >= 0; $i--) {
                if (isset($backtrace[$i]['class']) && \preg_match($regexInternal, $backtrace[$i]['class'])) {
                    break;
                }
            }
        } else {
            $i = 1;
        }
        $i += $offset;
        $iLine = $i;
        $iFunc = $i + 1;
        if (isset($backtrace[$iFunc]) && \in_array($backtrace[$iFunc]['function'], array('call_user_func', 'call_user_func_array'))) {
            $iLine++;
            $iFunc++;
        } elseif (isset($backtrace[$iFunc]['class'])
            && $backtrace[$iFunc]['class'] == 'ReflectionMethod'
            && $backtrace[$iFunc]['function'] == 'invoke'
        ) {
            // called via ReflectionMethod->invoke()
            $iLine++;
            $iFunc--;
        }
        if (isset($backtrace[$iFunc])) {
            $return = \array_merge($return, \array_intersect_key($backtrace[$iFunc], $return));
            if ($return['type'] == '->') {
                $return['class'] = \get_class($backtrace[$iFunc]['object']);
            }
        }
        if (isset($backtrace[$iLine])) {
            $return['file'] = $backtrace[$iLine]['file'];
            $return['line'] = $backtrace[$iLine]['line'];
        } else {
            $return['file'] = $backtrace[$numFrames-1]['file'];
            $return['line'] = 0;
        }
        return $return;
    }

    /**
     * returns required/included files sorted by directory
     *
     * @return array
     */
    public static function getIncludedFiles()
    {
        $includedFiles = \get_included_files();
        \usort($includedFiles, function ($valA, $valB) {
            $dirA = \dirname($valA);
            $dirB = \dirname($valB);
            return $dirA == $dirB
                ? \strnatcasecmp($valA, $valB)
                : \strnatcasecmp($dirA, $dirB);
        });
        return $includedFiles;
    }

    /**
     * Returns cli, cron, ajax, or http
     *
     * @return string cli | cron | ajax | http
     */
    public static function getInterface()
    {
        $return = 'http';
        $isCliOrCron = \count(\array_filter(array(
            \defined('STDIN'),
            isset($_SERVER['argv']),
            !\array_key_exists('REQUEST_METHOD', $_SERVER),
        ))) > 0;
        if ($isCliOrCron) {
            // TERM is a linux/unix thing
            $return = isset($_SERVER['TERM']) || \array_key_exists('PATH', $_SERVER)
                ? 'cli'
                : 'cron';
        } elseif (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest') {
            $return = 'ajax';
        }
        return $return;
    }

    /**
     * Returns a sent/pending response header value
     *
     * @param string $key default = 'Content-Type', header to return
     *
     * @return string
     * @req    php >= 5
     */
    public static function getResponseHeader($key = 'Content-Type')
    {
        $value = null;
        $headers = \headers_list();
        foreach ($headers as $header) {
            if (\preg_match('/^'.$key.':\s*([^;]*)/i', $header, $matches)) {
                $value = $matches[1];
                break;
            }
        }
        return $value;
    }

    /**
     * Checks if a given string is base64 encoded
     *
     * @param string $str string to check
     *
     * @return boolean
     */
    public static function isBase64Encoded($str)
    {
        return (boolean) \preg_match('%^[a-zA-Z0-9(!\s+)?\r\n/+]*={0,2}$%', \trim($str));
    }

    /**
     * Is passed argument a simple array with all-integer in sequence from 0 to n?
     * empty array returns true
     *
     * @param [mixed $val value to check
     *
     * @return boolean
     */
    public static function isList($val)
    {
        if (!\is_array($val)) {
            return false;
        }
        $keys = \array_keys($val);
        foreach ($keys as $i => $key) {
            if ($i != $key) {
                return false;
            }
        }
        return true;
    }

    /**
     * Determine PHP's MemoryLimit
     *
     * @return string
     */
    public static function memoryLimit()
    {
        $iniVal = \ini_get('memory_limit');
        return $iniVal ?: '128M';
    }

    /**
     * Parse string -o- attributes into a key=>value array
     *
     * @param string  $str        string to parse
     * @param boolean $dataDecode (true) whether to json_decode data attributes
     *
     * @return array
     */
    public static function parseAttribString($str, $dataDecode = true)
    {
        $attribs = array();
        $regexAttribs = '/\b([\w\-]+)\b(?: \s*=\s*(["\'])(.*?)\\2 | \s*=\s*(\S+) )?/xs';
        \preg_match_all($regexAttribs, $str, $matches);
        $keys = \array_map('strtolower', $matches[1]);
        $values = \array_replace($matches[3], \array_filter($matches[4], 'strlen'));
        foreach ($keys as $i => $k) {
            $attribs[$k] = $values[$i];
            if (\in_array($k, self::$htmlBoolAttr)) {
                $attribs[$k] = true;
            }
        }
        \ksort($attribs);
        foreach ($attribs as $k => $v) {
            if (\is_string($v)) {
                $attribs[$k] = \htmlspecialchars_decode($v);
            }
            $isDataAttrib = \strpos($k, 'data-') === 0;
            if ($isDataAttrib && $dataDecode) {
                $val = $attribs[$k];
                $attribs[$k] = \json_decode($attribs[$k], true);
                if ($attribs[$k] === null && $val !== 'null') {
                    $attribs[$k] = \json_decode('"'.$val.'"', true);
                }
            }
        }
        return $attribs;
    }

    /**
     * Parse HTML/XML tag
     *
     * returns array(
     *    'tagname' => string
     *    'attribs' => array
     *    'innerhtml' => string | null
     * )
     *
     * @param string $tag html tag to parse
     *
     * @return array
     */
    public static function parseTag($tag)
    {
        $regexTag = '#<([^\s>]+)([^>]*)>(.*)</\\1>#is';
        $regexTag2 = '#^<(?:\/\s*)?([^\s>]+)(.*?)\/?>$#s';
        $tag = \trim($tag);
        if (\preg_match($regexTag, $tag, $matches)) {
            $return = array(
                'tagname' => $matches[1],
                'attribs' => self::parseAttribString($matches[2]),
                'innerhtml' => $matches[3],
            );
        } elseif (\preg_match($regexTag2, $tag, $matches)) {
            $return = array(
                'tagname' => $matches[1],
                'attribs' => self::parseAttribString($matches[2]),
                'innerhtml' => null,
            );
        }
        return $return;
    }

    /**
     * Generate a unique request id
     *
     * @return string
     */
    public static function requestId()
    {
        return \hash(
            'crc32b',
            (isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : 'terminal')
                .(isset($_SERVER['REQUEST_TIME_FLOAT']) ? $_SERVER['REQUEST_TIME_FLOAT'] : $_SERVER['REQUEST_TIME'])
                .(isset($_SERVER['REMOTE_PORT']) ? $_SERVER['REMOTE_PORT'] : '')
        );
    }

    /**
     * serialize log for emailing
     *
     * @param array $data log data to serialize
     *
     * @return string
     */
    public static function serializeLog($data)
    {
        $str = \serialize($data);
        if (\function_exists('gzdeflate')) {
            $str = \gzdeflate($str);
        }
        $str = \chunk_split(\base64_encode($str), 124);
        return "START DEBUG\n"
            .$str    // chunk_split appends a "\r\n"
            .'END DEBUG';
    }

    /**
     * Use to unserialize the log serialized by emailLog
     *
     * @param string $str serialized log data
     *
     * @return array | false
     */
    public static function unserializeLog($str)
    {
        $strStart = 'START DEBUG';
        $strEnd = 'END DEBUG';
        if (\preg_match('/'.$strStart.'[\r\n]+(.+)[\r\n]+'.$strEnd.'/s', $str, $matches)) {
            $str = $matches[1];
        }
        $str = self::isBase64Encoded($str)
            ? \base64_decode($str)
            : false;
        if ($str && \function_exists('gzinflate')) {
            $strInflated = \gzinflate($str);
            if ($strInflated) {
                $str = $strInflated;
            }
        }
        $data = \unserialize($str);
        return $data;
    }

    /**
     * Convert array attribute value to string
     *
     * Convert class/style array value to string
     * This function is not meant for data attributs
     *
     * @param string $key   attribute name (class|style)
     * @param array  $value classnames for class, key/value for style
     *
     * @return string
     */
    private static function buildAttribArrayVal($key, $value = array())
    {
        if ($key == 'class') {
            if (!\is_array($value)) {
                $value = \explode(' ', $value);
            }
            $value = \array_filter(\array_unique($value));
            \sort($value);
            $value = \implode(' ', $value);
        } elseif ($key == 'style') {
            $keyValues = array();
            foreach ($value as $k => $v) {
                $keyValues[] = $k.':'.$v.';';
            }
            \sort($keyValues);
            $value = \implode('', $keyValues);
        } else {
            $value = null;
        }
        return $value;
    }

    /**
     * Convert boolean attribute value to string
     *
     * @param string  $key   attribute name
     * @param boolean $value true|false
     *
     * @return string
     */
    private static function buildAttribBoolVal($key, $value = true)
    {
        if ($key == 'autocomplete') {
            $value = $value ? 'on' : 'off';
        } elseif ($key == 'spellcheck') {
            $value = $value ? 'true' : 'false';
        } elseif ($key == 'translate') {
            $value = $value ? 'yes' : 'no';
        } elseif ($value) {
            // even if not a recognized boolean attribute
            $value = $key;
        } else {
            $value = null;
        }
        return $value;
    }

    /**
     * Syntax-only is_callable() check
     * Additionally checks that $array[0] is an object
     *
     * @param array $array variable to check
     *
     * @return boolean [description]
     */
    private static function isCallable($array)
    {
        return \is_callable($array, true) && \is_object($array[0]);
    }
}