<?php
/**
 * Database-Only Cache Manager — UltraguardPro
 *
 * Single, unified cache layer for database query results only.
 *
 * Design decisions
 * ────────────────
 * • Only ONE cache group is active: "db_query".
 *   Render / widget / page / asset / realtime caches are all intentionally
 *   absent — this plugin is an admin-panel security dashboard and must always
 *   display live data.
 *
 * • Every write (INSERT / UPDATE / DELETE) automatically invalidates ALL
 *   db_query transients for the affected table via invalidate_db().
 *   DatabaseCrud::clear_table_cache() and BaseTable::clear_all_caches()
 *   both call this path, so no manual cache busting is ever needed.
 *
 * • Removed groups: render, realtime, asset, short, default.
 *   Backward-compat stubs are provided so any remaining callers compile
 *   without errors, but they are all no-ops that return false / true.
 *
 * @package   UltraguardPro\core
 * @author    Ultraguard Team
 * @version   3.0.0
 */

namespace UltraguardPro\core;

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

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cache invalidation and lookup intentionally inspect DB-backed transient state directly.

class CacheManager {

    // ── Singleton ─────────────────────────────────────────────────────────────
    private static $instance = null;

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

    private function __construct() {}

    // ── State ─────────────────────────────────────────────────────────────────
    /** @var array|null Lazily initialised config */
    private $config = null;

    /** @var array Full-key memo so sanitize_key() is not called twice for the same pair */
    private static $key_memo = [];

    // ── Lazy init ─────────────────────────────────────────────────────────────

    /**
     * Initialise master config (idempotent).
     *
     * Only db_query_ttl and short_ttl are kept; short_ttl is consumed by
     * BaseTable to resolve $short_cache_duration / $very_short_cache_duration
     * from CacheManager::get_config(), so it must remain even though no
     * separate "short" cache group exists any more.
     */
    private function init_config(): void {
        if ( null !== $this->config ) { return; }

        $defaults = [
            'enabled'          => true,
            'use_object_cache' => true,
            'use_transients'   => true,
            'max_key_length'   => 172,
            'batch_size'       => 100,
            // DB-query cache: max age of any SQL result before a fresh DB read
            // is forced. Writes auto-invalidate immediately; the TTL is a
            // safety fallback only.
            'db_query_ttl'     => 2 * MINUTE_IN_SECONDS,
            // Kept so BaseTable can resolve short/very-short durations without
            // needing its own constant. All reads still go through db_query.
            'short_ttl'        => 2 * MINUTE_IN_SECONDS,
            // Legacy keys — retained so apply_filters callbacks referencing
            // them don't error; values are never consumed internally.
            'render_ttl'       => 0,
            'realtime_ttl'     => 0,
            'asset_ttl'        => 0,
            'default_ttl'      => 2 * MINUTE_IN_SECONDS,
        ];

        $this->config = apply_filters( 'ultraguard_cache_config', $defaults );
    }

    // ── Core API ──────────────────────────────────────────────────────────────
    // All $group arguments are accepted for backward compatibility, but every
    // active call is routed through the single "db_query" backend.

    /**
     * Get a cached value.
     *
     * @param  string $key
     * @param  string $group  Accepted for compat; only db_query (and its
     *                        legacy aliases) are served.
     * @return mixed|false
     */
    public function get( $key, $group = 'db_query' ) {
        $this->init_config();
        if ( ! $this->config['enabled'] || empty( $key ) || ! is_string( $key ) ) {
            return false;
        }
        if ( ! $this->is_db_group( $group ) ) { return false; }

        $fk = $this->full_key( $key );
        if ( false === $fk ) { return false; }

        // L1: object cache (Redis / Memcached).
        if ( $this->config['use_object_cache'] && wp_using_ext_object_cache() ) {
            $found = false;
            $data  = wp_cache_get( $fk, 'ultraguard_db_query', false, $found );
            if ( $found && false !== $data ) {
                do_action( 'ultraguard_cache_hit', $key, 'db_query' );
                return $data;
            }
        }

        // L2: transients.
        if ( $this->config['use_transients'] ) {
            $data = get_transient( $fk );
            if ( false !== $data ) {
                // Warm L1.
                if ( $this->config['use_object_cache'] ) {
                    wp_cache_set( $fk, $data, 'ultraguard_db_query', $this->config['db_query_ttl'] );
                }
                do_action( 'ultraguard_cache_hit', $key, 'db_query' );
                return $data;
            }
        }

        do_action( 'ultraguard_cache_miss', $key, 'db_query' );
        return false;
    }

