<?php
declare( strict_types=1 );

namespace FiboFilters\Indexer;

use FiboFilters\WooCommerce\Helper as WooCommerceHelper;

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

/**
 * Class Logger
 */
class Logger {
	const SOURCE         = 'fibofilters-indexer';
	const FAILURE_SOURCE = 'fibofilters-fail-indexer';

	/** @var \WC_Log_Handler_File */
	private $wc_log_handler_file;

	/** @var BuildInfo */
	private $build_info;

	/** @var Config */
	private $config;

	/** @var WooCommerceHelper */
	private $woocommerce_helper;

	public function __construct( BuildInfo $build_info, Config $config, WooCommerceHelper $woocommerce_helper ) {
		$this->build_info         = $build_info;
		$this->config             = $config;
		$this->woocommerce_helper = $woocommerce_helper;
	}

	/**
	 * Init WooCommerce log file handler
	 */
	private function maybe_init(): void {
		if ( $this->wc_log_handler_file === null ) {
			$this->wc_log_handler_file = $this->woocommerce_helper->get_wc_log_handler_file();
		}
	}

	/**
	 * Log indexer message
	 *
	 * @param string $message Log message.
	 * @param string $level One of the following:
	 *     'emergency': The indexer has stopped due to a fatal error.
	 *     'warning': PHP warnings.
	 *     'notice': PHP notices.
	 *     'info': Informational messages about indexer.
	 *     'debug': Debug-level messages.
	 * @param string $destination Destination. Choices: 'db', 'file' or 'both'.
	 * @param string $scope Scope of log. Choices: look at self::$indexerDebugScopes.
	 * @param array  $context Context.
	 */
	public function log( string $message, string $level = 'info', string $destination = 'both', string $scope = 'all', array $context = [] ): void {
		if ( defined( 'FIBOFILTERS_DISABLE_INDEXER_LOGS' ) && FIBOFILTERS_DISABLE_INDEXER_LOGS ) {
			return;
		}

		if ( $destination === 'file' || $destination === 'both' ) {
			$this->log_to_file( $message, $level, $scope, $context );
		}

		if ( $destination === 'db' || $destination === 'both' ) {
			$this->build_info->add_log( $message, $level );
		}
	}

	/**
	 * Log message to file
	 *
	 * @param string $message Message.
	 * @param string $level Log level.
	 * @param string $scope Scope.
	 * @param array  $context Context.
	 */
	public function log_to_file( string $message, string $level, string $scope = 'all', array $context = [] ): void {
		if ( ! $this->woocommerce_helper->is_valid_log_level( $level ) ) {
			/* translators: 1: Class method 2: debug level */
			_doing_it_wrong( __METHOD__, sprintf( esc_html_x( '%1$s was called with an invalid level "%2$s".', '[admin]', 'fibofilters' ), '<code>Logger::log</code>', esc_html( $level ) ), '1.0' );
		}

		// Skip debug messages is debugging for indexer is disabled or scope is not enabled.
		if (
			( $level === 'debug' && ! $this->config->is_debug() ) ||
			( $level === 'debug' && ! $this->config->is_debug_scope_active( $scope ) )
		) {
			return;
		}

		$this->maybe_init();

		$context = wp_parse_args(
			$context,
			[
				'source' => self::SOURCE,
			]
		);

		// We use "local" timestamp, because we want local date-time in logs.
		$local_timestamp = current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
		$message         = wp_strip_all_tags( $message );

		// Add process ID to every message.
		if ( function_exists( 'ini_get' ) && strpos( (string) ini_get( 'disable_functions' ), 'getmypid' ) === false ) {
			$message = '[' . getmypid() . '] ' . $message;
		}

		$this->wc_log_handler_file->handle( $local_timestamp, $level, $message, $context );
	}

	/**
	 * Remove logs from /wc-logs
	 *
	 * @param string $source Logs source.
	 */
	public function remove_logs( string $source = self::SOURCE ): void {
		$this->maybe_init();

		$files = $this->wc_log_handler_file->get_log_files();
		foreach ( $files as $handle => $filename ) {
			if ( strpos( $handle, $source ) !== false ) {
				$this->wc_log_handler_file->remove( $handle );
			}
		}
	}

	/**
	 * Logging of the most recent indexer error
	 *
	 * Used in conjunction with register_shutdown_function() function
	 *
	 * @param string $prefix Message prefix.
	 */
	public function maybe_log_error( string $prefix = '' ): void {
		$error = error_get_last();
		if ( is_null( $error ) ) {
			return;
		}

		$error_code = $this->get_error_code( $error['message'] );

		$error_string = $prefix . ( $error_code ? $error_code . ' ' : '' ) . $error['message'];

		$error_string .= ' | Type: ' . $this->code_to_string( $error['type'] );
		$error_string .= ' | File: ' . $error['file'] . ':' . $error['line'];

		if ( $this->is_warning_or_notice( $error['type'] ) ) {
			$level = $this->get_error_level( $error['type'] );
			$this->log( $error_string, $level, 'file' );
		} else {
			$this->log( $error_string, 'emergency', 'both' );
			do_action( 'fibofilters/indexer/cancel', 'error', 'Stop building the index. Starting the cancellation process.', false );
			sleep( 3 );
		}
	}

