<?php

defined( 'ABSPATH' ) || exit;

/**
 * BW Cron module — fleet-wide WordPress cron management.
 *
 * Disables native WP-Cron only while a central trigger (the BW Cron Hub,
 * built separately on mosiah) is demonstrably alive. If the hub goes silent
 * for longer than the fallback threshold (default 60 min), native cron
 * automatically resumes — the dead-man's-switch property.
 *
 * Companion to `flywheel_auto_updates` and `flywheel_app_passwords` in the
 * Core group: all three are "make WordPress's built-in scheduler behave
 * correctly in modern hosting" plumbing.
 *
 * ┌──────────────────────── DEAD-MAN'S-SWITCH ────────────────────────┐
 * │                                                                    │
 * │ The module's register() runs at `plugins_loaded`, which fires      │
 * │ BEFORE WordPress's `init`. wp_cron()'s spawn check happens at      │
 * │ init priority 10. So defining DISABLE_WP_CRON inside register() is │
 * │ in time to short-circuit native cron.                              │
 * │                                                                    │
 * │ FAIL-OPEN by design: any failure mode (missing last_external_run,  │
 * │ stale timestamp, filter returning ≤0, module disabled on the       │
 * │ Modules tab, bw-dev plugin deactivated entirely) leaves the        │
 * │ constant undefined → native WP-Cron runs → site keeps working.     │
 * │                                                                    │
 * │ NO HUB EVER MAKES A SITE GO SILENT.                                │
 * └────────────────────────────────────────────────────────────────────┘
 *
 * Storage: nested under `bw_dev_settings[bw_cron]`:
 *   - secret              64 hex chars, generated on first register()
 *   - last_external_run   Unix timestamp of the most recent successful hub call
 *   - fallback_minutes    auto-revert window (5–1440, default 60)
 *
 * @package BW_Dev
 */
class BW_Dev_Module_BW_Cron implements BW_Dev_Module_Interface {

	const REST_NAMESPACE = 'bw/v1';
	const REST_ROUTE     = '/run-cron';

	const REGEN_ACTION   = 'bw_dev_bw_cron_regenerate';
	const REGEN_NONCE    = 'bw_dev_bw_cron_regenerate';

	const RUNNER_MAX_SECONDS = 30;

	public function slug(): string {
		return 'bw_cron';
	}

	public function label(): string {
		return __( 'BW Cron', 'bw-dev' );
	}

	public function group(): string {
		return 'core';
	}

	public function default_settings(): array {
		return array(
			'secret'            => '',
			'last_external_run' => 0,
			'fallback_minutes'  => 60,
		);
	}

	/**
	 * Sanitize — preserves the secret and last_external_run across saves
	 * (those are managed by lifecycle hooks + the REST endpoint, NOT by
	 * the admin form). Only fallback_minutes is admin-editable.
	 */
	public function sanitize( array $data ): array {
		$current = (array) bw_dev()->settings()->get( $this->slug(), null, array() );
		$current = wp_parse_args( $current, $this->default_settings() );

		$out = array(
			'secret'            => (string) $current['secret'],
			'last_external_run' => (int) $current['last_external_run'],
		);

		$raw = isset( $data['fallback_minutes'] ) ? (int) $data['fallback_minutes'] : 60;
		$out['fallback_minutes'] = max( 5, min( 1440, $raw ) );

		return $out;
	}

	public function register(): void {
		// 1. Dead-man's-switch — run IMMEDIATELY. We're at plugins_loaded,
		//    so this defines DISABLE_WP_CRON before WP's init/wp_cron() fires.
		$this->maybe_disable_native_cron();

		// 2. Ensure a secret exists. Lazy-generated on first register() so
		//    fresh installs don't need an explicit activation hook for this.
		$this->ensure_secret();

		// 3. REST endpoint registration (fires at rest_api_init, later).
		add_action( 'rest_api_init', array( $this, 'register_rest_route' ) );

		// 4. Regenerate-secret admin-post handler.
		add_action( 'admin_post_' . self::REGEN_ACTION, array( $this, 'handle_regenerate' ) );
	}

	public function uninstall(): void {
		// Root option drop (in uninstall.php) handles persisted state.
	}

	// -------------------------------------------------------------------
	// Dead-man's-switch
	// -------------------------------------------------------------------

