<?php
/**
 * BaseCloud Boost — Page Cache Manager
 *
 * Manages reading, writing, and deleting page cache files.
 * Writes three compression variants per cached page:
 *   - index.html      (plain)
 *   - index.html.gz   (GZIP)
 *   - index.html.br   (Brotli)
 *
 * @package BaseCloud\Boost\Core
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Core;

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

/**
 * Manages cache file reading, writing, and deletion.
 */
class Page_Cache {

	/**
	 * Get aggregate cache statistics.
	 *
	 * @return array{count: int, size: int, oldest: int|null, newest: int|null}
	 */
	public function get_cache_stats(): array {
		$stats = [ 'count' => 0, 'size' => 0, 'oldest' => null, 'newest' => null ];

		if ( ! is_dir( BCBOOST_CACHE_DIR ) ) {
			return $stats;
		}

		$iterator = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( BCBOOST_CACHE_DIR, \FilesystemIterator::SKIP_DOTS )
		);

		foreach ( $iterator as $file ) {
			/** @var \SplFileInfo $file */
			// Count every cached page variant (desktop/mobile/country/cookie).
			if ( ! preg_match( '/^index[\w-]*\.html$/', $file->getFilename() ) ) {
				continue;
			}

			$mtime = $file->getMTime();
			$stats['count']++;
			$stats['size'] += $file->getSize();

			if ( $stats['oldest'] === null || $mtime < $stats['oldest'] ) {
				$stats['oldest'] = $mtime;
			}
			if ( $stats['newest'] === null || $mtime > $stats['newest'] ) {
				$stats['newest'] = $mtime;
			}
		}

