<?php
/**
 * Cache module — Pages index
 *
 * Maintains a lightweight index of pages currently cached on disk so the admin
 * UI can list them without recursively scanning thousands of files on every
 * page load. Updated incrementally by Page_Cache (write) and Cache_Purge
 * (delete), so the cost is paid in microseconds per cache hit instead of
 * hundreds of milliseconds per admin pageview.
 *
 * Storage: a single non-autoloaded option keyed by md5(url|variant). Each entry
 * carries the absolute URL, the variant (desktop/mobile/loggued/...), the file
 * size in bytes, the timestamp of the last write, and a flag distinguishing
 * HTML pages from cached redirects.
 *
 * Hard cap of 1000 entries to keep the option size reasonable on Elementor
 * sites with thousands of pages — the UI shows the most recent entries with a
 * notice when more files exist than the index tracks.
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Pages_Index {

	const OPTION_KEY = 'blaminhor_essentials_cache_pages_index';
	const MAX_ENTRIES = 1000;

	/** @var Blaminhor_Essentials_Cache_Pages_Index|null */
	private static $instance = null;

	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	private function __construct() {}

	/**
	 * Record a freshly written cache file (or update an existing entry).
	 *
	 * @param string $url     Absolute URL the cache file represents.
	 * @param string $variant Variant key (desktop, mobile, locale-suffix...).
	 * @param int    $size    File size in bytes.
	 * @param string $kind    'html' (default) or 'redirect'.
	 */
	public function record_write( $url, $variant, $size, $kind = 'html' ) {
		$url = (string) $url;
		if ( '' === $url ) {
			return;
		}
		$entries = $this->read();
		$key     = $this->key_for( $url, (string) $variant );

		$entries[ $key ] = array(
			'url'     => $url,
			'variant' => (string) $variant,
			'size'    => max( 0, (int) $size ),
			'mtime'   => time(),
			'kind'    => 'redirect' === $kind ? 'redirect' : 'html',
		);

		$entries = $this->trim( $entries );
		$this->write( $entries );
	}

	/**
	 * Drop every entry that matches a given URL (across all variants).
	 *
	 * @param string $url
	 */
	public function record_purge( $url ) {
		$url = (string) $url;
		if ( '' === $url ) {
			return;
		}
		$entries = $this->read();
		if ( empty( $entries ) ) {
			return;
		}
		$dirty = false;
		foreach ( $entries as $key => $entry ) {
			if ( isset( $entry['url'] ) && (string) $entry['url'] === $url ) {
				unset( $entries[ $key ] );
				$dirty = true;
			}
		}
		if ( $dirty ) {
			$this->write( $entries );
		}
	}

	/**
	 * Wipe the entire index. Called on full purge / theme switch / etc.
	 */
	public function record_purge_all() {
		delete_option( self::OPTION_KEY );
	}

	/**
	 * Return entries sorted by mtime DESC (most recent first), optionally paged.
	 *
	 * @param int $limit  0 = no limit.
	 * @param int $offset Skipped from the front of the sorted list.
	 * @return array<int, array>
	 */
	public function get_entries( $limit = 0, $offset = 0 ) {
		$entries = array_values( $this->read() );
		usort( $entries, static function ( $a, $b ) {
			$am = isset( $a['mtime'] ) ? (int) $a['mtime'] : 0;
			$bm = isset( $b['mtime'] ) ? (int) $b['mtime'] : 0;
			return $bm <=> $am;
		} );

		$offset = max( 0, (int) $offset );
		$limit  = max( 0, (int) $limit );
		if ( $offset > 0 || $limit > 0 ) {
			return array_slice( $entries, $offset, $limit > 0 ? $limit : null );
		}
		return $entries;
	}

	/**
	 * @return int Total number of entries in the index.
	 */
	public function get_count() {
		return count( $this->read() );
	}

	/**
	 * Combined size of every cached file currently tracked, in bytes.
	 *
	 * @return int
	 */
	public function get_total_bytes() {
		$total = 0;
		foreach ( $this->read() as $entry ) {
			if ( isset( $entry['size'] ) ) {
				$total += (int) $entry['size'];
			}
		}
		return $total;
	}

	/**
	 * @return bool True when the index is at the hard cap and older entries are
	 *              being dropped to make room for new writes.
	 */
	public function is_capped() {
		return $this->get_count() >= self::MAX_ENTRIES;
	}

	/* ------------------------------------------------------------------ */
	/*  Internals                                                          */
	/* ------------------------------------------------------------------ */

	/**
	 * @return array<string, array> Key → entry map.
	 */
	private function read() {
		$raw = get_option( self::OPTION_KEY, array() );
		return is_array( $raw ) ? $raw : array();
	}

	/**
	 * Persist the index. Non-autoloaded so the option does not bloat every
	 * frontend request — only the cache write path and the admin tab read it.
	 */
	private function write( $entries ) {
		update_option( self::OPTION_KEY, $entries, false );
	}

	/**
	 * Stable per-(url,variant) key. Hash so we keep entry keys short even when
	 * URLs are long (some Elementor sites serialise dozens of query params).
	 */
	private function key_for( $url, $variant ) {
		return md5( $url . '|' . $variant );
	}

	/**
	 * Drop the oldest entries when over the hard cap, keeping the most recently
	 * written. Sort by mtime DESC then take the first MAX_ENTRIES.
	 */
	private function trim( $entries ) {
		if ( count( $entries ) <= self::MAX_ENTRIES ) {
			return $entries;
		}
		uasort( $entries, static function ( $a, $b ) {
			$am = isset( $a['mtime'] ) ? (int) $a['mtime'] : 0;
			$bm = isset( $b['mtime'] ) ? (int) $b['mtime'] : 0;
			return $bm <=> $am;
		} );
		return array_slice( $entries, 0, self::MAX_ENTRIES, true );
	}
}