	/**
	 * @param \Throwable $e Error.
	 * @param string     $prefix Error log prefix.
	 */
	public function handle_throwable_error( \Throwable $e, string $prefix = '' ): bool {
		$error_code = $this->get_error_code( $e->getMessage() );

		$error_string = $prefix . ( $error_code ? $error_code . ' ' : '' ) . $e->getMessage();

		$type = $this->code_to_string( $e->getCode() );
		if ( ! empty( $type ) ) {
			$error_string .= ' | Type: ' . $type;
		}
		$error_string .= ' | File: ' . $e->getFile();
		$error_string .= ' | Trace: ' . $e->getTraceAsString();

		$level      = 'emergency';
		$error_type = $this->get_non_critical_error_type( $e->getMessage() );

		if ( ! empty( $error_type ) ) {
			$level = 'warning';

			// Store a given error type only once in the database.
			$non_critical_errors = $this->build_info->get_info( 'non_critical_errors', $this->config->get_index_role() );

			if ( ! isset( $non_critical_errors[ $error_type ] ) ) {
				$non_critical_errors[ $error_type ] = $error_string;
				$this->build_info->add_info( 'non_critical_errors', $non_critical_errors );

				$this->log( $error_string, $level, 'both' );
			} else {
				$this->log( $error_string, $level, 'file' );
			}

			return true;
		} else {
			if ( $this->build_info->get_info( 'status', $this->config->get_index_role() ) !== 'building' ) {
				$this->log( 'Critical error handling is omitted because the index is not being built', 'debug', 'file' );

				return false;
			}

			$this->log( $error_string, $level );
			do_action( 'fibofilters/indexer/cancel', 'error', 'Stop building the index. Starting the cancellation process.', false );
			sleep( 3 );

			return false;
		}
	}

	/**
	 * @param \Throwable $e Error.
	 * @param string     $prefix Error log prefix.
	 */
	public function handle_updater_throwable_error( \Throwable $e, string $prefix = '' ): void {
		$error_code = $this->get_error_code( $e->getMessage() );

		$error_string = $prefix . ( $error_code ? $error_code . ' ' : '' ) . $e->getMessage();

		$type = $this->code_to_string( $e->getCode() );
		if ( ! empty( $type ) ) {
			$error_string .= ' | Type: ' . $type;
		}
		$error_string .= ' | File: ' . $e->getFile();
		$error_string .= ' | Trace: ' . $e->getTraceAsString();

		$level      = 'emergency';
		$error_type = $this->get_non_critical_error_type( $e->getMessage() );
		if ( ! empty( $error_type ) ) {
			$level = 'warning';
		}

		$this->log_to_file( $error_string, $level, 'all', [ 'source' => 'fibofilters-updater' ] );
	}

	/**
	 * Convert error type to its string representation
	 *
	 * @param int $type Error type.
	 */
	public function code_to_string( int $type ): string {
		$error_codes = [
			E_ERROR             => 'E_ERROR', // 1
			E_WARNING           => 'E_WARNING', // 2
			E_PARSE             => 'E_PARSE', // 4
			E_NOTICE            => 'E_NOTICE', // 8
			E_CORE_ERROR        => 'E_CORE_ERROR', // 16
			E_CORE_WARNING      => 'E_CORE_WARNING', // 32
			E_COMPILE_ERROR     => 'E_COMPILE_ERROR', // 64
			E_COMPILE_WARNING   => 'E_COMPILE_WARNING', // 128
			E_USER_ERROR        => 'E_USER_ERROR', // 256
			E_USER_WARNING      => 'E_USER_WARNING', // 512
			E_USER_NOTICE       => 'E_USER_NOTICE', // 1024
			E_STRICT            => 'E_STRICT', // 2048
			E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', // 4096
			E_DEPRECATED        => 'E_DEPRECATED', // 8192
			E_USER_DEPRECATED   => 'E_USER_DEPRECATED', // 16384
		];

		return $error_codes[ $type ] ?? '';
	}

