<?php

/**
 * File-backed page-cache tag index.
 *
 * Maps cache tags (post:ID, post_type:slug, term:tax:id, author:ID,
 * front_page, home, post_type_archive:slug) to the cached page files that
 * carry them, so a single entity change can purge exactly the affected
 * cached pages instead of wiping the whole cache.
 *
 * The index is a bounded JSON document with an exclusive lock around every
 * read-modify-write. If the file is missing or corrupt, is_available()
 * returns false and callers fall back to a full purge — the index is an
 * optimisation, never a correctness dependency.
 *
 * @package ProRank\SEO\Modules\Performance
 */

declare(strict_types=1);

namespace ProRank\SEO\Modules\Performance;

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

/**
 * CacheTagIndex storage helper.
 */
final class CacheTagIndex {

    /**
     * Hard caps so the index file can never grow unbounded.
     */
    public const MAX_TAGS         = 8000;
    public const MAX_KEYS_PER_TAG = 4000;

    /**
     * Absolute path to the JSON index file.
     *
     * @var string
     */
    private string $file;

    /**
     * @param string $index_file Absolute path to the index file.
     */
    public function __construct( string $index_file ) {
        $this->file = $index_file;
    }

    /**
     * @return string Index file path.
     */
    public function path(): string {
        return $this->file;
    }

    /**
     * Whether the index file exists and is readable as valid JSON.
     *
     * Missing or corrupt => false => caller falls back to full purge.
     *
     * @return bool
     */
    public function is_available(): bool {
        if ( ! is_file( $this->file ) ) {
            return false;
        }
        return $this->read() !== null;
    }

    /**
     * Record that a cache file carries the given tags.
     *
     * @param string            $cache_key Normalised cache file path.
     * @param array<int,string> $tags      Tags to associate.
     * @return bool Whether the index was written.
     */
    public function index( string $cache_key, array $tags ): bool {
        $cache_key = trim( $cache_key );
        if ( $cache_key === '' || empty( $tags ) ) {
            return false;
        }

        return $this->mutate( static function ( array $data ) use ( $cache_key, $tags ): array {
            foreach ( $tags as $tag ) {
                $tag = (string) $tag;
                if ( $tag === '' ) {
                    continue;
                }
                if ( ! isset( $data[ $tag ] ) || ! is_array( $data[ $tag ] ) ) {
                    if ( count( $data ) >= self::MAX_TAGS ) {
                        continue; // bounded: drop new tags once full.
                    }
                    $data[ $tag ] = [];
                }
                if ( ! isset( $data[ $tag ][ $cache_key ] ) ) {
                    if ( count( $data[ $tag ] ) >= self::MAX_KEYS_PER_TAG ) {
                        continue;
                    }
                    $data[ $tag ][ $cache_key ] = 1;
                }
            }
            return $data;
        } ) !== null;
    }

    /**
     * Cache keys recorded under a tag (without modifying the index).
     *
     * @param string $tag Tag.
     * @return array<int,string>
     */
    public function keys_for_tag( string $tag ): array {
        $data = $this->read();
        if ( ! is_array( $data ) || ! isset( $data[ $tag ] ) || ! is_array( $data[ $tag ] ) ) {
            return [];
        }
        return array_keys( $data[ $tag ] );
    }

    /**
     * Remove a tag and return the cache keys that were associated with it.
     *
     * @param string $tag Tag.
     * @return array<int,string>
     */
    public function purge_tag( string $tag ): array {
        $keys = [];
        $this->mutate( static function ( array $data ) use ( $tag, &$keys ): array {
            if ( isset( $data[ $tag ] ) && is_array( $data[ $tag ] ) ) {
                $keys = array_keys( $data[ $tag ] );
                unset( $data[ $tag ] );
            }
            return $data;
        } );
        return $keys;
    }

    /**
     * Drop a single cache key from every tag (e.g. when a URL is purged).
     *
     * @param string $cache_key Normalised cache file path.
     * @return void
     */
    public function forget_key( string $cache_key ): void {
        $this->mutate( static function ( array $data ) use ( $cache_key ): array {
            foreach ( $data as $tag => $keys ) {
                if ( is_array( $keys ) && isset( $keys[ $cache_key ] ) ) {
                    unset( $data[ $tag ][ $cache_key ] );
                    if ( empty( $data[ $tag ] ) ) {
                        unset( $data[ $tag ] );
                    }
                }
            }
            return $data;
        } );
    }

    /**
     * Delete the index file entirely (used on full cache clears).
     *
     * @return void
     */
    public function clear(): void {
        if ( is_file( $this->file ) ) {
            @unlink( $this->file );
        }
    }

    /**
     * Read + decode the index. Returns null when missing or corrupt.
     *
     * @return array<string,mixed>|null
     */
    private function read(): ?array {
        if ( ! is_file( $this->file ) ) {
            return null;
        }
        $raw = @file_get_contents( $this->file );
        if ( $raw === false ) {
            return null;
        }
        if ( $raw === '' ) {
            return [];
        }
        $data = json_decode( $raw, true );
        return is_array( $data ) ? $data : null;
    }

    /**
     * Exclusive read-modify-write. Returns the new data or null on failure.
     *
     * @param callable $fn Mutator: array -> array.
     * @return array<string,mixed>|null
     */
    private function mutate( callable $fn ): ?array {
        $dir = dirname( $this->file );
        if ( ! is_dir( $dir ) && ! @mkdir( $dir, 0755, true ) && ! is_dir( $dir ) ) {
            return null;
        }

        $handle = @fopen( $this->file, 'c+' );
        if ( $handle === false ) {
            return null;
        }

        try {
            if ( ! flock( $handle, LOCK_EX ) ) {
                fclose( $handle );
                return null;
            }

            $raw  = stream_get_contents( $handle );
            $data = ( $raw === '' || $raw === false ) ? [] : json_decode( $raw, true );
            if ( ! is_array( $data ) ) {
                $data = []; // corrupt -> rebuild from scratch.
            }

            $data = $fn( $data );
            if ( ! is_array( $data ) ) {
                $data = [];
            }

            $encoded = json_encode( $data );
            if ( $encoded === false ) {
                flock( $handle, LOCK_UN );
                fclose( $handle );
                return null;
            }

            ftruncate( $handle, 0 );
            rewind( $handle );
            fwrite( $handle, $encoded );
            fflush( $handle );
            flock( $handle, LOCK_UN );
            fclose( $handle );
            return $data;
        } catch ( \Throwable $e ) {
            @flock( $handle, LOCK_UN );
            @fclose( $handle );
            return null;
        }
    }
}