	/**
	 * The safety-critical block. Only defines DISABLE_WP_CRON when the hub
	 * has triggered us within the fallback window. Fail-open in every
	 * other case.
	 */
	private function maybe_disable_native_cron(): void {
		if ( defined( 'DISABLE_WP_CRON' ) ) {
			return; // Respect any earlier definition (could be wp-config.php).
		}

		$settings = $this->get_settings();
		$last     = (int) ( $settings['last_external_run'] ?? 0 );
		if ( $last <= 0 ) {
			return; // Never triggered → native cron runs.
		}

		$threshold = (int) ( $settings['fallback_minutes'] ?? 60 );
		$threshold = (int) apply_filters( 'bw_cron_fallback_minutes', $threshold );
		if ( $threshold < 1 ) {
			$threshold = 60;
		}

		if ( ( time() - $last ) < ( $threshold * 60 ) ) {
			define( 'DISABLE_WP_CRON', true );
		}
		// Otherwise: stale trigger → fall through → native cron resumes.
	}

	// -------------------------------------------------------------------
	// Secret
	// -------------------------------------------------------------------

	/**
	 * Generate a 64-character hex secret if none exists. Idempotent.
	 */
	private function ensure_secret(): void {
		$settings = $this->get_settings();
		if ( '' !== (string) $settings['secret'] ) {
			return;
		}
		$this->write_setting( 'secret', $this->random_secret() );
	}

	private function random_secret(): string {
		try {
			return bin2hex( random_bytes( 32 ) );
		} catch ( Exception $e ) {
			return wp_generate_password( 64, false, false );
		}
	}

	private function matches_secret( string $candidate ): bool {
		$expected = (string) $this->get_settings()['secret'];
		if ( '' === $expected || '' === $candidate ) {
			return false;
		}
		return hash_equals( $expected, $candidate );
	}

	/**
	 * Force a brand-new secret. Triggered by the Regenerate button.
	 * The hub stops being able to authenticate until its registry
	 * entry for this site is updated with the new value — once the
	 * fallback window lapses, the site reverts to native cron. By design.
	 */
	private function regenerate_secret(): string {
		$new = $this->random_secret();
		$this->write_setting( 'secret', $new );
		return $new;
	}

	// -------------------------------------------------------------------
	// REST endpoint
	// -------------------------------------------------------------------

