<?php namespace ProcessWire;

/**
 * ProcessWire Logger (Logs Viewer)
 *
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 * 
 * @method string formatLogText($text, $logName = '')
 * 
 *
 */

class ProcessLogger extends Process {

	public static function getModuleInfo() {
		return array(
			'title' => __('Logs', __FILE__), 
			'summary' => __('View and manage system logs.', __FILE__), 
			'version' => 2, 
			'author' => 'Ryan Cramer', 
			'icon' => 'tree',
			'permission' => 'logs-view', 
			'permissions' => array(
				'logs-view' => 'Can view system logs',
				'logs-edit' => 'Can manage system logs',
				),
			'page' => array(
				'name' => 'logs', 
				'parent' => 'setup',
				'title' => 'Logs', 
				),
			'useNavJSON' => true, 
		);
	}
	
	public function __construct() {
		require_once(dirname(__FILE__) . '/LogEntriesArray.php'); 
		parent::__construct();
	}

	/**
	 * Provides output navigation logs list
	 * 
	 * @param array $options
	 * @return string
	 * 
	 */
	public function ___executeNavJSON(array $options = array()) {

		$options['itemLabel'] = 'name';
		$options['itemLabel2'] = 'when';
		$options['add'] = false;
		$options['edit'] = 'view/{name}/';
		$options['items'] = $this->wire()->log->getLogs(true); 
		$options['sort'] = false;
		
		foreach($options['items'] as $key => $item) {
			$item['when'] = wireRelativeTimeStr($item['modified'], true, false); 
			if(time() - $item['modified'] > 86400) {
				$item['icon'] = 'file-text-o';
			} else {
				$item['icon'] = 'file-text';
			}
			$options['items'][$key] = $item;
		}

		return parent::___executeNavJSON($options); 
	}

	public function ___execute() {
		/** @var MarkupAdminDataTable $table */
		$table = $this->wire('modules')->get('MarkupAdminDataTable'); 
		$table->setEncodeEntities(false);
		$table->headerRow(array(
			$this->_x('Name', 'th'),
			$this->_x('Modified', 'th'), 
			$this->_x('Entries', 'th'), 
			$this->_x('Size', 'th'),
		));
		$logs = $this->wire()->log->getLogs();
		foreach($logs as $log) {
			$logName = $log['name'];
			if(ctype_digit($logName)) $logName = " $logName";
			$table->row(array(
				$logName => "./view/$log[name]/", 
				"<span style='font-size: 0;'>$log[modified] </span>" . wireRelativeTimeStr($log['modified']),
				$this->wire()->log->getTotalEntries($log['name']),
				"<span style='font-size: 0;'>$log[size] </span>" . wireBytesStr($log['size'])
			));
		}
		$cnt = count($logs); 
		$out = 
			"<h2><i class='fa fa-lg fa-fw fa-tree'></i> " . sprintf($this->_n('%d log', '%d logs', $cnt), $cnt) . "</h2>" . 
			$table->render() . 
			"<p>" . 
			"<span class='detail'>" . $this->_('Create or add to log file from the API:') . "</span><br />" . 
			"<code class='notes'>wire('log')->save('name', 'entry text');</code>" . 
			"</p>";
				
		return $out; 
	}
	
