<?php
/**
 * Matador Jobs / Core / Activity Logger
 *
 * @link        https://matadorjobs.com/
 * @since       3.9.0
 *
 * @package     Matador Jobs Board
 * @subpackage  Core
 * @author      Matador Holdings, Inc
 * @copyright   (c) 2017-2024, Matador Holdings, Inc
 *
 * @license     http://opensource.org/licenses/gpl-3.0.php GNU Public License
 */

namespace matador\MatadorJobs\Core;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use DateTimeImmutable;
use Exception;
use matador\Matador;
use matador\Admin_Notices;

/**
 * Class Logger
 *
 * @since 3.9.0
 */
final class Logger {

    /**
	 * Stores the file name of the log file
	 *
	 * @since 3.9.0
	 *
	 * @var string
	 */
	private string $file;

    /**
	 * Stores the time now
	 *
	 * @since 3.9.0
	 *
	 * @var string
	 */
	private string $now;

	/**
	 * Keeps the position in the current file
	 *
	 * @since 3.9.0
	 *
	 * @var resource
	 */
	private $pointer;

	/**
	 * Variable Instance
	 *
	 * @since 3.9.0
	 *
	 * @var ?Logger
	 */
	private static ?Logger $instance = null;

    /**
	 * Constructor
	 *
	 * @since 3.9.0
	 */
	public function __construct() {
        
		if ( ! file_exists( Matador::variable( 'log_file_path' ) ) ) {

			if ( ! wp_mkdir_p( Matador::variable( 'log_file_path' ) ) ) {

				Admin_Notices::add( __( "Matador did not find a log file directory and was unable to create one. Please add the folder <kbd>wp-content/matador_uploads</kbd> with permissions <kbd>644</kbd>", 'matador-jobs' ), Severity::ERROR->value, 'matador-folder-create-failed' );
				
				return;
			}
		}

		if ( ! file_exists( Matador::variable( 'log_file_path' ) . '/index.php' ) ) {
			touch( Matador::variable( 'log_file_path' ) . '/index.php' ); // @codingStandardsIgnoreLine
		}

		$this->now = current_time( 'mysql' );

		if ( ! get_transient( 'matador_activity_log_delete_logs' ) ) {
			self::delete_logs();
			set_transient( 'matador_activity_log_delete_logs', true, 4 * HOUR_IN_SECONDS );
		}

		$this->file = Matador::variable( 'log_file_path' ) . current_time( 'Y-m-d' ) . '-matador-log-' . self::get_hash() .'.txt';

		if ( ! file_exists( $this->file ) ) {
			$this->write_header();
		}
	}

	/**
	 * Throw error on object clone.
	 *
	 * Singleton design pattern means is that there is a single object,
	 * and therefore, we don't want or allow the object to be cloned.
	 *
	 * @since 3.9.0
	 *
	 * @return void
	 */
	public function __clone() {
		_doing_it_wrong( __FUNCTION__, esc_html__( 'No can do! You may not clone an instance of the singleton class.', 'matador-jobs' ), esc_attr( Matador::VERSION ) );
	}

	/**
	 * Disable unserializing of the class.
	 *
	 * Unserializing of the class is also forbidden in the singleton pattern.
	 *
	 * @since 3.9.0
	 *
	 * @return void
	 */
	public function __wakeup(): void {
		_doing_it_wrong( __FUNCTION__, esc_html__( 'No can do! You may not unserialize an instance of the singleton class.', 'matador-jobs' ), esc_attr( Matador::VERSION ) );
	}

	/**
	 * Entry
	 *
	 * @since 3.9.0
	 *
	 * @param string $key               Required. The log entry key name. Should be unique to ensure easy searching of log files and code.
	 * @param string $message           Required. The translated message for the log item.
	 * @param string|Severity $severity The severity level. Optional. Accepts 'note', 'info', 'notice', 'warning', 'error', 'critical'. Defaults to 'notice'.
	 * @param array  $meta              Array of information about the action being logged, may be output in line. Optional.
	 *
	 * @return void
	 */
	public static function entry( string $key, string $message, string|Severity $severity = 'notice', array $meta = [] ): void {

		if ( ! ( isset( self::$instance ) ) && ! ( self::$instance instanceof Logger ) ) {
			self::$instance = new self();
		}

		if ( ! $severity instanceof Severity ) {
			$severity = Severity::tryFrom( strtolower( trim( $severity ) ) ) ?? Severity::NOTICE;
		}

		if ( ! self::$instance::should_log( $key, $severity, $meta ) ) {

			return;
		}

		self::$instance->write( $key, $message, $severity, $meta );
	}

