<?php
/**
 * Cache module — Cloudflare integration
 *
 * Lightweight Cloudflare API client. Covers what 90% of WordPress sites
 * actually need from Cloudflare:
 *
 *   - Targeted per-URL purge on post save (same impact map as the local
 *     page-cache purge, so both layers stay aligned).
 *   - Full-zone purge on theme switch / manual "Purge all".
 *   - Development mode toggle (3-hour auto-expire on Cloudflare's side).
 *   - One-click "Apply recommended Cloudflare settings for WordPress",
 *     with a stored snapshot so the previous values can be restored.
 *   - Token-capability detection so the UI greys out features the
 *     current API token cannot perform.
 *
 * APO, WAF rules, Analytics dashboards: out of scope — those belong in
 * Cloudflare's official plugin, which can run alongside this one as long
 * as only one of the two does the auto-purge (the CDN tab warns about it).
 *
 * The API token is encrypted at rest via blaminhor_essentials_encrypt()
 * so it never leaks in plain text in the options table or in a database
 * export.
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Cloudflare {

	const API_BASE           = 'https://api.cloudflare.com/client/v4';
	const PURGE_BATCH_SIZE   = 30;
	const SNAPSHOT_OPTION    = 'blaminhor_cache_cf_settings_snapshot';
	const CAPS_TRANSIENT     = 'blaminhor_cache_cf_token_caps';
	const CAPS_TRANSIENT_TTL = HOUR_IN_SECONDS;

	/**
	 * The settings PATCHed by apply_recommended_settings().
	 *
	 * Picked to match what the Cloudflare team itself recommends for
	 * WordPress (https://developers.cloudflare.com/cache/best-practices/),
	 * minus the toggles that fight Blaminhor Essentials's own optimisations:
	 *   - rocket_loader stays OFF (notoriously breaks jQuery + builders).
	 *   - minify_{css,js,html} stay OFF; the plugin's own minifier already
	 *     handles them, and Cloudflare's minifier is being phased out.
	 *
	 * @return array<string, mixed>
	 */
	public static function recommended_settings() {
		return array(
			'browser_cache_ttl'   => 14400,   // 4 hours.
			'always_online'       => 'on',
			'brotli'              => 'on',
			'security_level'      => 'medium',
			'cache_level'         => 'aggressive',
			'rocket_loader'       => 'off',
			'minify'              => array(
				'css'  => 'off',
				'html' => 'off',
				'js'   => 'off',
			),
			'email_obfuscation'   => 'on',
			'server_side_exclude' => 'on',
		);
	}

	/** @var Blaminhor_Essentials_Module_Cache */
	private $parent;

	public function __construct( Blaminhor_Essentials_Module_Cache $parent ) {
		$this->parent = $parent;
	}

	public function register_hooks() {
		if ( ! $this->is_configured() ) {
			return;
		}
		add_action( 'save_post', array( $this, 'on_post_change' ), 99, 1 );
		add_action( 'wp_trash_post', array( $this, 'on_post_change' ), 99, 1 );
		add_action( 'switch_theme', array( $this, 'purge_everything' ) );
		add_action( 'blaminhor_essentials_cache_purge_all', array( $this, 'purge_everything' ) );
	}

	public function is_configured() {
		return $this->parent->get_setting( 'cloudflare_enabled', false )
			&& '' !== $this->get_token()
			&& '' !== (string) $this->parent->get_setting( 'cloudflare_zone_id', '' );
	}

	/**
	 * Return the decrypted token from settings (encrypted at rest).
	 *
	 * @return string
	 */
	private function get_token() {
		$raw = (string) $this->parent->get_setting( 'cloudflare_api_token', '' );
		if ( '' === $raw ) {
			return '';
		}
		if ( function_exists( 'blaminhor_essentials_decrypt' ) ) {
			$dec = blaminhor_essentials_decrypt( $raw );
			if ( is_string( $dec ) && '' !== $dec ) {
				return $dec;
			}
		}
		return $raw; // fallback when encryption helper is unavailable
	}

	/**
	 * Skip auto-revisions / auto-saves so we don't burn Cloudflare API
	 * quota on noise events. Then purge ONLY the URLs that this post
	 * actually invalidates (permalink + home + archives + cat/tag pages),
	 * reusing the same impact-map as the local page cache.
	 *
	 * @param int $post_id
	 */
	public function on_post_change( $post_id ) {
		$post_id = (int) $post_id;
		if ( $post_id <= 0 ) {
			return;
		}
		if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
			return;
		}
		$status = get_post_status( $post_id );
		if ( in_array( $status, array( 'auto-draft', 'inherit' ), true ) ) {
			return;
		}
		$post = get_post( $post_id );
		if ( ! $post ) {
			return;
		}
		if ( in_array( $post->post_type, array( 'revision', 'auto-draft', 'nav_menu_item', 'customize_changeset' ), true ) ) {
			return;
		}

		$urls = array();
		if ( isset( $this->parent->purge ) && method_exists( $this->parent->purge, 'collect_urls_for_post' ) ) {
			$urls = (array) $this->parent->purge->collect_urls_for_post( $post );
		}
		if ( empty( $urls ) ) {
			// Couldn't compute an impact map — fall back to the conservative
			// full-zone flush so visitors never see stale HTML, at the cost
			// of a heavier cache miss spike.
			$this->purge_everything();
			return;
		}

		$this->purge_urls( $urls );
	}

	/**
	 * Purge a specific list of URLs from the Cloudflare edge cache.
	 *
	 * Cloudflare caps each call at 30 URLs — we chunk transparently so the
	 * caller can hand us any number of URLs.
	 *
	 * @param string[] $urls
	 * @return true|WP_Error
	 */
	public function purge_urls( array $urls ) {
		$urls = array_values( array_unique( array_filter( array_map( 'strval', $urls ) ) ) );
		if ( empty( $urls ) ) {
			return true;
		}

		$zone_id = (string) $this->parent->get_setting( 'cloudflare_zone_id', '' );
		$token   = $this->get_token();
		if ( '' === $zone_id || '' === $token ) {
			return new WP_Error( 'cf_not_configured', __( 'Cloudflare is not configured.', 'blaminhor-essentials' ) );
		}

		$endpoint = self::API_BASE . '/zones/' . rawurlencode( $zone_id ) . '/purge_cache';
		$chunks   = array_chunk( $urls, self::PURGE_BATCH_SIZE );
		$last_err = null;

		foreach ( $chunks as $chunk ) {
			$response = wp_remote_post(
				$endpoint,
				array(
					'timeout' => 10,
					'headers' => array(
						'Authorization' => 'Bearer ' . $token,
						'Content-Type'  => 'application/json',
					),
					'body'    => wp_json_encode( array( 'files' => array_values( $chunk ) ) ),
				)
			);
			if ( is_wp_error( $response ) ) {
				$last_err = $response;
				$this->log( 'Cloudflare targeted purge HTTP error: ' . $response->get_error_message(), 'warning' );
				continue;
			}
			$code = (int) wp_remote_retrieve_response_code( $response );
			$body = (string) wp_remote_retrieve_body( $response );
			if ( 200 !== $code ) {
				$last_err = new WP_Error( 'cf_http_' . $code, $body );
				$this->log( 'Cloudflare targeted purge rejected (HTTP ' . $code . '): ' . substr( $body, 0, 240 ), 'warning' );
			}
		}

		if ( $last_err instanceof WP_Error ) {
			return $last_err;
		}
		$this->log( 'Cloudflare targeted purge OK (' . count( $urls ) . ' URLs across ' . count( $chunks ) . ' batch(es)).', 'info' );
		return true;
	}

	/**
	 * @return true|WP_Error
	 */
	public function purge_everything() {
		$zone_id = (string) $this->parent->get_setting( 'cloudflare_zone_id', '' );
		$token   = $this->get_token();
		if ( '' === $zone_id || '' === $token ) {
			return new WP_Error( 'cf_not_configured', __( 'Cloudflare is not configured.', 'blaminhor-essentials' ) );
		}

		$response = wp_remote_post(
			self::API_BASE . '/zones/' . rawurlencode( $zone_id ) . '/purge_cache',
			array(
				'timeout' => 10,
				'headers' => array(
					'Authorization' => 'Bearer ' . $token,
					'Content-Type'  => 'application/json',
				),
				'body'    => wp_json_encode( array( 'purge_everything' => true ) ),
			)
		);
		if ( is_wp_error( $response ) ) {
			$this->log( 'Cloudflare purge HTTP error: ' . $response->get_error_message(), 'warning' );
			return $response;
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = (string) wp_remote_retrieve_body( $response );
		if ( 200 !== $code ) {
			$this->log( 'Cloudflare purge rejected (HTTP ' . $code . '): ' . substr( $body, 0, 240 ), 'warning' );
			return new WP_Error( 'cf_http_' . $code, $body );
		}
		$this->log( 'Cloudflare zone purged (purge_everything).', 'info' );
		return true;
	}

	/* ---------------------------------------------------------------- */
	/* Development mode                                                  */
	/* ---------------------------------------------------------------- */

	/**
	 * Read the current development-mode state from Cloudflare.
	 *
	 * @return array{value: 'on'|'off', time_remaining: int}|WP_Error
	 */
	public function get_development_mode() {
		$response = $this->api_request( 'GET', '/settings/development_mode' );
		if ( is_wp_error( $response ) ) {
			return $response;
		}
		$result = isset( $response['result'] ) ? (array) $response['result'] : array();
		return array(
			'value'          => isset( $result['value'] ) && 'on' === $result['value'] ? 'on' : 'off',
			'time_remaining' => isset( $result['time_remaining'] ) ? (int) $result['time_remaining'] : 0,
		);
	}

	/**
	 * Turn development mode on or off. Cloudflare auto-expires "on" after
	 * 3 hours, no client-side timer needed.
	 *
	 * @param bool $on
	 * @return true|WP_Error
	 */
	public function set_development_mode( $on ) {
		$response = $this->api_request(
			'PATCH',
			'/settings/development_mode',
			array( 'value' => $on ? 'on' : 'off' )
		);
		if ( is_wp_error( $response ) ) {
			return $response;
		}
		$this->log( 'Cloudflare development_mode set to ' . ( $on ? 'on' : 'off' ) . '.', 'info' );
		return true;
	}

	/* ---------------------------------------------------------------- */
	/* Recommended settings                                              */
	/* ---------------------------------------------------------------- */

	/**
	 * Snapshot the current values of every setting that
	 * apply_recommended_settings() is about to overwrite, then PATCH
	 * each one to the recommended value.
	 *
	 * Returns a structured summary so the UI can show "X of Y settings
	 * applied, 1 skipped (token permission missing)".
	 *
	 * @return array{applied: array<int, string>, skipped: array<int, array{key: string, reason: string}>, snapshot_saved: bool}|WP_Error
	 */
	public function apply_recommended_settings() {
		$recommended = self::recommended_settings();

		$snapshot = array();
		foreach ( $recommended as $key => $_value ) {
			$current = $this->api_request( 'GET', '/settings/' . rawurlencode( $key ) );
			if ( is_wp_error( $current ) ) {
				$snapshot[ $key ] = array( 'error' => $current->get_error_code() );
				continue;
			}
			$snapshot[ $key ] = isset( $current['result']['value'] ) ? $current['result']['value'] : null;
		}
		update_option(
			self::SNAPSHOT_OPTION,
			array(
				'taken_at' => time(),
				'zone_id'  => (string) $this->parent->get_setting( 'cloudflare_zone_id', '' ),
				'values'   => $snapshot,
			),
			false
		);

		$applied = array();
		$skipped = array();
		foreach ( $recommended as $key => $value ) {
			$response = $this->api_request(
				'PATCH',
				'/settings/' . rawurlencode( $key ),
				array( 'value' => $value )
			);
			if ( is_wp_error( $response ) ) {
				$skipped[] = array(
					'key'    => $key,
					'reason' => $response->get_error_code(),
				);
				continue;
			}
			$applied[] = $key;
		}

		$this->log(
			sprintf(
				'Cloudflare apply_recommended_settings: %d applied, %d skipped.',
				count( $applied ),
				count( $skipped )
			),
			'info'
		);

		return array(
			'applied'        => $applied,
			'skipped'        => $skipped,
			'snapshot_saved' => true,
		);
	}

	/**
	 * Restore the snapshot taken by apply_recommended_settings().
	 *
	 * @return array{restored: array<int, string>, skipped: array<int, array{key: string, reason: string}>}|WP_Error
	 */
	public function reset_settings() {
		$snapshot = get_option( self::SNAPSHOT_OPTION, null );
		if ( ! is_array( $snapshot ) || empty( $snapshot['values'] ) ) {
			return new WP_Error(
				'cf_no_snapshot',
				__( 'No previous Cloudflare settings snapshot to restore from.', 'blaminhor-essentials' )
			);
		}

		$restored = array();
		$skipped  = array();
		foreach ( $snapshot['values'] as $key => $value ) {
			if ( null === $value || ( is_array( $value ) === false && isset( $value['error'] ) ) ) {
				$skipped[] = array(
					'key'    => $key,
					'reason' => 'no_snapshot_value',
				);
				continue;
			}
			$response = $this->api_request(
				'PATCH',
				'/settings/' . rawurlencode( $key ),
				array( 'value' => $value )
			);
			if ( is_wp_error( $response ) ) {
				$skipped[] = array(
					'key'    => $key,
					'reason' => $response->get_error_code(),
				);
				continue;
			}
			$restored[] = $key;
		}

		delete_option( self::SNAPSHOT_OPTION );
		$this->log(
			sprintf(
				'Cloudflare reset_settings: %d restored, %d skipped.',
				count( $restored ),
				count( $skipped )
			),
			'info'
		);

		return array(
			'restored' => $restored,
			'skipped'  => $skipped,
		);
	}

	/**
	 * Whether a snapshot exists (used to decide if the Reset button is shown).
	 *
	 * @return bool
	 */
	public function has_snapshot() {
		$snapshot = get_option( self::SNAPSHOT_OPTION, null );
		return is_array( $snapshot ) && ! empty( $snapshot['values'] );
	}

	/* ---------------------------------------------------------------- */
	/* Token capability detection                                        */
	/* ---------------------------------------------------------------- */

	/**
	 * Detect what the configured token can actually do, so the UI can
	 * grey out features the token lacks permission for. Cached for
	 * one hour to avoid hammering the verify endpoint on every page load.
	 *
	 * @param bool $force_refresh
	 * @return array{token_valid: bool, can_purge: bool, can_read_settings: bool, can_write_settings: bool, error: string}
	 */
	public function detect_capabilities( $force_refresh = false ) {
		if ( ! $force_refresh ) {
			$cached = get_transient( self::CAPS_TRANSIENT );
			if ( is_array( $cached ) ) {
				return $cached;
			}
		}

		$caps = array(
			'token_valid'        => false,
			'can_purge'          => false,
			'can_read_settings'  => false,
			'can_write_settings' => false,
			'error'              => '',
		);

		// 1. Token validity.
		$verify = $this->verify_token();
		if ( is_wp_error( $verify ) ) {
			$caps['error'] = $verify->get_error_code();
			set_transient( self::CAPS_TRANSIENT, $caps, self::CAPS_TRANSIENT_TTL );
			return $caps;
		}
		$caps['token_valid'] = true;

		// 2. Can we read settings on this zone? (also implicitly: zone access)
		$read                      = $this->api_request( 'GET', '/settings/browser_cache_ttl' );
		$caps['can_read_settings'] = ! is_wp_error( $read );

		// 3. Can we write settings? Probe by reading then writing the same
		// value back — a no-op edit if the permission is there, a 403 if not.
		if ( $caps['can_read_settings'] && isset( $read['result']['value'] ) ) {
			$probe_value                = $read['result']['value'];
			$write                      = $this->api_request(
				'PATCH',
				'/settings/browser_cache_ttl',
				array( 'value' => $probe_value )
			);
			$caps['can_write_settings'] = ! is_wp_error( $write );
		}

		// 4. Can we purge? Probe with a fake URL — Cloudflare returns 200
		// (treating the call as a no-op since the URL is not cached) when
		// the permission is there, or 403 when the Cache Purge scope is missing.
		$purge_probe = $this->api_request(
			'POST',
			'/purge_cache',
			array( 'files' => array( home_url( '/__be_cf_capability_probe__' ) ) )
		);
		if ( is_wp_error( $purge_probe ) ) {
			$caps['can_purge'] = false !== strpos( $purge_probe->get_error_code(), 'cf_http_4' )
				&& false === strpos( $purge_probe->get_error_code(), 'cf_http_403' );
		} else {
			$caps['can_purge'] = true;
		}

		set_transient( self::CAPS_TRANSIENT, $caps, self::CAPS_TRANSIENT_TTL );
		return $caps;
	}

	/**
	 * One-shot connectivity test for the admin UI: hits /user/tokens/verify.
	 *
	 * @return true|WP_Error
	 */
	public function verify_token() {
		$token = $this->get_token();
		if ( '' === $token ) {
			return new WP_Error( 'cf_no_token', __( 'No Cloudflare API token set.', 'blaminhor-essentials' ) );
		}
		$response = wp_remote_get(
			self::API_BASE . '/user/tokens/verify',
			array(
				'timeout' => 8,
				'headers' => array( 'Authorization' => 'Bearer ' . $token ),
			)
		);
		if ( is_wp_error( $response ) ) {
			return $response;
		}
		if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			return new WP_Error( 'cf_invalid_token', (string) wp_remote_retrieve_body( $response ) );
		}
		return true;
	}

	/* ---------------------------------------------------------------- */
	/* HTTP plumbing                                                     */
	/* ---------------------------------------------------------------- */

	/**
	 * Generic /zones/{zone}/{path} request. Returns the parsed JSON
	 * "result" array on success or a WP_Error on failure.
	 *
	 * @param string     $method  GET | POST | PATCH
	 * @param string     $path    Path *after* /zones/{zone}, leading slash required.
	 * @param array|null $body    Optional body for POST/PATCH.
	 * @return array|WP_Error
	 */
	private function api_request( $method, $path, $body = null ) {
		$zone_id = (string) $this->parent->get_setting( 'cloudflare_zone_id', '' );
		$token   = $this->get_token();
		if ( '' === $zone_id || '' === $token ) {
			return new WP_Error( 'cf_not_configured', __( 'Cloudflare is not configured.', 'blaminhor-essentials' ) );
		}

		$args = array(
			'method'  => $method,
			'timeout' => 10,
			'headers' => array(
				'Authorization' => 'Bearer ' . $token,
				'Content-Type'  => 'application/json',
			),
		);
		if ( null !== $body ) {
			$args['body'] = wp_json_encode( $body );
		}

		$response = wp_remote_request(
			self::API_BASE . '/zones/' . rawurlencode( $zone_id ) . $path,
			$args
		);
		if ( is_wp_error( $response ) ) {
			return $response;
		}
		$code     = (int) wp_remote_retrieve_response_code( $response );
		$raw_body = (string) wp_remote_retrieve_body( $response );
		if ( $code < 200 || $code >= 300 ) {
			return new WP_Error( 'cf_http_' . $code, $raw_body );
		}
		$decoded = json_decode( $raw_body, true );
		return is_array( $decoded ) ? $decoded : array();
	}

	/**
	 * @param string $message
	 * @param string $level
	 */
	private function log( $message, $level = 'info' ) {
		if ( function_exists( 'blaminhor_essentials_log' ) ) {
			blaminhor_essentials_log( $message, $level );
		}
	}
}