	protected function processAction($action, $name) {
		
		$log = $this->wire()->log;
		$input = $this->wire()->input;
		$session = $this->wire()->session;
		
		if(!$input->post("submit_$action")) {
			throw new WireException("Action missing submit"); 
		}
	
		if($action != 'download' && !$this->wire()->user->hasPermission('logs-edit')) {
			throw new WirePermissionException("You don't have permission to execute that action"); 
		}
			
		switch($action) {
			
			case 'delete':
				if($log->delete($name)) $this->message(sprintf($this->_('Deleted log: %s'), $name)); 
				$session->location($this->wire()->page->url); 	
				break;
			
			case 'prune':
				$days = (int) $input->post('prune_days'); 
				$qty = $log->prune($name, $days); 
				$this->message(sprintf($this->_('Pruned "%s" log file (now contains %d entries)'), $name, $qty));
				$session->location('./'); 
				break;
			
			case 'download':
				$filename = $log->getFilename($name);
				if(file_exists($filename)) wireSendFile($filename, array('forceDownload' => true));
				break;
			
			case 'add':
				$text = $this->wire()->sanitizer->text($input->post('add_text')); 
				if(strlen($text)) {
					$log->save($name, $text); 
					$this->message(sprintf($this->_('Saved new log entry to "%s"'), $name)); 
				} else {
					$this->error($this->_('Log entry text was blank')); 
				}
				$session->location('./'); 
				break;
		}
	}
	