    /**
     * Store a value.
     *
     * @param  string   $key
     * @param  mixed    $data
     * @param  string   $group      Accepted for compat; only db_query is active.
     * @param  int|null $expiration Override TTL in seconds.
     * @return bool
     */
    public function set( $key, $data, $group = 'db_query', $expiration = null ): bool {
        $this->init_config();
        if ( ! $this->config['enabled'] || empty( $key ) || ! is_string( $key ) ) {
            return false;
        }
        if ( ! $this->is_db_group( $group ) ) { return false; }
        if ( ! $this->is_cacheable( $data ) ) { return false; }

        $fk  = $this->full_key( $key );
        if ( false === $fk ) { return false; }

        $ttl = max( 1, (int) ( $expiration ?? $this->config['db_query_ttl'] ) );
        $ok  = true;

        if ( $this->config['use_object_cache'] ) {
            $ok = wp_cache_set( $fk, $data, 'ultraguard_db_query', $ttl ) && $ok;
        }
        if ( $this->config['use_transients'] ) {
            $ok = set_transient( $fk, $data, $ttl ) && $ok;
        }

        if ( $ok ) { do_action( 'ultraguard_cache_set', $key, 'db_query', $ttl ); }
        return $ok;
    }

    /**
     * Delete a specific entry.
     *
     * @param  string $key
     * @param  string $group
     * @return bool
     */
    public function delete( $key, $group = 'db_query' ): bool {
        $this->init_config();
        if ( empty( $key ) || ! is_string( $key ) ) { return false; }
        if ( ! $this->is_db_group( $group ) ) { return false; }

        $fk = $this->full_key( $key );
        if ( false === $fk ) { return false; }

        if ( $this->config['use_object_cache'] ) {
            wp_cache_delete( $fk, 'ultraguard_db_query' );
        }
        $ok = true;
        if ( $this->config['use_transients'] ) {
            $ok = delete_transient( $fk );
        }
        if ( $ok ) { do_action( 'ultraguard_cache_delete', $key, 'db_query' ); }
        return $ok;
    }

    // ── Typed DB helpers ──────────────────────────────────────────────────────

    /** Store a DB-query result. */
    public function set_db( $key, $data, $expiration = null ): bool {
        return $this->set( $key, $data, 'db_query', $expiration );
    }

    /** Retrieve a DB-query result. Returns false on miss. */
    public function get_db( $key ) {
        return $this->get( $key, 'db_query' );
    }

    /**
     * Invalidate cached DB results — called automatically on every write.
     *
     * Cache keys come from two origins with different formats:
     *
     * 1. BaseTable  → ultraguard_db_query_{cache_group}_{blog_id}_v{ver}_{type}_{hash}
     *    Cleared by prefix: ultraguard_db_query_{table_key}
     *
     * 2. DatabaseCrud → ultraguard_db_query_{table_key}_{operation}_{hash}
     *    e.g. ultraguard_db_query_ultraguard_ll_blocked_ips_get_records_abc123
     *    Cleared by the SAME prefix after the key-format fix that puts table_key first.
     *
     * Passing null removes ALL db_query transients (full flush).
     *
     * @param string|null $table_key  Scoped invalidation key, or null for full flush.
     */
    public function invalidate_db( $table_key = null ): bool {
        $prefix = null !== $table_key
            ? 'ultraguard_db_query_' . sanitize_key( (string) $table_key )
            : 'ultraguard_db_query_';

        return $this->batch_delete_transients( $prefix );
    }

    // ── Invalidation / flush ──────────────────────────────────────────────────

    /**
     * Flush cache.  The $group argument is ignored — always flushes db_query.
     *
     * @param string|null $group  (legacy compat, ignored)
     */
    public function flush( $group = null ): bool {
        return $this->invalidate_db();
    }