	public function register_rest_route(): void {
		register_rest_route(
			self::REST_NAMESPACE,
			self::REST_ROUTE,
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'handle_run_cron' ),
				'permission_callback' => array( $this, 'authorize_run_cron' ),
			)
		);
	}

	/**
	 * Permission callback. Constant-time secret compare. Empty / wrong /
	 * absent → 401 with no detail leak.
	 */
	public function authorize_run_cron( $request ) {
		$candidate = $this->extract_request_secret( $request );
		if ( '' === $candidate || ! $this->matches_secret( $candidate ) ) {
			return new WP_Error(
				'bw_cron_unauthorized',
				__( 'Unauthorized.', 'bw-dev' ),
				array( 'status' => 401 )
			);
		}
		return true;
	}

	private function extract_request_secret( $request ): string {
		// Preferred: X-BW-Cron-Secret header (WP REST normalizes header keys
		// to lowercase + underscores when calling get_header()).
		$header = $request->get_header( 'x_bw_cron_secret' );
		if ( ! empty( $header ) ) {
			return trim( (string) $header );
		}
		// Fallback: ?secret= for hosting front-ends that strip custom headers.
		$param = $request->get_param( 'secret' );
		if ( ! empty( $param ) ) {
			return trim( (string) $param );
		}
		return '';
	}

	/**
	 * Run all due cron events, return JSON. Always updates
	 * last_external_run — the hub's heartbeat is what matters for the
	 * dead-man's-switch, not whether any events actually fired.
	 */
	public function handle_run_cron( $request ) {
		unset( $request );
		$result = $this->run_due_cron();
		$this->write_setting( 'last_external_run', time() );
		return new WP_REST_Response( $result, 200 );
	}

	// -------------------------------------------------------------------
	// Cron runner — mirrors wp-cron.php
	// -------------------------------------------------------------------

	/**
	 * Runs due cron events directly. We can't call wp_cron() because that
	 * function checks DISABLE_WP_CRON (which our dead-man's-switch set) and
	 * short-circuits. Bypassing it from our authenticated trigger path is
	 * the entire point of this module.
	 */
	private function run_due_cron(): array {
		$start  = microtime( true );
		$ran    = array();
		$result = array(
			'ok'          => true,
			'ran'         => $ran,
			'duration_ms' => 0,
			'note'        => null,
		);

		// Mirror core's `doing_cron` transient lock so concurrent triggers
		// don't double-run events.
		$lock_age_max   = 10 * MINUTE_IN_SECONDS;
		$lock_transient = get_transient( 'doing_cron' );
		$gmt_time       = microtime( true );

		if ( $lock_transient && $lock_transient > ( $gmt_time - $lock_age_max ) ) {
			$result['note']        = 'doing_cron lock held; another cron run is in progress';
			$result['duration_ms'] = (int) round( ( microtime( true ) - $start ) * 1000 );
			return $result;
		}

		$our_lock = sprintf( '%.22F', $gmt_time );
		set_transient( 'doing_cron', $our_lock );

		try {
			$crons = $this->due_cron_events( $gmt_time );

			foreach ( $crons as $timestamp => $cronhooks ) {
				foreach ( $cronhooks as $hook => $keys ) {
					foreach ( $keys as $k => $v ) {
						$schedule = $v['schedule'];

						// Reschedule recurring events BEFORE firing so a
						// callback that throws doesn't lose the schedule.
						if ( $schedule ) {
							$rs = wp_reschedule_event( $timestamp, $schedule, $hook, $v['args'] );
							if ( is_wp_error( $rs ) ) {
								error_log( sprintf( '[BW Dev BW Cron] wp_reschedule_event failed for %s: %s', $hook, $rs->get_error_message() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
							}
						}

						wp_unschedule_event( $timestamp, $hook, $v['args'] );
						do_action_ref_array( $hook, $v['args'] );

						$ran[] = (string) $hook;

						if ( ( microtime( true ) - $start ) > self::RUNNER_MAX_SECONDS ) {
							$result['note'] = sprintf( 'time-cap reached at %ds; remaining due events run on next trigger', self::RUNNER_MAX_SECONDS );
							break 3;
						}
					}
				}
			}
		} finally {
			$current_lock = get_transient( 'doing_cron' );
			if ( $current_lock === $our_lock ) {
				delete_transient( 'doing_cron' );
			}
		}

		$result['ran']         = $ran;
		$result['duration_ms'] = (int) round( ( microtime( true ) - $start ) * 1000 );
		return $result;
	}

	private function due_cron_events( float $gmt_time ): array {
		$crons = _get_cron_array();
		if ( ! is_array( $crons ) ) {
			return array();
		}
		$due = array();
		foreach ( $crons as $timestamp => $cronhooks ) {
			if ( $timestamp > $gmt_time ) {
				break;
			}
			$due[ $timestamp ] = $cronhooks;
		}
		return $due;
	}

	// -------------------------------------------------------------------
	// Regenerate handler
	// -------------------------------------------------------------------

	public function handle_regenerate(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to do this.', 'bw-dev' ), '', array( 'response' => 403 ) );
		}
		check_admin_referer( self::REGEN_NONCE );
		$this->regenerate_secret();
		$back = wp_get_referer();
		wp_safe_redirect(
			add_query_arg(
				array( 'bw_cron_msg' => 'regenerated' ),
				$back ? $back : admin_url( 'options-general.php?page=bw-dev&tab=bw_cron' )
			)
		);
		exit;
	}

	// -------------------------------------------------------------------
	// Settings tab
	// -------------------------------------------------------------------

	public function render_tab(): void {
		$settings = $this->get_settings();
		$secret   = (string) $settings['secret'];
		$last     = (int) $settings['last_external_run'];
		$fallback = (int) $settings['fallback_minutes'];
		if ( $fallback < 1 ) {
			$fallback = 60;
		}

		$endpoint = esc_url_raw( rest_url( self::REST_NAMESPACE . self::REST_ROUTE ) );
		$now      = time();
		$age      = $last > 0 ? ( $now - $last ) : null;
		$within   = ( null !== $age && $age < ( $fallback * 60 ) );

		if ( 0 === $last ) {
			$mode_label = __( 'Never triggered — native WP-Cron is active', 'bw-dev' );
			$mode_class = 'notice-info';
			$mode_help  = __( 'The hub has never called this site. Until it does, WordPress\'s native piggyback cron runs normally. Drop the secret + endpoint into the hub registry to take over.', 'bw-dev' );
		} elseif ( $within ) {
			$mode_label = __( 'Hub-managed — native WP-Cron is disabled', 'bw-dev' );
			$mode_class = 'notice-success';
			$mode_help  = __( 'External triggers are arriving within the fallback window. The dead-man\'s-switch is armed: if triggers stop for longer than the threshold below, this site automatically reverts to native cron.', 'bw-dev' );
		} else {
			$mode_label = __( 'Native fallback — hub appears down, native WP-Cron is back on', 'bw-dev' );
			$mode_class = 'notice-warning';
			$mode_help  = __( 'The last external trigger arrived more than the threshold ago, so the dead-man\'s-switch reverted this site to native cron. Scheduled tasks are still running; the hub just isn\'t driving them right now.', 'bw-dev' );
		}

		$msg         = isset( $_GET['bw_cron_msg'] ) ? sanitize_key( wp_unslash( (string) $_GET['bw_cron_msg'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$pfx         = BW_Dev_Settings::OPTION . '[' . $this->slug() . ']';
		$masked      = '' !== $secret ? ( str_repeat( '•', 16 ) . substr( $secret, -8 ) ) : '';

		?>
		<p class="description">
			<?php esc_html_e( 'Fleet-wide WordPress cron management. Native WP-Cron piggybacks on visitor page loads, stealing time from real requests under load. This module disables native cron only while a central trigger (the BW Cron Hub on mosiah) is demonstrably alive — and auto-reverts to native cron if the hub goes silent for longer than the fallback threshold below.', 'bw-dev' ); ?>
		</p>

		<?php if ( 'regenerated' === $msg ) : ?>
			<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Secret regenerated. Update the hub registry with the new value before the next scheduled trigger, otherwise this site will revert to native cron once the fallback window lapses.', 'bw-dev' ); ?></p></div>
		<?php endif; ?>

		<?php if ( ! is_ssl() ) : ?>
			<div class="notice notice-warning">
				<p><strong><?php esc_html_e( 'This site is not served over HTTPS.', 'bw-dev' ); ?></strong>
				<?php esc_html_e( 'The secret travels in clear text on every hub trigger. Switch to HTTPS before going live.', 'bw-dev' ); ?></p>
			</div>
		<?php endif; ?>

		<div class="notice <?php echo esc_attr( $mode_class ); ?>" style="padding:14px 18px; margin:14px 0;">
			<p style="margin:0; font-weight:600; font-size:14px;"><?php echo esc_html( $mode_label ); ?></p>
			<p style="margin:6px 0 0;"><?php echo esc_html( $mode_help ); ?></p>
		</div>

		<h2><?php esc_html_e( 'Status', 'bw-dev' ); ?></h2>
		<table class="form-table" role="presentation">
			<tbody>
				<tr>
					<th scope="row"><?php esc_html_e( 'Last external trigger', 'bw-dev' ); ?></th>
					<td>
						<?php if ( $last > 0 ) : ?>
							<?php
							echo esc_html(
								sprintf(
									/* translators: 1: human-readable time difference 2: ISO datetime */
									__( '%1$s ago (%2$s)', 'bw-dev' ),
									human_time_diff( $last, $now ),
									wp_date( 'Y-m-d H:i:s', $last )
								)
							);
							?>
						<?php else : ?>
							<em><?php esc_html_e( 'never', 'bw-dev' ); ?></em>
						<?php endif; ?>
					</td>
				</tr>
				<tr>
					<th scope="row"><?php esc_html_e( 'Endpoint URL', 'bw-dev' ); ?></th>
					<td>
						<code style="word-break:break-all;"><?php echo esc_html( $endpoint ); ?></code>
						<p class="description"><?php esc_html_e( 'POST to this URL with the secret in the X-BW-Cron-Secret header.', 'bw-dev' ); ?></p>
					</td>
				</tr>
			</tbody>
		</table>

		<h2><?php esc_html_e( 'Per-site secret', 'bw-dev' ); ?></h2>
		<table class="form-table" role="presentation">
			<tbody>
				<tr>
					<th scope="row"><?php esc_html_e( 'Secret', 'bw-dev' ); ?></th>
					<td>
						<?php if ( '' !== $secret ) : ?>
							<code id="bw-dev-bw-cron-secret" style="word-break:break-all; user-select:all;"><?php echo esc_html( $masked ); ?></code>
							<button type="button" class="button" id="bw-dev-bw-cron-secret-reveal" data-revealed-label="<?php esc_attr_e( 'Hide', 'bw-dev' ); ?>" data-hidden-label="<?php esc_attr_e( 'Reveal', 'bw-dev' ); ?>"><?php esc_html_e( 'Reveal', 'bw-dev' ); ?></button>
							<script>
							(function () {
								var revealed = false;
								var full = <?php echo wp_json_encode( $secret ); ?>;
								var mask = <?php echo wp_json_encode( $masked ); ?>;
								var c = document.getElementById('bw-dev-bw-cron-secret');
								var b = document.getElementById('bw-dev-bw-cron-secret-reveal');
								b.addEventListener('click', function () {
									revealed = !revealed;
									c.textContent = revealed ? full : mask;
									b.textContent = revealed ? b.dataset.revealedLabel : b.dataset.hiddenLabel;
								});
							})();
							</script>

							<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block; margin-left:14px;" onsubmit="return confirm(<?php echo esc_attr( wp_json_encode( __( 'Regenerate the secret? The hub will stop authenticating until you update its registry with the new value — once the fallback threshold lapses, this site reverts to native cron.', 'bw-dev' ) ) ); ?>);">
								<?php wp_nonce_field( self::REGEN_NONCE ); ?>
								<input type="hidden" name="action" value="<?php echo esc_attr( self::REGEN_ACTION ); ?>" />
								<button type="submit" class="button"><?php esc_html_e( 'Regenerate', 'bw-dev' ); ?></button>
							</form>
						<?php else : ?>
							<em><?php esc_html_e( 'Generating on first load — refresh this page.', 'bw-dev' ); ?></em>
						<?php endif; ?>
						<p class="description"><?php esc_html_e( 'Sent in the X-BW-Cron-Secret header by the hub. Regenerating breaks the hub\'s registry entry until you copy the new value over.', 'bw-dev' ); ?></p>
					</td>
				</tr>
			</tbody>
		</table>

		<h2><?php esc_html_e( 'Fallback threshold', 'bw-dev' ); ?></h2>
		<table class="form-table" role="presentation">
			<tbody>
				<tr>
					<th scope="row"><label for="bw-dev-bw-cron-fb"><?php esc_html_e( 'Revert to native cron after', 'bw-dev' ); ?></label></th>
					<td>
						<input type="number" id="bw-dev-bw-cron-fb" name="<?php echo esc_attr( $pfx . '[fallback_minutes]' ); ?>" min="5" max="1440" value="<?php echo esc_attr( (string) $fallback ); ?>" class="small-text" />
						<?php esc_html_e( 'minutes without an external trigger.', 'bw-dev' ); ?>
						<p class="description"><?php esc_html_e( 'Range 5–1440 (24 h). The default of 60 minutes balances "wedged hub doesn\'t leave the site idle for long" against "brief hub blips don\'t cause cron-mode thrashing." Programmatically overridable via the bw_cron_fallback_minutes filter.', 'bw-dev' ); ?></p>
					</td>
				</tr>
			</tbody>
		</table>
		<?php
	}

	// -------------------------------------------------------------------
	// Settings cache helpers
	// -------------------------------------------------------------------

	/**
	 * Returns the bw_cron sub-array merged onto defaults.
	 */
	private function get_settings(): array {
		$saved = (array) bw_dev()->settings()->get( $this->slug(), null, array() );
		return wp_parse_args( $saved, $this->default_settings() );
	}

	/**
	 * Atomically write one field. Bypasses the cached settings layer
	 * (matches the pattern in image_optimizer's record_stats — protects
	 * against stale-snapshot clobbering during the same request) and
	 * syncs the cache after.
	 */
	private function write_setting( string $key, $value ): void {
		$option   = get_option( BW_Dev_Settings::OPTION, array() );
		if ( ! is_array( $option ) ) {
			$option = array();
		}
		$section = isset( $option[ $this->slug() ] ) && is_array( $option[ $this->slug() ] )
			? $option[ $this->slug() ]
			: array();
		$section            = wp_parse_args( $section, $this->default_settings() );
		$section[ $key ]    = $value;
		$option[ $this->slug() ] = $section;
		update_option( BW_Dev_Settings::OPTION, $option );

		bw_dev()->settings()->update_section( $this->slug(), $section );
	}
}