	public function ___executeView() {
		
		$input = $this->wire()->input;
		$session = $this->wire()->session;
		$config = $this->wire()->config;
		$modules = $this->wire()->modules;
		$sanitizer = $this->wire()->sanitizer;
		$log = $this->wire()->log;
		
		$name = $input->urlSegment2;
		if(!$name) $session->redirect('../');
		$logs = $log->getLogs();
		if(!isset($logs[$name])) {
			$this->error(sprintf('Unknown log: %s', $name));
			$session->location('../');
		}
		$action = $input->post('action'); 
		if($action) $this->processAction($action, $name);
		$limit = 100;
		$options = array('limit' => $limit); 
		
		$q = $input->get('q');
		if($q !== null && strlen($q)) {
			$options['text'] = $sanitizer->text($q);
			$input->whitelist('q', $options['text']); 
		}
		
		$dateFrom = $input->get('date_from');
		if($dateFrom !== null && strlen($dateFrom)) {
			$options['dateFrom'] = ctype_digit("$dateFrom") ? (int) $dateFrom : strtotime("$dateFrom 00:00:00");
			$input->whitelist('date_from', $options['dateFrom']); 
		}
		
		$dateTo = $input->get('date_to');
		if($dateTo !== null && strlen($dateTo)) {
			$options['dateTo'] = ctype_digit("$dateTo") ? (int) $dateTo : strtotime("$dateTo 23:59:59");
			$input->whitelist('date_to', $options['dateTo']); 
		}

		$options['pageNum'] = (int) $input->pageNum; 
		
		do {
			// since the total count the pagination is based on may not always be accurate (dups, etc.)
			// we migrate to the last populated pagination when items turn up empty
			$items = $log->getEntries($name, $options);
			if(count($items)) break;
			if($options['pageNum'] < 2) break;
			$options['pageNum']--;
		} while(1);
		
		if($config->ajax) return $this->renderLogAjax($items, $name); 
	
		/** @var InputfieldForm $form */
		$form = $modules->get('InputfieldForm');
		
		/** @var InputfieldFieldset $fieldset */
		$fieldset = $modules->get('InputfieldFieldset');
		$fieldset->attr('id', 'FieldsetTools'); 
		$fieldset->label = $this->_('Helpers');
		$fieldset->collapsed = Inputfield::collapsedYes; 
		$fieldset->icon = 'sun-o';
		$form->add($fieldset);
	
		/** @var InputfieldText $f */
		$f = $modules->get('InputfieldText'); 
		$f->attr('name', 'q');
		$f->label = $this->_('Text Search');
		$f->icon = 'search';
		$f->columnWidth = 50; 
		$fieldset->add($f);

		/** @var InputfieldDatetime $f */
		$f = $modules->get('InputfieldDatetime');
		$f->attr('name', 'date_from');
		$f->label = $this->_('Date From'); 
		$f->icon = 'calendar';
		$f->columnWidth = 25; 
		$f->datepicker = InputfieldDatetime::datepickerFocus; 
		$f->attr('placeholder', 'yyyy-mm-dd'); 
		$fieldset->add($f);
	
		/** @var InputfieldDatetime $f */
		$f = $modules->get('InputfieldDatetime');
		$f->attr('name', 'date_to');
		$f->icon = 'calendar';
		$f->label = $this->_('Date To'); 
		$f->columnWidth = 25;
		$f->attr('placeholder', 'yyyy-mm-dd'); 
		$f->datepicker = InputfieldDatetime::datepickerFocus; 
		$fieldset->add($f);
	
		/** @var InputfieldSelect $f */
		$f = $modules->get('InputfieldSelect');
		$f->attr('name', 'action');
		$f->label = $this->_('Actions');
		$f->description = $this->_('Select an action below. You will be asked to click a button before the action is executed.'); 
		$f->icon = 'fire';
		$f->collapsed = Inputfield::collapsedYes; 
		$f->addOption('download', $this->_('Download'));
		$fieldset->add($f);
		
		if($this->wire()->user->hasPermission('logs-edit')) {
			
			$f->addOption('add', $this->_('Grow (Add Entry)'));
			$f->addOption('prune', $this->_('Chop (Prune)'));
			$f->addOption('delete', $this->_('Burn (Delete)'));
		
			/** @var InputfieldInteger $f */
			$f = $modules->get('InputfieldInteger');
			$f->attr('name', 'prune_days');
			$f->label = $this->_('Chop To # Days');
			$f->inputType = 'number';
			$f->min = 1;
			$f->icon = 'cut';
			$f->description = $this->_('Reduce the size of the log file to contain only entries from the last [n] days.');
			$f->notes = $this->_('Must be 1 or greater.');
			$f->value = 30;
			$f->showIf = "action=prune";
			$fieldset->add($f);

			/** @var InputfieldText $f */
			$f = $modules->get('InputfieldText');
			$f->attr('name', 'add_text');
			$f->label = $this->_('New Log Entry');
			$f->icon = 'leaf';
			$f->showIf = "action=add";
			$fieldset->add($f);

			/** @var InputfieldSubmit $f */
			$f = $modules->get('InputfieldSubmit');
			$f->value = $this->_('Chop this log file now');
			$f->icon = 'cut';
			$f->attr('name', 'submit_prune');
			$f->showIf = 'action=prune';
			$fieldset->add($f);

			/** @var InputfieldSubmit $f */
			$f = $modules->get('InputfieldSubmit');
			$f->value = $this->_('Burn this log now (permanently delete)');
			$f->icon = 'fire';
			$f->attr('name', 'submit_delete');
			$f->showIf = 'action=delete';
			$fieldset->add($f);

			/** @var InputfieldSubmit $f */
			$f = $modules->get('InputfieldSubmit');
			$f->value = $this->_('Add this log entry');
			$f->icon = 'leaf';
			$f->attr('name', 'submit_add');
			$f->showIf = 'action=add';
			$fieldset->add($f);
		}
	
		/** @var InputfieldSubmit $f */
		$f = $modules->get('InputfieldSubmit');
		$f->value = $this->_('Download this log file now');
		$f->icon = 'download';
		$f->attr('name', 'submit_download');
		$f->showIf = 'action=download';
		$fieldset->add($f);

		$this->headline(ucfirst($name)); 
		$this->breadcrumb('../../', $this->wire()->page->title); 
	
		return 
			$form->render() . 
			"<div id='ProcessLogEntries'>" . 
			$this->renderLog($items, $name) . 
			"</div>";
	}
	