    /** Flush every plugin transient (used by lifecycle deactivation hook). */
    public function flush_all_plugin_transients(): bool {
        $this->init_config();
        do_action( 'ultraguard_cache_before_flush_all' );
        $ok = $this->batch_delete_transients( 'ultraguard_' );
        do_action( 'ultraguard_cache_after_flush_all', $ok );
        return $ok;
    }

    // ── Backward-compat no-ops ────────────────────────────────────────────────
    // Render / realtime / asset groups have been removed.  These stubs keep
    // existing callers compiling without changes.

    /** @deprecated Render cache permanently disabled. Always a no-op. */
    public function flush_render_transients(): bool { return true; }

    /** @deprecated Render cache permanently disabled. Always a no-op. */
    public function invalidate_render(): bool { return true; }

    /** @deprecated Render cache permanently disabled. Always returns false (cache miss). */
    public function get_render( $key ): bool { return false; }

    /** @deprecated Render cache permanently disabled. Always returns false. */
    public function set_render( $key, $html, $expiration = null ): bool { return false; }

    /** @deprecated Realtime group removed. Always returns false. */
    public function set_realtime( $key, $data ): bool { return false; }

    /** @deprecated Realtime group removed. Always returns false (cache miss). */
    public function get_realtime( $key ): bool { return false; }

    // ── Bulk helpers ──────────────────────────────────────────────────────────

    public function get_multiple( $keys, $group = 'db_query' ): array {
        if ( ! is_array( $keys ) || empty( $keys ) ) { return []; }
        $out = [];
        foreach ( $keys as $k ) {
            $v = $this->get( (string) $k, $group );
            if ( false !== $v ) { $out[ $k ] = $v; }
        }
        return $out;
    }

    public function set_multiple( $items, $group = 'db_query', $expiration = null ): bool {
        if ( ! is_array( $items ) || empty( $items ) ) { return false; }
        $ok = true;
        foreach ( $items as $k => $v ) {
            $ok = $this->set( (string) $k, $v, $group, $expiration ) && $ok;
        }
        return $ok;
    }

    // ── Config / stats API ────────────────────────────────────────────────────

    public function set_config( $overrides ): void {
        if ( ! is_array( $overrides ) ) { return; }
        $this->init_config();
        $this->config   = wp_parse_args( $overrides, $this->config );
        self::$key_memo = [];
        do_action( 'ultraguard_cache_config_updated', $this->config );
    }

    public function get_config(): array {
        $this->init_config();
        return $this->config;
    }

    /**
     * Returns the single active cache group (db_query).
     * Legacy callers expecting multiple groups receive a single-element array.
     */
    public function get_groups(): array {
        $this->init_config();
        return [
            'db_query' => [
                'ttl'         => $this->config['db_query_ttl'],
                'compression' => false,
                'persistent'  => false,
            ],
        ];
    }

    public function get_stats(): array {
        global $wpdb;
        $this->init_config();

        $row = $wpdb->get_row(  // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query built via prepare()
            $wpdb->prepare(
                "SELECT COUNT(*) AS cnt, SUM(LENGTH(option_value)) AS sz
                   FROM {$wpdb->options}
                  WHERE option_name LIKE %s",
                '_transient_ultraguard_db_query_%'
            ),
            ARRAY_A
        );

        return apply_filters( 'ultraguard_cache_stats', [
            'enabled'          => $this->config['enabled'],
            'active_group'     => 'db_query',
            'db_query_ttl_sec' => $this->config['db_query_ttl'],
            'transients_count' => (int) ( $row['cnt'] ?? 0 ),
            'total_size_bytes' => (int) ( $row['sz']  ?? 0 ),
        ] );
    }

    // ── Internal helpers ──────────────────────────────────────────────────────

    /**
     * Maps group names to the db_query backend.
     * "short" and "default" were separate groups in v2 and are transparently
     * forwarded here so BaseTable / DatabaseCrud calls using those names work.
     */
    private function is_db_group( string $group ): bool {
        return in_array( $group, [ 'db_query', 'short', 'default' ], true );
    }

