<?php
/**
 * BaseCloud Boost — Varnish / Reverse-Proxy Cache Integration
 *
 * Varnish (and most reverse-proxy caches) sit in front of WordPress and serve
 * their own copy of the page, so clearing BaseCloud Boost's file cache alone
 * would leave Varnish serving stale HTML. This class mirrors every BCB purge
 * event to Varnish over HTTP:
 *
 *   - Single URL changed  → HTTP PURGE  <url>            (VCL: return (purge))
 *   - Whole site changed  → HTTP BAN    <home> + regex   (VCL: ban by host)
 *
 * Varnish must be told to trust the WordPress server's IP and to honour PURGE /
 * BAN. A ready-to-paste VCL snippet is available via get_vcl_snippet() and is
 * shown in WP Admin → BaseCloud Boost → Advanced.
 *
 * @package BaseCloud\Boost\Compatibility
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Compatibility;

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

/**
 * Sends PURGE / BAN requests to Varnish on cache invalidation.
 */
class Varnish {

	/**
	 * Cookie that tells the reverse proxy (and Boost itself) to bypass the cache for
	 * this visitor — set for logged-in users and active carts, cleared otherwise. Add
	 * this name to your proxy's "excluded cookies" list (on Cloudways: Application →
	 * Varnish Settings → Excluded Cookies) so dynamic visitors always get a fresh page.
	 *
	 * @var string
	 */
	public const NOCACHE_COOKIE = 'bcboost-nocache';

	/**
	 * Whether the Cloudways-managed Varnish purge is auto-enabled for this request
	 * (Cloudways detected and the user hasn't explicitly configured Varnish).
	 *
	 * @var bool
	 */
	private bool $auto_cloudways = false;

	/**
	 * Register purge hooks when Varnish integration is active.
	 *
	 * Active when the user enabled it OR — turnkey — when we're on Cloudways, whose
	 * Varnish sits in front of Boost. Purging stale pages is always desirable, so on
	 * Cloudways we wire the purge automatically (no setup, survives reinstalls) so
	 * "Boost cache" actually clears Varnish and edits show immediately. Opt out with
	 * the `BCBOOST_auto_cloudways_varnish` filter.
	 *
	 * @return void
	 */
	public function boot(): void {
		$enabled = (bool) BCBOOST_get_option( 'varnish_enabled', false );

		$this->auto_cloudways = ! $enabled
			&& Host_Detector::is_cloudways()
			&& (bool) apply_filters( 'BCBOOST_auto_cloudways_varnish', true );

		if ( ! $enabled && ! $this->auto_cloudways ) {
			return;
		}

		add_action( 'BCBOOST_after_purge_url',  [ $this, 'purge_url' ] );
		add_action( 'BCBOOST_after_purge_all',  [ $this, 'purge_all' ] );
		add_action( 'BCBOOST_after_purge_post', [ $this, 'on_purge_post' ], 10, 2 );
		add_action( 'BCBOOST_after_purge_term', [ $this, 'on_purge_term' ], 10, 2 );

		// Maintain the reverse-proxy bypass cookie for dynamic visitors.
		add_action( 'template_redirect', [ $this, 'manage_bypass_cookie' ], 0 );
	}

	/**
	 * Whether the integration targets a Cloudways-style managed Varnish, which uses
	 * URLPURGE for a single URL and a PURGE to /?purgeall for the whole zone (you
	 * cannot edit its VCL), instead of the self-managed PURGE/BAN convention.
	 *
	 * When the user has explicitly enabled Varnish we honour their chosen mode;
	 * otherwise the auto-Cloudways path implies Cloudways mode.
	 *
	 * @return bool
	 */
	private function is_cloudways(): bool {
		if ( BCBOOST_get_option( 'varnish_enabled', false ) ) {
			return 'cloudways' === BCBOOST_get_option( 'varnish_mode', 'self' );
		}
		return $this->auto_cloudways;
	}

	/**
	 * HTTP method for purging a single URL (Cloudways: URLPURGE, else PURGE).
	 *
	 * @return string
	 */
	private function single_method(): string {
		return $this->is_cloudways() ? 'URLPURGE' : 'PURGE';
	}

	/**
	 * Purge a single URL from Varnish.
	 *
	 * @param string $url Full URL.
	 * @return void
	 */
	public function purge_url( string $url ): void {
		if ( '' === $url ) {
			return;
		}
		$this->send( $this->single_method(), $url );
	}