    /**
	 * Write File
	 *
	 * @since 3.9.0
	 *
     * @param string   $key      Required. The log entry key name. Should be unique to ensure easy searching of log files and code.
     * @param string   $message  Required. The translated message for the log item.
     * @param Severity $severity The severity level. Optional. Accepts 'note', 'info', 'notice', 'warning', 'error', 'critical'. Defaults to 'notice'.
     * @param array    $meta     Array of information about the action being logged, may be output in line. Optional.
     *
	 * @return void
	 */
	private function write( string $key, string $message, Severity $severity, array $meta = [] ) : void {

		/**
		 * Matador Activity Log Before Write
		 *
		 * @wordpress-filter `matador_activity_log_entry_before_write`
		 *
		 * Action fires before an activity log write is completed.
		 *
		 * @since 3.9.0
		 *
		 * @param string $key      Required. The log entry key name. Should be unique to ensure easy searching of log files and code.
		 * @param string $message  Required. The translated message for the log item.
		 * @param string $severity The severity level. Optional. Accepts 'note', 'info', 'notice', 'warning', 'error', 'critical'. Defaults to 'notice'.
		 * @param array  $meta     Array of information about the action being logged, may be output in line. Optional.
		 * @param Logger $this     Current instance of the Logger class.
		 */
		do_action( 'matador_activity_log_entry_before_write', $key, $message, $severity->value, $meta, self::$instance );

		$this->open();

		$line = sprintf( '%1$s [%2$s]: %4$s (%3$s)', current_time( 'mysql' ), strtoupper( $severity->value ), $key, $message ) . "\n";

		/**
		 * Matador Activity Log Verbose Mode
		 * @wordpress-filter `matador_activity_log_verbose`
		 *
		 * Whether to print the array of data associated with the activity log entry, but is overridden by the
		 * `MATADOR_LOGGING_VERBOSE` constant.
		 *
		 * @since 3.9.0
		 *
		 * @param bool $should True/False should print. Default false.
		 * @return bool
		 */
		$verbose = apply_filters( 'matador_activity_log_verbose', false );

		$verbose = defined( 'MATADOR_LOGGING_VERBOSE' ) ? constant( 'MATADOR_LOGGING_VERBOSE' ) : $verbose;

        if ( $verbose && ! empty( $meta ) ) {
            $line .= print_r( $meta, true ) . "\n";
        }

		fwrite( $this->pointer, $line ); // @codingStandardsIgnoreLine

		$this->close();

		/**
		 * Matador Activity Log After Write
		 *
		 * @wordpress-filter `matador_activity_log_entry_after_write`
		 *
		 * Action fires after an activity log write is completed.
		 *
		 * @since 3.9.0
		 *
		 * @param string $key      Required. The log entry key name. Should be unique to ensure easy searching of log files and code.
		 * @param string $message  Required. The translated message for the log item.
		 * @param string $severity The severity level. Optional. Accepts 'note', 'info', 'notice', 'warning', 'error', 'critical'. Defaults to 'notice'.
		 * @param array  $meta     Array of information about the action being logged, may be output in line. Optional.
		 * @param Logger $this     Current instance of the Logger class.
		 */
		do_action( 'matador_activity_log_entry_after_write', $key, $message, $severity->value, $meta, self::$instance );
	}

    /**
	 * Write File Header
	 *
	 * @since 3.9.0
	 *
	 * @return void
	 */
	private function write_header() : void {

		global $wp_version;

		$headers = [
			'Matador Jobs Activity Log',
			'File created: ' . $this->now,
			'Matador Version: ' . Matador::VERSION,
			'WordPress Version: ' . $wp_version,
			'PHP Version: ' . phpversion(),
		];

		/**
		 * Filter: Activity Log Header
		 * @wordpress-filter `matador_activity_log_file_header`
		 *
		 * This filter allows you to add(remove) lines to the log header.
		 *
		 * @since 3.7.5
		 * @since 3.9.0 Renamed from `matador_event_log_file_headers`
		 *
		 * @param array $headers Indexed array of text lines, one string per line.
		 *
		 * @returns array
		 */
		$headers = apply_filters( 'matador_activity_log_file_header', $headers );

		$this->open();

		foreach ( $headers as $line ) {
			fwrite( $this->pointer, $line . "\n" );
		}

		fwrite( $this->pointer, str_pad( '', 40, '=' ) . "\n" );

		$this->close();
	}

    /**
	 * Open File
	 *
	 * @since 3.9.0
	 *
	 * @return void
	 */
	private function open() : void {
		$this->pointer = fopen( $this->file, 'a+' ); // @codingStandardsIgnoreLine
	}

    /**
	 * Close File
	 *
	 * @since 3.9.0
	 *
	 * @return void
	 */
	private function close() : void {
		fclose( $this->pointer ); // @codingStandardsIgnoreLine
	}

    /**
	 * Is Activity Logging Enabled?
	 *
	 * Checks that logging is enabled either via config constant `MATADOR_LOGGING_ENABLED` or the value of the setting
     * from the plugin options.
	 *
	 * @since 3.9.0
	 *
	 * @return bool
	 */
	private static function is_enabled() : bool {

		return defined( 'MATADOR_LOGGING_ENABLED' ) ? constant( 'MATADOR_LOGGING_ENABLED' ) : 0 < Matador::setting( 'logging' );
	}

