<?php

/**
 * LolaCore � MemoryManager
 *
 * @package LolaCore
 * @license GPL-2.0-or-later
 */

declare(strict_types=1);

namespace LolaCore\Memory;

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Required for core functionality, dynamic queries, and performance. Validated inputs.

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class MemoryManager
 *
 * CRUD operations on the wp_lolacore_memory table.
 * Manages persistent facts that Lola remembers across sessions.
 *
 * All queries use $wpdb->prepare() for security.
 * Archived facts are excluded from relevance queries.
 */
class MemoryManager
{
    private string $table;

    public function __construct()
    {
        global $wpdb;
        $this->table = $wpdb->prefix . 'lolacore_memory';
    }

    /**
     * Saves a new fact to memory.
     *
     * @param string      $factType       Category: site_state|user_preference|action_result|plugin_conflict|performance_metric|client_context|disagreement
     * @param string      $content        The fact in natural language.
     * @param int         $importance     Importance score (1-10, 10 = critical).
     * @param string      $source         Origin: scan|conversation|action|system.
     * @param string|null $sessionId      UUID of the session where the fact was created.
     * @param array|null  $structuredData Optional structured data associated with the fact.
     * @return int The ID of the newly created fact.
     */
    public function saveFact(
        string $factType,
        string $content,
        int $importance,
        string $source,
        ?string $sessionId = null,
        ?array $structuredData = null,
    ): int {
        global $wpdb;

        $data = [
            'fact_type'   => $factType,
            'content'     => $content,
            'importance'  => $importance,
            'source'      => $source,
            'session_id'  => $sessionId,
            'decay_score' => 1.000,
        ];

        $formats = ['%s', '%s', '%d', '%s', '%s', '%f'];

        if ($structuredData !== null) {
            $data['structured_data'] = wp_json_encode($structuredData);
            $formats[] = '%s';
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        $wpdb->insert($this->table, $data, $formats);

        return (int) $wpdb->insert_id;
    }

    /**
     * Retrieves a fact by ID. Increments access_count and resets decay_score to 1.000.
     *
     * @param int $id The fact ID.
     * @return array|null The fact data, or null if not found.
     */
    public function getFact(int $id): ?array
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        $fact = $wpdb->get_row(
            $wpdb->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id),
            ARRAY_A,
        );

        if ($fact === null) {
            return null;
        }

        // Increment access count and reset decay score
        $this->markAccessed($id);

        // Refresh the row to reflect updated values
        $fact['access_count'] = (int) $fact['access_count'] + 1;
        $fact['decay_score']  = '1.000';

