<?php
/**
 * Site-wide maintenance mode.
 *
 * Unlike WP core's 10-minute `.maintenance` flag (designed for upgrades),
 * this honours the toggle indefinitely, serves a branded 503 page, and lets
 * admins + whitelisted IPs bypass.
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

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

class MaintenanceMode {

	const OPTION = 'backreload_maintenance';

	public function boot(): void {
		// `template_redirect` runs after WP is fully bootstrapped but before any
		// template output starts — perfect spot to interrupt with our own page.
		add_action( 'template_redirect', [ $this, 'maybe_serve' ], 1 );

		// Safety net: WP-core's wp_maintenance() reads .maintenance BEFORE auth
		// runs, so a stale file would lock out admins. Clean it up on every boot.
		// (Real restores still use it transiently — see RestoreEngine.)
		$stuck = ABSPATH . '.maintenance';
		if ( file_exists( $stuck ) ) {
			$ts = 0;
			$src = (string) @file_get_contents( $stuck );
			if ( preg_match( '/\d+/', $src, $m ) ) $ts = (int) $m[0];
			// Older than 5 min and we own the toggle? Remove it.
			if ( $ts && ( time() - $ts ) > 5 * MINUTE_IN_SECONDS ) {
				@unlink( $stuck ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
			}
		}
	}

	public function status(): array {
		$cfg = (array) get_option( self::OPTION, [] );
		return wp_parse_args( $cfg, [
			'active'      => false,
			'heading'     => "We'll be right back",
			'message'     => 'The site is briefly offline while we run maintenance. Check back in a few minutes.',
			'eta'         => '', // ISO 8601 datetime, optional
			'bypass_ips'  => [],
			'started_at'  => null,
			'started_by'  => null,
		] );
	}

	public function is_active(): bool {
		return (bool) ( $this->status()['active'] ?? false );
	}

	/**
	 * @param array{heading?:string,message?:string,eta?:string,bypass_ips?:array<int,string>} $config
	 */
	public function enable( array $config = [] ): void {
		$cur = $this->status();
		$next = array_merge( $cur, $config, [
			'active'     => true,
			'started_at' => current_time( 'mysql', true ),
			'started_by' => function_exists( 'wp_get_current_user' ) ? wp_get_current_user()->user_login : null,
		] );
		update_option( self::OPTION, $next );

		// NOTE: we do NOT write WP's `.maintenance` file here. That file is read
		// by wp_maintenance() before any authentication runs, so it would lock
		// out admins (defeating the whole point of a manual toggle). Our own
		// template_redirect handler renders the branded page with proper bypass.
	}

	public function disable(): void {
		$cur = $this->status();
		$cur['active'] = false;
		update_option( self::OPTION, $cur );
		@unlink( ABSPATH . '.maintenance' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
	}

	public function maybe_serve(): void {
		if ( ! $this->is_active() ) return;

		// Logged-in admins keep working.
		if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
			return;
		}

		// IP whitelist for staging / VPNs.
		$ip = $this->client_ip();
		foreach ( (array) ( $this->status()['bypass_ips'] ?? [] ) as $allowed ) {
			$allowed = trim( $allowed );
			if ( $allowed && $allowed === $ip ) return;
		}

		// REST / wp-admin should remain reachable so the toggle keeps working
		// (a stuck "on" toggle with no way back would be a disaster).
		if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
			return;
		}

		status_header( 503 );
		header( 'Retry-After: 600' );
		header( 'Content-Type: text/html; charset=utf-8' );
		nocache_headers();
		// The render() function builds a self-contained HTML document whose
		// every dynamic value is wrapped in esc_html() / esc_attr() at the
		// echo site (see the function body). The output is safe HTML — no
		// further escaping at this boundary or the page would be double-
		// encoded into a string of escaped tags. Marked safe explicitly so
		// the linter doesn't flag echoing it.
		echo $this->render( $this->status() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render() escapes inline.
		exit;
	}

	/**
	 * Branded 503 page. Single-file, no external dependencies — works even if
	 * other plugins / the active theme are broken.
	 *
	 * Every value pulled from the maintenance options is held as a RAW string
	 * here and escaped at the echo site below. Doing escaping inline keeps the
	 * data-flow obvious to the WP.org Plugin Check linter, which can't follow
	 * pre-escaped variables across an assignment.
	 */
	private function render( array $cfg ): string {
		$site_name    = (string) get_bloginfo( 'name' );
		$heading_text = (string) ( $cfg['heading'] ?: "We'll be right back" );
		$message_text = (string) ( $cfg['message'] ?: '' );
		$eta_value    = ! empty( $cfg['eta'] ) ? (string) $cfg['eta'] : '';
		$eta_display  = $eta_value ? (string) gmdate( 'M j, Y H:i', strtotime( $eta_value ) ) : '';

		// This is a complete standalone HTML document served directly to
		// visitors while the site is in maintenance — it does NOT load the
		// theme/template or fire wp_head. We still go through the WordPress
		// enqueue API: register + enqueue the bundled stylesheet, then let
		// wp_print_styles() emit the tag (no hand-written <link> in the markup).
		wp_register_style( 'backreload-maintenance', plugins_url( 'assets/maintenance.css', BACKRELOAD_FILE ), [], BACKRELOAD_VERSION );
		wp_enqueue_style( 'backreload-maintenance' );
		ob_start();
		?><!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width,initial-scale=1">
	<title><?php echo esc_html( $heading_text ); ?> · <?php echo esc_html( $site_name ); ?></title>
	<?php wp_print_styles( 'backreload-maintenance' ); ?>
</head>
<body>
	<main class="card" role="alert" aria-live="polite">
		<div class="mark">
			<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor"
				stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
				<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
			</svg>
		</div>
		<p class="site"><?php echo esc_html( $site_name ); ?></p>
		<h1><span class="spinner" aria-hidden="true"></span><?php echo esc_html( $heading_text ); ?></h1>
		<p><?php echo esc_html( $message_text ); ?></p>
		<?php if ( $eta_value ) : ?>
			<p class="eta">Estimated back online:
				<time datetime="<?php echo esc_attr( $eta_value ); ?>"><?php echo esc_html( $eta_display ); ?> UTC</time>
			</p>
		<?php endif; ?>
		<p class="foot">Status 503</p>
	</main>
</body>
</html>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Resolve the client IP for the bypass allowlist check.
	 *
	 * SECURITY: forwarded headers (CF-Connecting-IP, X-Forwarded-For) are
	 * trivially spoofable when the request reaches the origin directly. If
	 * the site is NOT behind a trusted reverse proxy, an attacker can forge
	 * any of those headers in a direct request and bypass maintenance mode.
	 *
	 * We default to REMOTE_ADDR only. Sites behind Cloudflare / a reverse
	 * proxy can opt in via the `trust_forwarded_ip` setting in wp-options
	 * (or the `backreload_trust_forwarded_ip` filter) — then we honour
	 * CF-Connecting-IP and X-Forwarded-For.
	 */
	private function client_ip(): string {
		$trust = (bool) apply_filters(
			'backreload_trust_forwarded_ip',
			(bool) get_option( 'backreload_trust_forwarded_ip', false )
		);

		// When NOT trusting forwarded headers, REMOTE_ADDR is the only
		// signal we'll honor.
		if ( ! $trust ) {
			$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
			return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
		}

		// Trusted-proxy mode. Two headers in priority order:
		//
		//   1. CF-Connecting-IP — single value set by Cloudflare. Not
		//      user-attacker-spoofable because CF strips inbound copies
		//      before forwarding. Take it as-is when present.
		//   2. X-Forwarded-For — comma-separated chain `client, proxy1, proxy2`.
		//      The RIGHTMOST entry is the closest hop to OUR server (the
		//      last proxy). Walking right-to-left peels trusted proxies one
		//      at a time and stops at the first untrusted entry — that's
		//      the real client. The previous implementation took the
		//      LEFTMOST entry blindly, which is the entry an attacker can
		//      freely inject (anything earlier in the chain is just text
		//      the attacker chose to prepend).
		//
		// We don't ship a configurable trusted-proxy CIDR list — operators
		// who enable trust-proxy are declaring "the proxy in front of me
		// rewrites XFF, trust whatever it produces". Without a list, the
		// best we can do is take the LAST entry (closest hop) rather than
		// the FIRST (most-spoofable). This matches what Cloudflare's
		// documentation recommends for sites that aren't on CF.
		if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
			$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
			if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) return $ip;
		}
		if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
			$chain = array_map( 'trim', explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ) );
			// Walk right-to-left, return the first valid IP encountered.
			// On a single-proxy setup this is the original client; on a
			// multi-proxy setup it's the immediate upstream (the closest
			// hop to us). Either way, NOT the attacker-controlled head.
			for ( $i = count( $chain ) - 1; $i >= 0; $i-- ) {
				if ( filter_var( $chain[ $i ], FILTER_VALIDATE_IP ) ) {
					return $chain[ $i ];
				}
			}
		}
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
		return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
	}
}