	/**
	 * Get code and message of last emergency log from indexer logs
	 *
	 * @return array Code and message
	 */
	public function get_last_emergency_log(): array {
		$this->maybe_init();

		$last_error_code    = '';
		$last_error_message = '';

		$files = $this->wc_log_handler_file->get_log_files();
		foreach ( $files as $handle => $filename ) {
			if ( strpos( $handle, self::SOURCE ) === false ) {
				continue;
			}
			if ( ! file_exists( WC_LOG_DIR . $filename ) ) {
				continue;
			}
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			$handle = fopen( WC_LOG_DIR . $filename, 'r' );
			if ( $handle ) {
				// phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
				while ( ( $line = fgets( $handle ) ) !== false ) {
					if ( strpos( $line, 'EMERGENCY' ) === false ) {
						continue;
					}
					preg_match_all( '/\[Error [C|c]ode: (\d+)]/m', $line, $matches, PREG_SET_ORDER, 0 );
					if ( isset( $matches[0][1] ) ) {
						$last_error_code = (string) $matches[0][1];
					}
					$last_error_message = $line;
				}
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				fclose( $handle );
			}
			break;
		}

		return [ $last_error_code, $last_error_message ];
	}

	/**
	 * Get non-critical error type
	 *
	 * @param string $message Error message.
	 *
	 * @return string Error type or empty string.
	 */
	public function get_non_critical_error_type( string $message ): string {
		preg_match_all( '/Call to a member function [a-zA-Z0-9-_\x80\xff]*\(\) on (null|string|bool|array|non-object)/mi', $message, $matches, PREG_SET_ORDER, 0 );
		if ( isset( $matches[0] ) ) {
			return 'call-on-non-object';
		}

		preg_match_all( '/Database error \"WordPress database error: Could not perform query because it contains invalid data.\"/mi', $message, $matches, PREG_SET_ORDER, 0 );
		if ( isset( $matches[0] ) ) {
			return 'wpdb-invalid-data';
		}

		// We check the translation of this error, because it sometimes appears in logs.
		preg_match_all( '/Database error \"' . __( 'WordPress database error: Could not perform query because it contains invalid data.' ) . '\"/mi', $message, $matches, PREG_SET_ORDER, 0 ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain
		if ( isset( $matches[0] ) ) {
			return 'wpdb-invalid-data';
		}

		return '';
	}

	/**
	 * Get error code by its message
	 *
	 * @param string $message Error message.
	 */
	public function get_error_code( string $message ): string {
		preg_match_all( '/User (.*) already has more than \'max_user_connections\' active connections/mi', $message, $matches, PREG_SET_ORDER, 0 );
		if ( isset( $matches[0] ) ) {
			return '[Error code: 003]';
		}
		preg_match_all( '/Too many connections/mi', $message, $matches, PREG_SET_ORDER, 0 );
		if ( isset( $matches[0] ) ) {
			return '[Error code: 004]';
		}
		preg_match_all( '/MySQL server has gone away/mi', $message, $matches, PREG_SET_ORDER, 0 );
		if ( isset( $matches[0] ) ) {
			return '[Error code: 005]';
		}

		return '';
	}

	public function copy_last_logs( string $source = self::SOURCE, string $destination = self::FAILURE_SOURCE ): void {
		$filenames = self::get_log_filenames( $source );

		$this->remove_logs( $destination );

		foreach ( $filenames as $filename ) {
			if ( strpos( $filename, $source ) !== false ) {
				$filename_copy = str_replace( $source, $destination, $filename );
				copy( $filename, $filename_copy );
			}
		}
	}

	/**
	 * Get log filenames
	 *
	 * @param string $source Logs source.
	 *
	 * @return array Filenames
	 */
	public function get_log_filenames( string $source = self::SOURCE ): array {
		$this->maybe_init();

		$files     = $this->wc_log_handler_file->get_log_files();
		$filenames = [];

		foreach ( $files as $handle => $filename ) {
			if ( strpos( $handle, $source ) !== false ) {
				if ( file_exists( WC_LOG_DIR . $filename ) ) {
					$filenames[] = WC_LOG_DIR . $filename;
				}
			}
		}

		return $filenames;
	}

	private function is_warning_or_notice( int $type ): bool {
		return in_array(
			$type,
			[
				E_NOTICE,
				E_WARNING,
				E_CORE_WARNING,
				E_COMPILE_WARNING,
				E_USER_WARNING,
				E_USER_NOTICE,
				E_STRICT,
				E_DEPRECATED,
				E_USER_DEPRECATED,
			],
			true
		);
	}

	private function get_error_level( int $type ): string {
		$level = 'emergency';

		switch ( $type ) {
			case E_NOTICE:
			case E_USER_NOTICE:
			case E_STRICT:
			case E_DEPRECATED:
			case E_USER_DEPRECATED:
				$level = 'notice';
				break;
			case E_WARNING:
			case E_CORE_WARNING:
			case E_COMPILE_WARNING:
			case E_USER_WARNING:
				$level = 'warning';
				break;
		}

		return $level;
	}
}