	/**
	 * Purge the entire site from Varnish.
	 *
	 * @return void
	 */
	public function purge_all(): void {
		// Cloudways' managed Varnish clears the whole zone via a PURGE to /?purgeall
		// (its VCL is fixed and does not honour BAN headers).
		if ( $this->is_cloudways() ) {
			$this->send( 'PURGE', home_url( '/?purgeall' ) );
			return;
		}

		$home = home_url( '/' );
		$host = (string) wp_parse_url( $home, PHP_URL_HOST );

		// A BAN lets Varnish invalidate everything for this host in one request.
		// The companion VCL bans on req.http.x-ban-host (falls back to a root PURGE
		// regex for VCLs that only implement PURGE).
		$method  = strtoupper( (string) BCBOOST_get_option( 'varnish_purge_method', 'ban' ) );
		$headers = [];

		if ( 'BAN' === $method ) {
			$headers['X-Ban-Host'] = $host;
			$headers['X-Ban-Url']  = '.*';
		} else {
			$method               = 'PURGE';
			$headers['X-Purge-Method'] = 'regex';
			$headers['X-Purge-Regex']  = '.*';
		}

		$this->send( $method, $home, $headers );
	}

	/**
	 * After a post purge — purge each affected URL.
	 *
	 * @param int      $post_id Post ID (unused; URLs already resolved).
	 * @param string[] $urls    URLs purged locally.
	 * @return void
	 */
	public function on_purge_post( int $post_id, array $urls ): void {
		$method = $this->single_method();
		foreach ( array_unique( array_filter( $urls ) ) as $url ) {
			$this->send( $method, (string) $url );
		}
	}

	/**
	 * After a taxonomy term purge — purge the term archive and the homepage.
	 *
	 * @param int    $term_id  Term ID.
	 * @param string $taxonomy Taxonomy slug.
	 * @return void
	 */
	public function on_purge_term( int $term_id, string $taxonomy ): void {
		$method    = $this->single_method();
		$term_link = get_term_link( $term_id, $taxonomy );
		if ( ! is_wp_error( $term_link ) ) {
			$this->send( $method, (string) $term_link );
		}
		$this->send( $method, home_url( '/' ) );
	}

	/**
	 * Set the bypass cookie for visitors who must skip the proxy cache (logged in or
	 * active cart), clear it once they are anonymous again. To stay cache-friendly the
	 * Set-Cookie header is only emitted on the transition — never on a normal anonymous
	 * request — so the proxy can still cache those.
	 *
	 * @return void
	 */
	public function manage_bypass_cookie(): void {
		if ( is_admin() || headers_sent()
			|| ( defined( 'DOING_AJAX' ) && DOING_AJAX )
			|| ( defined( 'DOING_CRON' ) && DOING_CRON )
			|| ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
			return;
		}

		$name   = self::NOCACHE_COOKIE;
		$bypass = is_user_logged_in() || $this->cart_active();
		$has    = isset( $_COOKIE[ $name ] );
		$path   = ( defined( 'COOKIEPATH' ) && COOKIEPATH ) ? COOKIEPATH : '/';
		$domain = defined( 'COOKIE_DOMAIN' ) ? (string) COOKIE_DOMAIN : '';

		if ( $bypass && ! $has ) {
			@setcookie( $name, '1', 0, $path, $domain, is_ssl(), false ); // phpcs:ignore
			$_COOKIE[ $name ] = '1';
		} elseif ( ! $bypass && $has ) {
			@setcookie( $name, '', time() - 3600, $path, $domain, is_ssl(), false ); // phpcs:ignore
			unset( $_COOKIE[ $name ] );
		}
	}

	/**
	 * Whether the current visitor has a non-empty WooCommerce cart.
	 *
	 * @return bool
	 */
	private function cart_active(): bool {
		if ( ! function_exists( 'WC' ) ) {
			return false;
		}
		$wc = WC();
		if ( ! $wc || ! isset( $wc->cart ) || ! $wc->cart ) {
			return false;
		}
		return ! $wc->cart->is_empty();
	}

	/**
	 * Whether the current request appears to be served through Varnish.
	 * Used for the admin status indicator only (not for purging).
	 *
	 * @return bool
	 */
	public static function is_detected(): bool {
		foreach ( [ 'HTTP_X_VARNISH', 'HTTP_X_VARNISH_CACHE' ] as $key ) {
			if ( ! empty( $_SERVER[ $key ] ) ) {
				return true;
			}
		}
		$via = strtolower( (string) ( $_SERVER['HTTP_VIA'] ?? '' ) );
		return false !== strpos( $via, 'varnish' );
	}

	// ─── Private ──────────────────────────────────────────────────────────────

