<?php
/**
 * Shutdown handler for the Alsendo Connect plugin.
 *
 * @package Alsendo Connect
 */

declare( strict_types=1 );

namespace Inspire_Labs\Alsendo_Connect;

/**
 * Registers a PHP shutdown function that deactivates the plugin on fatal errors.
 *
 * Must be registered before any plugin bootstrap code runs, so that even a fatal
 * error during early loading is caught.
 *
 * Skipped for AJAX, REST API and WP-Cron requests — a fatal in those contexts
 * should not deactivate the plugin for all subsequent page loads.
 */
class ShutdownHandler {
	/**
	 * Captured uncaught throwable (PHP 7/8 fatal-like errors).
	 *
	 * In PHP 7/8 many fatal conditions (e.g. "Class not found") are thrown as
	 * \Error, so error_get_last() may not contain anything useful for shutdown.
	 *
	 * @var \Throwable|null
	 */
	private static ?\Throwable $uncaught_throwable = null;

	/**
	 * Previous exception handler, if any.
	 *
	 * @var callable|null
	 */
	private static $prev_exception_handler = null;

	/**
	 * Whether current request context should never deactivate the plugin.
	 *
	 * @return bool
	 */
	private function should_skip_deactivation(): bool {
		if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
			return true;
		}
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return true;
		}
		if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
			return true;
		}

		return false;
	}

	/**
	 * Absolute path to the main plugin file.
	 *
	 * @var string
	 */
	private string $plugin_file;

	/**
	 * Normalize file paths for cross-platform comparisons.
	 *
	 * @param string $path File path.
	 * @return string Normalized path.
	 */
	private function normalize_path( string $path ): string {
		return str_replace( '\\', '/', $path );
	}

	/**
	 * Try to log error details via WooCommerce logger (if available).
	 *
	 * @param string $message Message to log.
	 */
	private function maybe_log_with_wc_logger( string $message ): void {
		if ( ! function_exists( 'wc_get_logger' ) ) {
			return;
		}

		try {
			$logger = \wc_get_logger();
			if ( is_object( $logger ) && method_exists( $logger, 'critical' ) ) {
				$logger->critical( $message, array( 'source' => 'alsendo-shutdown-handler' ) );
				return;
			}
			if ( is_object( $logger ) && method_exists( $logger, 'error' ) ) {
				$logger->error( $message, array( 'source' => 'alsendo-shutdown-handler' ) );
			}
		} catch ( \Throwable $e ) {
			// Intentionally ignore logger failures.
		}
	}

	/**
	 * @param string $plugin_file Absolute path to the main plugin file (__FILE__ from alsendo-connect.php).
	 */
	private function __construct( string $plugin_file ) {
		$this->plugin_file = $plugin_file;
	}

	/**
	 * Register the shutdown handler.
	 *
	 * @param string $plugin_file Absolute path to the main plugin file.
	 */
	public static function register( string $plugin_file ): void {
		$handler = new self( $plugin_file );
		// Catch uncaught \Throwable (e.g. "Class not found" in PHP 7/8).
		// Keep previous handler to preserve default behavior when we decide not to act.
		self::$prev_exception_handler = set_exception_handler( array( $handler, 'handle_uncaught_throwable' ) );
		register_shutdown_function( array( $handler, 'handle' ) );
	}

	/**
	 * Exception handler for uncaught \Throwable.
	 *
	 * @param \Throwable $throwable The uncaught throwable.
	 */
	public function handle_uncaught_throwable( \Throwable $throwable ): void {
		self::$uncaught_throwable = $throwable;

		// If this throwable is not from our plugin, delegate to previous handler (if any).
		if ( ! $this->is_throwable_ours( $throwable ) ) {
			if ( is_callable( self::$prev_exception_handler ) ) {
				call_user_func( self::$prev_exception_handler, $throwable );
				return;
			}

			// Fallback to default PHP handling if there was no previous handler.
			throw $throwable;
		}

		// Never deactivate on AJAX/REST/CRON requests.
		if ( $this->should_skip_deactivation() ) {
			$this->log_only( sprintf( '%s: %s', get_class( $throwable ), $throwable->getMessage() ), $throwable );
			if ( is_callable( self::$prev_exception_handler ) ) {
				call_user_func( self::$prev_exception_handler, $throwable );
				return;
			}
			throw $throwable;
		}

		// Try to deactivate immediately (not only at shutdown) to avoid white-screen loops.
		$this->deactivate_and_die( sprintf( '%s: %s', get_class( $throwable ), $throwable->getMessage() ), $throwable );
	}

	/**
	 * Shutdown callback. Deactivates the plugin if a fatal error originated in its directory.
	 */
	public function handle(): void {
		if ( $this->should_skip_deactivation() ) {
			return;
		}

		// First: uncaught throwable (PHP 7/8 fatal-like errors).
		if ( self::$uncaught_throwable instanceof \Throwable && $this->is_throwable_ours( self::$uncaught_throwable ) ) {
			$this->deactivate_and_die(
				sprintf(
					'%s in %s:%d: %s',
					get_class( self::$uncaught_throwable ),
					self::$uncaught_throwable->getFile(),
					self::$uncaught_throwable->getLine(),
					self::$uncaught_throwable->getMessage()
				),
				self::$uncaught_throwable
			);
			return;
		}

		$error = error_get_last();

		if ( ! $error ) {
			return;
		}

		$fatal_types = array( E_ERROR, E_PARSE, E_COMPILE_ERROR, E_CORE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR );
		$is_fatal    = in_array( $error['type'], $fatal_types, true );
		$is_ours     = false !== strpos(
			$this->normalize_path( $error['file'] ),
			$this->normalize_path( plugin_dir_path( $this->plugin_file ) )
		);

		if ( ! $is_fatal || ! $is_ours ) {
			return;
		}

		$location = '';
		if ( ! empty( $error['file'] ) && ! empty( $error['line'] ) ) {
			$location = sprintf( ' in %s:%d', (string) $error['file'], (int) $error['line'] );
		}
		$this->deactivate_and_die( (string) $error['message'] . $location );
	}

	/**
	 * Check whether throwable originated from this plugin directory (best-effort).
	 *
	 * @param \Throwable $throwable Throwable.
	 * @return bool
	 */
	private function is_throwable_ours( \Throwable $throwable ): bool {
		$plugin_dir = $this->normalize_path( plugin_dir_path( $this->plugin_file ) );
		$file       = $this->normalize_path( $throwable->getFile() );

		if ( false !== strpos( $file, $plugin_dir ) ) {
			return true;
		}

		// If the top-level file is not ours, still check the stack trace frames.
		foreach ( $throwable->getTrace() as $frame ) {
			if ( empty( $frame['file'] ) ) {
				continue;
			}
			if ( false !== strpos( $this->normalize_path( (string) $frame['file'] ), $plugin_dir ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Format a short stack trace for logs.
	 *
	 * @param \Throwable $throwable Throwable.
	 * @param int        $max_frames Maximum frames.
	 * @return string
	 */
	private function format_trace( \Throwable $throwable, int $max_frames = 12 ): string {
		$lines = array();
		$idx   = 0;

		foreach ( $throwable->getTrace() as $frame ) {
			$file = isset( $frame['file'] ) ? (string) $frame['file'] : '';
			$line = isset( $frame['line'] ) ? (int) $frame['line'] : 0;
			$func = '';

			if ( isset( $frame['class'] ) || isset( $frame['function'] ) ) {
				$func = ( isset( $frame['class'] ) ? (string) $frame['class'] : '' )
					. ( isset( $frame['type'] ) ? (string) $frame['type'] : '' )
					. ( isset( $frame['function'] ) ? (string) $frame['function'] : '' )
					. '()';
			}

			$at = '';
			if ( '' !== $file && 0 !== $line ) {
				$at = sprintf( '%s:%d', $file, $line );
			} elseif ( '' !== $file ) {
				$at = $file;
			}

			$lines[] = sprintf( '#%d %s%s%s', $idx, $func, ( '' !== $func && '' !== $at ) ? ' at ' : '', $at );
			$idx++;
			if ( $idx >= $max_frames ) {
				break;
			}
		}

		return implode( "\n", $lines );
	}

	/**
	 * Deactivate the plugin and abort the request with a message.
	 *
	 * @param string $reason Error/exception message.
	 * @param \Throwable|null $throwable Throwable for extra context.
	 */
	private function deactivate_and_die( string $reason, ?\Throwable $throwable = null ): void {
		if ( ! function_exists( 'deactivate_plugins' ) && defined( 'ABSPATH' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$detail_message = 'The Alsendo Connect plugin has been deactivated due to an error: ' . $reason;
		if ( $throwable instanceof \Throwable ) {
			$detail_message .= sprintf( "\nOrigin: %s:%d", $throwable->getFile(), $throwable->getLine() );
			$trace = $this->format_trace( $throwable );
			if ( '' !== $trace ) {
				$detail_message .= "\nStack trace (truncated):\n" . $trace;
			}
		}
		$user_message   = 'An error occurred. Please refresh the page and try again.';
		$show_details   = function_exists( 'current_user_can' ) && current_user_can( 'activate_plugins' );
		$die_message    = $show_details ? $detail_message : $user_message;

		$this->maybe_log_with_wc_logger( $detail_message );

		if ( function_exists( 'deactivate_plugins' ) && function_exists( 'plugin_basename' ) ) {
			deactivate_plugins( plugin_basename( $this->plugin_file ) );
		}

		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		error_log( $detail_message );

		if ( function_exists( 'wp_die' ) && function_exists( 'esc_html' ) ) {
			wp_die( esc_html( $die_message ), '', array( 'response' => 500 ) );
		}

		// Last-resort fallback (if WP isn't fully loaded).
		if ( $show_details ) {
			echo htmlspecialchars( $detail_message, ENT_QUOTES, 'UTF-8' );
		} else {
			echo htmlspecialchars( $user_message, ENT_QUOTES, 'UTF-8' );
		}
		exit( 1 );
	}

	/**
	 * Log error details without deactivating the plugin.
	 *
	 * @param string          $reason Error/exception message.
	 * @param \Throwable|null $throwable Throwable for extra context.
	 */
	private function log_only( string $reason, ?\Throwable $throwable = null ): void {
		$detail_message = 'Alsendo Connect error (no deactivation): ' . $reason;
		if ( $throwable instanceof \Throwable ) {
			$detail_message .= sprintf( "\nOrigin: %s:%d", $throwable->getFile(), $throwable->getLine() );
			$trace = $this->format_trace( $throwable );
			if ( '' !== $trace ) {
				$detail_message .= "\nStack trace (truncated):\n" . $trace;
			}
		}

		$this->maybe_log_with_wc_logger( $detail_message );
		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		error_log( $detail_message );
	}
}