	protected function renderLogAjax(array $items, $name) {
		$input = $this->wire()->input;
		
		$time = (int) $input->get('time');
		$render = true;
		$qtyNew = 0;
		$note = '';
		if($time) {
			foreach($items as $entry) {
				$entryTime = strtotime($entry['date']);
				if($entryTime > $time) $qtyNew++;
			}
			if(!$qtyNew) $render = false;
		}
		if($qtyNew) {
			$note = sprintf($this->_n('One new log entry on page 1', 'Multiple new log entries on page 1', $qtyNew), $qtyNew);
			$note .= " (" . date('H:i:s') . ")";
		}
		$data = array(
			'qty' => -1,
			'qtyNew' => 0,
			'out' => '',
			'note' => $note,
			'time' => time(),
			'url' => $input->url() . '?' . $input->queryString()
		);
		if($render) {
			$data = array_merge($data, array(
				'qty' => count($items),
				'qtyNew' => $qtyNew, 
				'out' => $this->renderLog($items, $name, $time),
			));
		} else {
			// leave default data, which tells it not to render anything
		}
		header("Content-type: application/json;");
		return json_encode($data);
	}
	
	protected function renderLog(array $items, $name, $time = 0) {
	
		$sanitizer = $this->wire()->sanitizer;
	
		/** @var MarkupAdminDataTable $table */
		$table = $this->wire()->modules->get('MarkupAdminDataTable');
		$table->setSortable(false);
		$table->setEncodeEntities(false);
		$templateItem = reset($items);
		$headers = array(
			'date' => $this->_x('Date/Time', 'th'),
			'user' => $this->_x('User', 'th'),
			'url' => $this->_x('URL', 'th'),
			'text' => $this->_x('Text', 'th'),
		);
		
		if(empty($templateItem['user']) && empty($templateItem['url'])) {
			unset($templateItem['user'], $templateItem['url']); 
			$table->headerRow(array(
				$headers['date'], 
				$headers['text'],
			));
		} else {
			$table->headerRow($headers);
		}

		foreach($items as $entry) {
			
			$ts = strtotime($entry['date']); 
			$date = wireRelativeTimeStr($entry['date']);
			
			if($time && $ts > $time) {
				// highlight new items
				$date = "<i class='fa fa-leaf ProcessLogNew'></i> $date";
			}
			
			if(strpos($entry['text'], '&') !== false) {
				$entry['text'] = $sanitizer->unentities($entry['text']); 
			}
			
			foreach($entry as $key => $value) {
				$entry[$key] = $sanitizer->entities($value);
			}
			
			$row = array("$date<br /><span class='detail'>$entry[date]</span>");
			
			if(count($templateItem) >= 4) {
				
				$row[] = $entry['user'];
				
				$entry['url'] = preg_replace('{^https?://[^/]+}', '', $entry['url']);
				$url = $entry['url'];
				if($url == '/?/') {
					$url = 2; // array key
					$entry['url'] = '?';
				}
				$urlLabel = $this->formatLogUrlLabel($entry['url']); 
				$row[$urlLabel] = $url;
			}
			
			$row[] = $this->formatLogText($entry['text'], $name); 
			
			$table->row($row);
		}

		/** @var LogEntriesArray $entries */
		$entries = $this->wire(new LogEntriesArray());
		
		if(count($items)) {
			reset($items);
			$key = key($items);
			list($n, $total, $start, $end, $limit) = explode('/', $key);
			if($n && $end) {} // ignore
			$entries->import($items);
			$entries->setLimit($limit);
			$entries->setStart($start);
			$entries->setTotal($total);
			/** @var MarkupPagerNav $pager */
			$pager = $this->wire()->modules->get('MarkupPagerNav');
			$options = array('baseUrl' => "../$name/");
			$pagerOut = $pager->render($entries, $options);
			$pagerHeadline = $entries->getPaginationString();
			$pagerHeadline .= " " . 
				"<small class='ui-priority-secondary'>(" . 
				($pager->isLastPage() ? $this->_('actual') : $this->_('estimate')) . 
				")</small>";
			$iconClass = '';
		} else {
			$pagerHeadline = $this->_('No matching log entries');
			$iconClass = 'fa-rotate-270';
			$pagerOut = '';
		}
		
		$pageNum = $this->wire('input')->pageNum();
		$time = time();
		
		$out = 
			"<div id='ProcessLogPage' data-page='$pageNum' data-time='$time'>" .
			$pagerOut .
			"<h2 id='ProcessLogHeadline'>" . 
			"<i id='ProcessLogSpinner' class='fa fa-fw fa-lg fa-tree $iconClass'></i> $pagerHeadline " . 
			"<small class='notes'></small></h2>" .
			$table->render() .
			"<div class='ui-helper-clearfix'>$pagerOut</div>" . 
			"</div>";
		
		return $out; 

	}