	/**
	 * Send a purge request to every configured Varnish server.
	 *
	 * When specific servers are configured, the request is sent to each server's
	 * address while the original URL's host is preserved in the Host header, so
	 * Varnish matches the correct cached object even when it listens on an
	 * internal IP/port (e.g. 127.0.0.1:6081).
	 *
	 * @param string                $method  HTTP method (PURGE | BAN).
	 * @param string                $url     Target URL.
	 * @param array<string, string> $headers Extra request headers.
	 * @return void
	 */
	private function send( string $method, string $url, array $headers = [] ): void {
		$parts = wp_parse_url( $url );
		if ( empty( $parts['host'] ) ) {
			return;
		}
		$host = $parts['host'];
		$path = ( $parts['path'] ?? '/' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );

		$args = [
			'method'    => $method,
			'timeout'   => 5,
			'blocking'  => false, // Fire-and-forget; we don't block content edits on it.
			'sslverify' => false,
			'headers'   => array_merge( [ 'Host' => $host ], $headers ),
			'user-agent' => 'BaseCloudBoost/' . BCBOOST_VERSION . ' (Varnish purge)',
		];

		/**
		 * Filter the Varnish purge HTTP arguments (method, headers, timeout…).
		 *
		 * @param array  $args   wp_remote_request arguments.
		 * @param string $url    Target URL.
		 * @param string $method HTTP method.
		 */
		$args = apply_filters( 'BCBOOST_varnish_request_args', $args, $url, $method );

		foreach ( $this->servers() as $server ) {
			// $server is host[:port] of the Varnish front-end; default = the URL host.
			$target = $this->server_scheme( $server, $parts ) . '://' . $server . $path;
			wp_remote_request( $target, $args );
		}
	}

	/**
	 * Choose the scheme for a purge request to a given Varnish server. An internal
	 * front-end (loopback/private IP, or any host with an explicit port such as
	 * 127.0.0.1:8080 / :6081) speaks plain HTTP — sending HTTPS there fails. Only when
	 * the purge targets the site's own public hostname do we mirror the page's scheme.
	 *
	 * @param string               $server host[:port].
	 * @param array<string, mixed> $parts  Parsed target URL.
	 * @return string 'http' | 'https'
	 */
	private function server_scheme( string $server, array $parts ): string {
		$host     = (string) strtok( $server, ':' );
		$has_port = false !== strpos( $server, ':' );
		$is_local = in_array( $host, [ '127.0.0.1', 'localhost', '::1' ], true )
			|| (bool) preg_match( '#^(?:10\.|127\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)#', $host );

		if ( $has_port || $is_local ) {
			return 'http';
		}
		return ( isset( $parts['scheme'] ) && 'https' === $parts['scheme'] ) ? 'https' : 'http';
	}

	/**
	 * List of Varnish server addresses (host[:port]) to send purges to.
	 * Defaults to the site's own host when none are configured.
	 *
	 * @return string[]
	 */
	private function servers(): array {
		$raw     = (string) BCBOOST_get_option( 'varnish_servers', '' );
		$servers = array_filter( array_map( 'trim', explode( "\n", $raw ) ) );

		if ( empty( $servers ) ) {
			if ( $this->is_cloudways() ) {
				// Cloudways' Varnish front-end listens on localhost:8080.
				$servers = [ '127.0.0.1:8080' ];
			} else {
				$host = (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST );
				if ( '' !== $host ) {
					$servers = [ $host ];
				}
			}
		}

		/**
		 * Filter the list of Varnish servers to purge.
		 *
		 * @param string[] $servers Host[:port] addresses.
		 */
		return (array) apply_filters( 'BCBOOST_varnish_servers', array_values( $servers ) );
	}

	/**
	 * A ready-to-paste VCL snippet (Varnish 4+) that trusts the WordPress server
	 * and honours BaseCloud Boost's PURGE (single URL) and BAN (site-wide) requests.
	 *
	 * @return string
	 */
	public static function get_vcl_snippet(): string {
		$server_ip = isset( $_SERVER['SERVER_ADDR'] ) ? preg_replace( '/[^0-9a-fA-F:.]/', '', (string) $_SERVER['SERVER_ADDR'] ) : '127.0.0.1';

		return "# BaseCloud Boost — Varnish VCL (Varnish 4/6+)\n"
			. "# Add to your default.vcl, then reload Varnish.\n\n"
			. "acl bcboost_purge {\n"
			. "    \"localhost\";\n"
			. "    \"127.0.0.1\";\n"
			. "    \"" . $server_ip . "\";   # this WordPress server\n"
			. "}\n\n"
			. "sub vcl_recv {\n"
			. "    if (req.method == \"PURGE\") {\n"
			. "        if (!client.ip ~ bcboost_purge) { return (synth(405, \"Not allowed.\")); }\n"
			. "        return (purge);\n"
			. "    }\n"
			. "    if (req.method == \"BAN\") {\n"
			. "        if (!client.ip ~ bcboost_purge) { return (synth(405, \"Not allowed.\")); }\n"
			. "        ban(\"req.http.host == \" + req.http.X-Ban-Host + \" && req.url ~ \" + req.http.X-Ban-Url);\n"
			. "        return (synth(200, \"Banned.\"));\n"
			. "    }\n"
			. "}\n";
	}
}