		return $stats;
	}

	/**
	 * Get a list of all currently cached URLs.
	 *
	 * @return string[]
	 */
	public function get_cached_urls(): array {
		if ( ! is_dir( BCBOOST_CACHE_DIR ) ) {
			return [];
		}

		$urls     = [];
		$base_len = strlen( BCBOOST_CACHE_DIR );

		$iterator = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( BCBOOST_CACHE_DIR, \FilesystemIterator::SKIP_DOTS )
		);

		foreach ( $iterator as $file ) {
			/** @var \SplFileInfo $file */
			if ( $file->getFilename() !== 'index.html' ) {
				continue;
			}

			$relative = substr( $file->getPath(), $base_len );
			$parts    = explode( '/', ltrim( $relative, '/' ), 2 );

			if ( count( $parts ) === 2 ) {
				$urls[] = $parts[0] . '://' . $parts[1];
			}
		}

		return $urls;
	}

	/**
	 * Check whether a URL is cached and not yet expired.
	 *
	 * @param string $url Full URL.
	 * @return bool
	 */
	public function is_cached( string $url ): bool {
		$file     = $this->url_to_cache_file( $url );
		$lifetime = (int) BCBOOST_get_option( 'cache_lifetime', 86400 );
		return file_exists( $file ) && ( time() - filemtime( $file ) ) < $lifetime;
	}

	/**
	 * Get the age (seconds) of a cached URL.
	 *
	 * @param string $url Full URL.
	 * @return int|null Age in seconds, or null if not cached.
	 */
	public function get_cache_age( string $url ): ?int {
		$file = $this->url_to_cache_file( $url );
		return file_exists( $file ) ? time() - (int) filemtime( $file ) : null;
	}

	/**
	 * Write HTML to the cache, creating plain, GZIP, and Brotli variants.
	 *
	 * @param string $url  Full URL.
	 * @param string $html HTML content.
	 * @param string $stem Cache filename stem (e.g. "index", "index-mobile",
	 *                     "index--<hash>"). Defaults to the plain "index" variant.
	 * @return bool True if the plain file was written.
	 */
	public function write_to_cache( string $url, string $html, string $stem = 'index' ): bool {
		$file = $this->url_to_cache_file( $url, $stem );
		$dir  = dirname( $file );

		if ( ! wp_mkdir_p( $dir ) ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( sprintf( '[BaseCloud Boost] wp_mkdir_p failed for cache dir: %s — likely a file ownership mismatch (CloudWays: delete the folder via SFTP and let PHP recreate it)', $dir ) );
			}
			return false;
		}

		return $this->write_all_variants( $html, $file );
	}

	/**
	 * Delete every cached variant for a given URL.
	 *
	 * A single URL can have many cached files: desktop/mobile, per-country and
	 * per-cookie variants, each in plain / .gz / .br form. They all live in the
	 * URL's own directory as index*.html[.gz|.br], so we glob and remove the lot
	 * rather than guessing fixed filenames (which silently missed variants before).
	 *
	 * @param string $url Full URL.
	 * @return bool True if all matched files were removed.
	 */
	public function delete_cache_file( string $url ): bool {
		$dir = dirname( $this->url_to_cache_file( $url ) );

		$files = glob( $dir . '/index*.html' ) ?: [];
		$files = array_merge(
			$files,
			glob( $dir . '/index*.html.gz' ) ?: [],
			glob( $dir . '/index*.html.br' ) ?: []
		);

		$deleted = true;
		foreach ( $files as $file ) {
			if ( is_file( $file ) && ! unlink( $file ) ) {
				$deleted = false;
			}
		}

		return $deleted;
	}

	/**
	 * Delete ALL cached pages.
	 *
	 * Preserves `bcboost-options.json` — the settings snapshot advanced-cache.php
	 * reads on every request. Wiping it on purge silently disabled cache serving
	 * (and any drop-in behaviour keyed off options) until the next settings save
	 * rewrote it. The `index.php` directory-listing guard is kept for the same
	 * reason: Activator only recreates it on activation, not on purge.
	 *
	 * @return bool
	 */
	public function delete_all_cache(): bool {
		return $this->rmdir_recursive(
			BCBOOST_CACHE_DIR,
			true,
			[ 'bcboost-options.json', 'index.php' ]
		);
	}

	/**
	 * Walk the cache directory and delete files older than the configured lifetime.
	 *
	 * @return int Number of file groups (pages) deleted.
	 */
	public function delete_expired_cache(): int {
		if ( ! is_dir( BCBOOST_CACHE_DIR ) ) {
			return 0;
		}

		$lifetime = (int) BCBOOST_get_option( 'cache_lifetime', 86400 );
		$deleted  = 0;
		$now      = time();

		$iterator = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( BCBOOST_CACHE_DIR, \FilesystemIterator::SKIP_DOTS ),
			\RecursiveIteratorIterator::CHILD_FIRST
		);

		foreach ( $iterator as $item ) {
			/** @var \SplFileInfo $item */
			// Match every page variant: index.html, index-mobile.html, index--<hash>.html …
			if ( ! $item->isFile() || ! preg_match( '/^index[\w-]*\.html$/', $item->getFilename() ) ) {
				continue;
			}
			if ( ( $now - $item->getMTime() ) >= $lifetime ) {
				$base = $item->getPathname();
				foreach ( [ $base, $base . '.gz', $base . '.br' ] as $v ) {
					if ( file_exists( $v ) ) {
						unlink( $v );
					}
				}
				$deleted++;
			}
		}

		return $deleted;
	}

	/**
	 * Convert a URL to its corresponding cache file path.
	 *
	 * @param string $url  Full URL.
	 * @param string $stem Cache filename stem (e.g. "index", "index-mobile",
	 *                     "index--<hash>"). Defaults to the plain "index" variant.
	 * @return string Absolute path to the cache file.
	 */
	public function url_to_cache_file( string $url, string $stem = 'index' ): string {
		$parsed = wp_parse_url( $url );
		$scheme = $parsed['scheme'] ?? 'http';
		$host   = strtolower( $parsed['host'] ?? '' );
		$path   = rtrim( $parsed['path'] ?? '/', '/' ) ?: '/';

		// Harden the stem: only our own [a-z0-9-] filenames are ever valid here.
		$stem = preg_replace( '/[^a-z0-9\-]/i', '', $stem ) ?: 'index';

		return BCBOOST_CACHE_DIR . $scheme . '/' . $host . $path . '/' . $stem . '.html';
	}

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

	/**
	 * Write plain HTML and all compressed variants atomically.
	 *
	 * @param string $html      HTML content.
	 * @param string $base_file Path to index.html.
	 * @return bool True if plain file written.
	 */
	private function write_all_variants( string $html, string $base_file ): bool {
		// Append a cache-generation timestamp comment so the HTML source confirms a write.
		$html .= "\n<!-- Cached by BaseCloud Boost at " . gmdate( 'Y-m-d H:i:s' ) . ' UTC -->';

		// Use atomic temp-file + rename instead of LOCK_EX.
		// CloudWays uses an NFS-backed wp-content volume; flock() (used internally
		// by LOCK_EX) is unsupported on NFS and raises "Inappropriate ioctl for device".
		// rename() is atomic on NFS and requires no locking.
		if ( ! $this->atomic_write( $base_file, $html ) ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( sprintf( '[BaseCloud Boost] atomic_write failed for: %s — PHP user likely cannot write to this path (CloudWays: check www-data ownership)', $base_file ) );
			}
			return false;
		}

		if ( function_exists( 'gzencode' ) ) {
			$gz = gzencode( $html, 9 );
			if ( $gz !== false ) {
				$this->atomic_write( $base_file . '.gz', $gz );
			}
		}

		if ( function_exists( 'brotli_compress' ) ) {
			$br = brotli_compress( $html, 11, BROTLI_TEXT );
			if ( $br !== false ) {
				$this->atomic_write( $base_file . '.br', $br );
			}
		}

		return true;
	}

	/**
	 * Write content atomically by writing to a temp file then renaming.
	 *
	 * Avoids LOCK_EX which fails on NFS-backed filesystems (e.g. CloudWays).
	 *
	 * @param string $destination Target file path.
	 * @param string $content     Content to write.
	 * @return bool
	 */
	private function atomic_write( string $destination, string $content ): bool {
		$tmp = $destination . '.tmp.' . getmypid();

		if ( file_put_contents( $tmp, $content ) === false ) {
			return false;
		}

		if ( ! rename( $tmp, $destination ) ) {
			@unlink( $tmp );
			return false;
		}

		return true;
	}

	/**
	 * Recursively remove a directory.
	 *
	 * @param string   $dir         Path to remove.
	 * @param bool     $keep_parent Keep the top-level directory itself.
	 * @param string[] $preserve    File basenames to leave in place.
	 * @return bool
	 */
	private function rmdir_recursive( string $dir, bool $keep_parent = false, array $preserve = [] ): bool {
		if ( ! is_dir( $dir ) ) {
			return true;
		}

		$iterator = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ),
			\RecursiveIteratorIterator::CHILD_FIRST
		);

		foreach ( $iterator as $item ) {
			/** @var \SplFileInfo $item */
			if ( $item->isDir() ) {
				// May legitimately fail when a preserved file remains inside —
				// suppressed so a purge can never spray warnings into the page.
				@rmdir( $item->getPathname() );
			} elseif ( ! in_array( $item->getFilename(), $preserve, true ) ) {
				@unlink( $item->getPathname() );
			}
		}

		if ( ! $keep_parent ) {
			@rmdir( $dir );
		}

		return true;
	}
}