    /**
     * Build a normalised, length-safe transient/object-cache key.
     * All active keys live under the "ultraguard_db_query_" prefix.
     */
    private function full_key( string $key ) {
        if ( isset( self::$key_memo[ $key ] ) ) { return self::$key_memo[ $key ]; }

        $this->init_config();
        $sk = sanitize_key( $key );
        if ( '' === $sk ) {
            self::$key_memo[ $key ] = false;
            return false;
        }

        $full = 'ultraguard_db_query_' . $sk;
        $max  = $this->config['max_key_length'];

        if ( strlen( $full ) > $max ) {
            $full = 'ultraguard_db_query_' . md5( $sk );
            if ( strlen( $full ) > $max ) {
                $full = substr( $full, 0, $max );
            }
        }

        self::$key_memo[ $key ] = $full;
        return $full;
    }

    private function is_cacheable( $data ): bool {
        if ( is_resource( $data ) ) { return false; }
        if ( is_object( $data ) ) {
            if ( $data instanceof \WP_Post || $data instanceof \WP_User ||
                 $data instanceof \WP_Term || $data instanceof \stdClass ) {
                return true;
            }
            return $data instanceof \Serializable || method_exists( $data, '__sleep' );
        }
        return true;
    }

    /**
     * Batch-delete all transients whose names start with $prefix.
     *
     * Uses direct SQL (the WP API has no prefix-based bulk delete).
     * Flushes 'alloptions' from the object cache afterward so the deleted
     * entries are not served from memory within the same request.
     */
    private function batch_delete_transients( string $prefix ): bool {
        global $wpdb;
        $this->init_config();

        $batch    = max( 1, (int) $this->config['batch_size'] );
        $like_val = $wpdb->esc_like( '_transient_'         . $prefix ) . '%';
        $like_to  = $wpdb->esc_like( '_transient_timeout_' . $prefix ) . '%';

        $ok = true;
        do {
            $result = $this->run_transient_delete_batch( $like_val, $like_to, $batch );
            if ( false === $result ) { $ok = false; break; }
            if ( $result < $batch )  { break; }
        } while ( true );

        // Invalidate WP's in-memory alloptions bucket so the deleted transients
        // are not served from the object cache within this same request.
        wp_cache_delete( 'alloptions', 'options' );

        // Also flush the ultraguard_db_query object-cache group so that L1
        // (Redis / Memcached / in-process WP cache) does not keep serving stale
        // values for keys whose transients we just deleted from the DB.
        if ( function_exists( 'wp_cache_flush_group' ) ) {
            wp_cache_flush_group( 'ultraguard_db_query' );
        } else {
            // Fallback: delete individual object-cache entries matching the prefix.
            // wp_cache_flush() is intentionally not called — it would wipe unrelated caches.
            wp_cache_delete( $prefix, 'ultraguard_db_query' );
        }

        return $ok;
    }

    private function run_transient_delete_batch( string $like_val, string $like_to, int $batch ) {
        global $wpdb;

        $attempt = 0;

        do {
            $attempt++;
            $previous_suppression = $wpdb->suppress_errors( true );
            $result               = $wpdb->query(  // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query built via prepare()
                $wpdb->prepare(
                    "DELETE FROM {$wpdb->options}
                      WHERE option_name LIKE %s
                         OR option_name LIKE %s
                      LIMIT %d",
                    $like_val,
                    $like_to,
                    $batch
                )
            );
            $error_message = (string) $wpdb->last_error;
            $wpdb->suppress_errors( $previous_suppression );

            if ( false !== $result ) {
                return $result;
            }

            if ( ! $this->is_lock_contention_error( $error_message ) || $attempt >= 3 ) {
                if ( '' !== $error_message ) {
                    Logger::warning( '[CacheManager] Transient cleanup skipped: ' . $error_message );
                }

                return false;
            }

            usleep( 50000 * $attempt );
        } while ( true );
    }

    private function is_lock_contention_error( string $error_message ): bool {
        $error_message = strtolower( $error_message );

        return false !== strpos( $error_message, 'deadlock found' )
            || false !== strpos( $error_message, 'lock wait timeout exceeded' );
    }

    /** For test isolation only. */
    public static function clear_key_memo(): void {
        self::$key_memo = [];
    }
}