	/**
	 * Format log URL label
	 * 
	 * @param string $url
	 * @return string
	 * 
	 */
	protected function formatLogUrlLabel($url) {
	
		if($url === '?') return $url;
	
		if(strpos($url, '://') !== false) {
			$url = preg_replace('{^https?://[^/]+}', '', $url);
		}
		
		$config = $this->wire()->config;
		$rootUrl = $config->urls->root; 
		$adminUrl = $config->urls->admin;
		$isAdmin = false;
		
		if(strpos($url, $adminUrl) === 0) {
			$isAdmin = true;
			$url = substr($url, strlen($adminUrl)); 
		} else if($rootUrl !== '/' && strpos($url, $rootUrl) === 0) {
			$url = substr($url, strlen($rootUrl)-1); 
		}
		
		if($isAdmin && strpos($url, 'page/edit/') !== false && preg_match('/[?&]id=(\d+)/', $url, $matches)) {
			$url = 'page/edit/?id=' . $matches[1];
		} else if($url === '/http404/') {
			$url = $this->_('404 not found');
		}
			
		if(strlen($url) > 50) {
			$url = substr($url, 0, 50) . '&hellip;';
		}
		
		return $url;
	}

	/**
	 * Format log line txt
	 * 
	 * @param string $text
	 * @param string $logName
	 * @return string
	 * 
	 */
	protected function ___formatLogText($text, $logName = '') {
		
		$config = $this->wire()->config;

		// shorten paths 
		foreach(array('site', 'wire') as $name) {
			if(strpos($text, "/$name/") === false) continue;
			$path = $config->paths($name);
			if(strpos($text, $path) !== false) {
				$text = str_replace($path, "/$name/", $text); 
			} else {
				// $text = preg_replace('![-_/\\:a-zA-Z0-9]+/' . $name . '/!', "/$name/", $text); 
			}
		}
		
		// shorten assumed namespaces
		if(strpos($text, 'ProcessWire\\') !== false) {
			$text = str_replace('ProcessWire\\', '', $text); 
		}

		// formatting of stack traces in errors/exceptions logs
		if($logName === 'errors' || $logName === 'exceptions') {
			if(strpos($text, '(line ') && preg_match('/\((line \d+ of [^)]+)\)/', $text, $matches)) {
				$text = str_replace($matches[0], "<br /><span class='notes'>" . ucfirst($matches[1]) . "</span>", $text);
			} else if(strpos($text, '(in ') && preg_match('!\((in /[^)]+? line \d+)\)!', $text, $matches)) {
				$text = str_replace($matches[0], "<br /><span class='notes'>" . ucfirst($matches[1]) . "</span>", $text);
			}
			if(strpos($text, ' #0 /')) {
				list($text, $traces) = explode(' #0 /', $text, 2);
				$traces = preg_split('! #\d+ /!', $traces);
				$text .= "<span class='detail'>";
				foreach($traces as $key => $trace) {
					$n = $key + 1;
					$text .= "<br />$n. /$trace";
				}
				$text .= "</span>";
			}
		}
	
		// identify recurring instances
		if(strpos($text, ' ^+')) {
			$_text = $text;
			list($text, $qty) = explode(' ^+', $text, 2);
			if(ctype_digit($qty)) {
				$text .= "<br />" .
					"<span class='detail'>" .
					sprintf($this->_n('Plus %d earlier duplicate ', 'Plus %d earlier duplicates', $qty), $qty) .
					"</span>";
			} else {
				// oops, restore
				$text = $_text;
			}
		}
		
		return $text;
	}

}