        return $fact;
    }

    /**
     * Retrieves facts filtered by type.
     *
     * @param string $factType The fact type to filter by.
     * @param int    $limit    Maximum number of facts to return.
     * @return array Array of fact rows.
     */
    public function getFactsByType(string $factType, int $limit = 20): array
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        return $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$this->table}
                 WHERE fact_type = %s AND is_archived = 0
                 ORDER BY (importance * decay_score) DESC
                 LIMIT %d",
                $factType,
                $limit,
            ),
            ARRAY_A,
        ) ?: [];
    }

    /**
     * Retrieves the most relevant active facts, ordered by composite relevance score.
     *
     * Relevance = importance × decay_score (DESC).
     * Archived facts are always excluded.
     *
     * @param int $limit Maximum number of facts to return.
     * @return array Array of fact rows.
     */
    public function getRelevantFacts(int $limit = 30): array
    {
        global $wpdb;

        // Pinned facts always surface first, then rank by importance × decay_score.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        return $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$this->table}
                 WHERE is_archived = 0
                 ORDER BY is_pinned DESC, (importance * decay_score) DESC
                 LIMIT %d",
                $limit,
            ),
            ARRAY_A,
        ) ?: [];
    }

    /**
     * Updates specific fields of a fact.
     *
     * @param int   $id   The fact ID.
     * @param array $data Key-value pairs of fields to update.
     * @return bool True if the update was successful.
     */
    public function updateFact(int $id, array $data): bool
    {
        global $wpdb;

        // Only allow updating safe fields
        $allowed = ['content', 'fact_type', 'importance', 'structured_data', 'source', 'is_archived', 'is_pinned'];
        $filtered = array_intersect_key($data, array_flip($allowed));

        if (empty($filtered)) {
            return false;
        }

        // Encode structured_data if present
        if (isset($filtered['structured_data']) && is_array($filtered['structured_data'])) {
            $filtered['structured_data'] = wp_json_encode($filtered['structured_data']);
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        $result = $wpdb->update(
            $this->table,
            $filtered,
            ['id' => $id],
        );

        return $result !== false;
    }

    /**
     * Searches facts by content using LIKE (no vector search in MVP).
     *
     * @param string $query The search query.
     * @param int    $limit Maximum results.
     * @return array Matching fact rows.
     */
    public function searchFacts(string $query, int $limit = 20): array
    {
        global $wpdb;

        $likeQuery = '%' . $wpdb->esc_like($query) . '%';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        return $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$this->table}
                 WHERE content LIKE %s AND is_archived = 0
                 ORDER BY (importance * decay_score) DESC
                 LIMIT %d",
                $likeQuery,
                $limit,
            ),
            ARRAY_A,
        ) ?: [];
    }

    /**
     * Marks a fact as accessed: increments access_count, resets decay_score, updates last_accessed.
     *
     * @param int $id The fact ID.
     */
    public function markAccessed(int $id): void
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$this->table}
                 SET access_count = access_count + 1,
                     decay_score = 1.000,
                     last_accessed = %s
                 WHERE id = %d",
                current_time('mysql'),
                $id,
            ),
        );
    }

    /**
     * Returns the count of active (non-archived) facts.
     *
     * @return int The count.
     */
    public function getActiveFactCount(): int
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        return (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$this->table} WHERE is_archived = 0",
        );
    }

    /**
     * Exports every memory fact as a JSON-encoded archive bundle.
     *
     * The bundle includes an `origin_hash` derived from `site_url()` to prevent
     * cross-site memory contamination on import, plus a plugin version stamp
     * for forward-compatibility with future schema migrations.
     */
    public function exportAll(): string
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        $rows = $wpdb->get_results("SELECT * FROM {$this->table}", ARRAY_A) ?: [];

        $version = defined('LOLACORE_VERSION') ? LOLACORE_VERSION : '0';

        $bundle = [
            'version'      => $version,
            'origin_hash'  => hash('sha256', site_url()),
            'exported_at'  => gmdate('c'),
            'fact_count'   => count($rows),
            'facts'        => $rows,
        ];

        return (string) wp_json_encode($bundle, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
    }

    /**
     * Imports memory facts from a JSON archive previously produced by exportAll().
     *
     * Validates the `origin_hash` against the current site to refuse archives
     * exported from a different origin. Returns counts of imported and skipped
     * rows. Throws on invalid input so the caller can surface the reason.
     *
     * @return array{imported:int, skipped:int}
     * @throws \InvalidArgumentException If JSON is malformed or origin_hash mismatches.
     */
    public function importFromJson(string $json): array
    {
        $bundle = json_decode($json, true);

        if (!is_array($bundle)) {
            throw new \InvalidArgumentException('Invalid JSON payload.');
        }

        $expectedHash = hash('sha256', site_url());
        $providedHash = (string) ($bundle['origin_hash'] ?? '');

        if ($providedHash !== $expectedHash) {
            throw new \InvalidArgumentException('Origin mismatch: this archive was exported from a different site.');
        }

        $facts = $bundle['facts'] ?? [];
        if (!is_array($facts)) {
            throw new \InvalidArgumentException('Archive does not contain a facts array.');
        }

        global $wpdb;
        $imported = 0;
        $skipped  = 0;

        foreach ($facts as $fact) {
            if (!is_array($fact) || empty($fact['fact_type']) || !isset($fact['content'])) {
                $skipped++;
                continue;
            }

            $row = [
                'fact_type'       => (string) $fact['fact_type'],
                'content'         => (string) $fact['content'],
                'importance'      => isset($fact['importance']) ? (int) $fact['importance'] : 5,
                'decay_score'     => isset($fact['decay_score']) ? (float) $fact['decay_score'] : 1.000,
                'access_count'    => isset($fact['access_count']) ? (int) $fact['access_count'] : 0,
                'source'          => isset($fact['source']) ? (string) $fact['source'] : 'import',
                'is_archived'     => isset($fact['is_archived']) ? (int) $fact['is_archived'] : 0,
            ];

            $formats = ['%s', '%s', '%d', '%f', '%d', '%s', '%d'];

            if (isset($fact['structured_data']) && $fact['structured_data'] !== null) {
                $row['structured_data'] = is_string($fact['structured_data']) 
                    ? $fact['structured_data'] 
                    : (string) wp_json_encode($fact['structured_data']);
                $formats[] = '%s';
            }

            if (!empty($fact['session_id'])) {
                $row['session_id'] = (string) $fact['session_id'];
                $formats[] = '%s';
            }

            if (!empty($fact['created_at'])) {
                $row['created_at'] = (string) $fact['created_at'];
                $formats[] = '%s';
            }

            if (!empty($fact['last_accessed'])) {
                $row['last_accessed'] = (string) $fact['last_accessed'];
                $formats[] = '%s';
            }

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
            $result = $wpdb->insert($this->table, $row, $formats);

            if ($result === false) {
                $skipped++;
            } else {
                $imported++;
            }
        }

        return ['imported' => $imported, 'skipped' => $skipped];
    }

    /**
     * Compacts the memory table by dropping low-value rows and reclaiming space.
     *
     * Removes archived facts and active facts whose decay_score has fallen
     * below 0.10 (effectively forgotten). Then runs OPTIMIZE TABLE to rebuild
     * indexes and free fragmented pages. Records the run timestamp so the UI
     * can show "last maintenance".
     *
     * @return array{deleted:int, remaining:int}
     */
    public function compact(): array
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        $deletedArchived = (int) $wpdb->query("DELETE FROM {$this->table} WHERE is_archived = 1");

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        $deletedDecayed = (int) $wpdb->query("DELETE FROM {$this->table} WHERE decay_score < 0.10 AND is_archived = 0");

        // OPTIMIZE TABLE cannot be parameterized; the table name is known and trusted.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Custom LolaCore table query.
        $wpdb->query("OPTIMIZE TABLE {$this->table}");

        $this->touchMaintenanceTimestamp();

        return [
            'deleted'   => $deletedArchived + $deletedDecayed,
            'remaining' => $this->getActiveFactCount(),
        ];
    }

    /**
     * Wipes the memory table entirely. Resets the maintenance timestamp.
     *
     * Irreversible — the caller must double-confirm at the UI layer.
     */
    public function wipeAll(): void
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Custom LolaCore table query.
        $wpdb->query("TRUNCATE TABLE {$this->table}");

        $this->touchMaintenanceTimestamp();
    }

    private function touchMaintenanceTimestamp(): void
    {
        global $wpdb;
        $configTable = $wpdb->prefix . 'lolacore_config';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Custom LolaCore table query.
        $wpdb->replace(
            $configTable,
            ['config_key' => 'last_maintenance_at', 'config_value' => gmdate('c')]
        );
    }

    /**
     * Returns a memory row by fact_hash, if present.
     *
     * @return array<string, mixed>|null
     */
    public function getFactByHash(string $factHash): ?array
    {
        if ($factHash === '') {
            return null;
        }

        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $row = $wpdb->get_row(
            $wpdb->prepare("SELECT * FROM {$this->table} WHERE fact_hash = %s LIMIT 1", $factHash),
            ARRAY_A
        );

        return is_array($row) ? $row : null;
    }

    /**
     * Inserts or skips a fleet reconcile fact (source = hub). Never overwrites local owner rows.
     *
     * @param array<string, mixed> $fact
     * @return string inserted|skipped_duplicate|skipped_collision|updated_importance|invalid
     */
    public function upsertHubFact(array $fact): string
    {
        $factType   = sanitize_key((string) ($fact['fact_type'] ?? ''));
        $content    = trim((string) ($fact['content'] ?? ''));
        $importance = max(1, min(10, (int) ($fact['importance'] ?? 5)));

        if ($factType === '' || $content === '') {
            return 'invalid';
        }

        $factHash = (string) ($fact['fact_hash'] ?? '');
        if ($factHash === '') {
            $factHash = \LolaCore\Hub\HubFactHash::compute($factType, $content);
        }

        $existing = $this->getFactByHash($factHash);
        if ($existing !== null) {
            $source = (string) ($existing['source'] ?? '');
            if ($source !== 'hub') {
                return 'skipped_collision';
            }

            $existingImportance = (int) ($existing['importance'] ?? 5);
            if ($importance > $existingImportance) {
                $this->updateFact((int) $existing['id'], ['importance' => $importance]);
                return 'updated_importance';
            }

            return 'skipped_duplicate';
        }

        global $wpdb;

        $data = [
            'fact_type'   => $factType,
            'content'     => $content,
            'importance'  => $importance,
            'source'      => 'hub',
            'fact_hash'   => $factHash,
            'decay_score' => 1.000,
        ];

        $formats = ['%s', '%s', '%d', '%s', '%s', '%f'];

        if (isset($fact['structured_data']) && is_array($fact['structured_data'])) {
            $data['structured_data'] = wp_json_encode($fact['structured_data']);
            $formats[]               = '%s';
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $inserted = $wpdb->insert($this->table, $data, $formats);

        return $inserted === false ? 'invalid' : 'inserted';
    }

    /**
     * Watermark for hub-sourced facts (master badge / delta sync).
     *
     * @return array{max_ts: string|null, count: int, fact_hashes: string[]}
     */
    public function getHubMemoryWatermark(): array
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $row = $wpdb->get_row(
            "SELECT MAX(updated_at) AS max_ts, COUNT(*) AS cnt FROM {$this->table} WHERE source = 'hub' AND is_archived = 0",
            ARRAY_A
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $hashes = $wpdb->get_col(
            "SELECT fact_hash FROM {$this->table} WHERE source = 'hub' AND is_archived = 0 AND fact_hash IS NOT NULL ORDER BY id ASC LIMIT 500"
        );

        return [
            'max_ts'      => is_array($row) ? (string) ($row['max_ts'] ?? '') : null,
            'count'       => is_array($row) ? (int) ($row['cnt'] ?? 0) : 0,
            'fact_hashes' => is_array($hashes) ? array_values(array_filter($hashes, 'is_string')) : [],
        ];
    }

    /**
     * Exports non-hub (owner) facts for pull/seed, filtered by type policy.
     *
     * @param string[] $types
     * @return array<int, array<string, mixed>>
     */
    public function exportOwnerFactsForHub(array $types, int $limit = 100): array
    {
        global $wpdb;

        if ($types === []) {
            return [];
        }

        $placeholders = implode(', ', array_fill(0, count($types), '%s'));
        $params       = array_merge($types, [$limit]);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, fact_type, content, importance, source, fact_hash, created_at, updated_at
                 FROM {$this->table}
                 WHERE is_archived = 0 AND source <> 'hub' AND fact_type IN ({$placeholders})
                 ORDER BY updated_at DESC
                 LIMIT %d",
                ...$params
            ),
            ARRAY_A
        );

        if (! is_array($rows)) {
            return [];
        }

        $facts = [];
        foreach ($rows as $row) {
            if (! is_array($row)) {
                continue;
            }

            $content  = (string) ($row['content'] ?? '');
            $factType = (string) ($row['fact_type'] ?? '');
            $hash     = (string) ($row['fact_hash'] ?? '');
            if ($hash === '') {
                $hash = \LolaCore\Hub\HubFactHash::compute($factType, $content);
            }

            $facts[] = [
                'id'         => (int) ($row['id'] ?? 0),
                'fact_type'  => $factType,
                'content'    => $content,
                'importance' => (int) ($row['importance'] ?? 5),
                'source'     => (string) ($row['source'] ?? ''),
                'fact_hash'  => $hash,
                'created_at' => (string) ($row['created_at'] ?? ''),
                'updated_at' => (string) ($row['updated_at'] ?? ''),
            ];
        }

        return $facts;
    }

    /**
     * @param string[] $types
     * @return array{max_ts: string|null, count: int, fact_hashes: string[]}
     */
    public function getOwnerMemoryWatermark(array $types): array
    {
        global $wpdb;

        if ($types === []) {
            return ['max_ts' => null, 'count' => 0, 'fact_hashes' => []];
        }

        $placeholders = implode(', ', array_fill(0, count($types), '%s'));

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $row = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT MAX(updated_at) AS max_ts, COUNT(*) AS cnt
                 FROM {$this->table}
                 WHERE is_archived = 0 AND source <> 'hub' AND fact_type IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic IN list matches spread args.
                ...$types
            ),
            ARRAY_A
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $hashes = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT fact_hash FROM {$this->table}
                 WHERE is_archived = 0 AND source <> 'hub' AND fact_type IN ({$placeholders}) AND fact_hash IS NOT NULL
                 ORDER BY id ASC LIMIT 500", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic IN list matches spread args.
                ...$types
            )
        );

        return [
            'max_ts'      => is_array($row) ? (string) ($row['max_ts'] ?? '') : null,
            'count'       => is_array($row) ? (int) ($row['cnt'] ?? 0) : 0,
            'fact_hashes' => is_array($hashes) ? array_values(array_filter($hashes, 'is_string')) : [],
        ];
    }

}