    /**
     * Checks if the current message should be written to the log file
     * 
     * @since 3.9.0
     *
     * @param string   $key      The log entry key name. Required.
     * @param Severity $severity The severity level. Required. @see class::__construct() description of levels.
     * @param array    $meta     Array of information about the activity being logged. Optional.
     *
     * @return bool
     */
    private static function should_log( string $key, Severity $severity, array $meta = [] ) : bool {

        if ( ! self::is_enabled() ) {

			return false;
		}

		if ( $severity === SEVERITY::INFO ) {

			return true;
		}

        $setting = defined( 'MATADOR_LOGGING_LEVEL' ) ? constant( 'MATADOR_LOGGING_LEVEL' ) : ( Matador::setting( 'logging_level' ) ?: 'warning' );

		$setting = Severity::tryFrom( strtolower( trim( esc_attr( $setting ) ) ) ) ?? Severity::WARNING;

		$should_log = ( $severity->level() >= $setting->level() );

	    $args = [
            'level'   => $severity->value,
            'setting' => $setting->value,
            'key'     => $key,
            'meta'    => $meta,
        ];

        /**
         * Matador Logger Should Log Activity
         * @wordpress-filter `matador_activity_logger_should_log`
         *
         * This filter allows developers to implement custom logic to determine if a message should be logged.
         *
         * @since 3.9.0
         *
         * @param bool  $should_log True/False for whether the code determined we should log the entry.
         * @param array $args       Associative array of information about the current loggable activity.
         *
         * @returns bool
         */
        return apply_filters( 'matador_activity_logger_should_log', $should_log, $args );
    }

    /**
	 * Get Hash
	 *
	 * Appends a hash to the file names to obfuscate log file names for security purposes.
     * 
     * @since 3.9.0
     * @access private
	 *
	 * @return string
	 */
	private static function get_hash() : string {

		return substr( md5( Matador::variable( 'log_file_path' ) ), 0, 12 );
	}

    /**
	 * List Activity Log Files
	 *
	 * @since 3.9.0
	 *
	 * @return string
	 */
	public static function list_logs() : string {

		$files    = array_reverse( glob( Matador::variable( 'log_file_path' ) . '*-matador-log*.txt' ) );
		$output   = '';
		$base_url = Matador::variable( 'uploads_base_url' );

		$days_to_keep_logs = defined( 'MATADOR_LOGGING_DAYS' ) ? constant( 'MATADOR_LOGGING_DAYS' ) : (int) Matador::setting( 'logging' );

		if ( $days_to_keep_logs < count( $files ) || '-1' === $days_to_keep_logs ) {
			self::delete_logs(); // deletes logs
			// re-call list of file as we may have just removed one(some).
			$files = array_reverse( glob( Matador::variable( 'log_file_path' ) . '*-matador-log*.txt' ) );
		}

		if ( empty( $files ) ) {
			return '<br />' . esc_html__( 'No Matador Activity Log files were found.', 'matador-jobs' );
		}

		foreach ( $files as $file ) {
			$fullpath = explode( wp_normalize_path( DIRECTORY_SEPARATOR ), $file );
			$filename = end( $fullpath );
			$output  .= sprintf( '<br /><a href="%1$s" target="_blank">%2$s</a>', esc_url( $base_url . $filename ), $filename );
		}

		return $output;
	}

    /**
	 * Delete Activity Log Files
	 *
	 * @since 3.9.0
     * 
     * @return void
	 */
	public static function delete_logs() : void {

		$files       = glob( Matador::variable( 'log_file_path' ) . '*-matador-log*.txt' );
		$date_offset = defined( 'MATADOR_LOGGING_DAYS' ) ? constant( 'MATADOR_LOGGING_DAYS' ) : (int) Matador::setting( 'logging' );

		if ( 0 === $date_offset ) {

			return;
		}

		foreach ( $files as $file ) {
			$fullpath = explode( DIRECTORY_SEPARATOR, $file );
			$filename = explode( '-', end( $fullpath ) );
			if( strtotime( $filename[0] .'-' . $filename[1] . '-'. $filename[2] ) ) {
				$filedate = $filename[0] .'-' . $filename[1] . '-'. $filename[2];
			} else {
				$filedate = $filename[0];
			}

			if ( strtotime( $filedate ) ) {
				try {
					$filedate          = new DateTimeImmutable( $filedate );
					$date_file_created = new DateTimeImmutable( current_time( 'mysql' ) );
					$age_of_file       = $filedate->diff( $date_file_created );
				} catch ( Exception $error ) {
					self::entry( 'matador-activity-log-delete-expired-log-date-error', __( 'Activity log expired check caused an Exception on DateTimeImmutable. Log file not deleted.', 'matador-jobs' ), Severity::WARNING->value, [ 'filename' => $file, 'error' => $error ] );
					continue;
				}

				if ( $date_offset < $age_of_file->days && file_exists( $file ) ) {
					unlink( $file );
					self::entry( 'matador-activity-log-expired-log-removed', __( 'Expired activity log removed.', 'matador-jobs' ), Severity::INFO->value, [ 'filename' => $file ] );
				}
			} else {
				unlink( $file );
				self::entry( 'matador-activity-log-unknown-log-removed', __( 'Bad hashed log file removed.', 'matador-jobs' ), Severity::WARNING->value, [ 'filename' => $file ] );
			}
		}
	}
}
