<?php
/**
 * LolaCore — MemoryBootstrapRunner
 *
 * Discovers, executes, and schedules addon memory bootstraps.
 * Uses a global adaptive budget to select the most important facts
 * across all active addons.
 *
 * @package LolaCore
 * @license GPL-2.0-or-later
 * @since   1.2.0
 */

declare(strict_types=1);

namespace LolaCore\Memory;

use LolaCore\Addon\Contract\MemoryBootstrapInterface;

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

class MemoryBootstrapRunner
{
    /** @var int Maximum baseline facts stored globally. Filterable via lolacore_max_baseline_facts. */
    private const MAX_BASELINE_FACTS = 24;

    /** @var int Minimum guaranteed facts per addon regardless of competition. */
    private const MIN_PER_ADDON = 2;

    /** @var int Maximum candidates each addon can propose. */
    private const MAX_CANDIDATES_PER_ADDON = 8;

    /** @var string WP-Cron hook name for unified refresh. */
    public const CRON_HOOK = 'lolacore_bootstrap_refresh';

    /** @var string Option key storing last-run timestamps per addon. */
    private const TIMESTAMPS_OPTION = 'lolacore_bootstrap_timestamps';

    /** @var string The source tag stored with bootstrap facts. */
    public const SOURCE_TAG = 'bootstrap';

    private MemoryManager $memory;

    public function __construct(?MemoryManager $memory = null)
    {
        $this->memory = $memory ?? new MemoryManager();
    }

    // ──────────────────────────────────────────────
    //  Discovery
    // ──────────────────────────────────────────────

    /**
     * Collect all registered bootstraps from the addon ecosystem.
     *
     * Addons register their MemoryBootstrapInterface via the
     * 'lolacore_register_memory_bootstraps' filter.
     *
     * @return MemoryBootstrapInterface[]
     */
    private function discoverBootstraps(): array
    {
        /** @var MemoryBootstrapInterface[] $bootstraps */
        $bootstraps = \apply_filters('lolacore_register_memory_bootstraps', []);

        // Validate: only keep valid interface implementations
        return array_filter(
            $bootstraps,
            fn ($b) => $b instanceof MemoryBootstrapInterface
        );
    }

    // ──────────────────────────────────────────────
    //  Execution
    // ──────────────────────────────────────────────

    /**
     * Run a single addon's bootstrap by slug.
     *
     * Used on addon activation for immediate seeding.
     */
    public function run(string $slug): int
    {
        $bootstraps = $this->discoverBootstraps();
        $target = null;

        foreach ($bootstraps as $bootstrap) {
            if ($bootstrap->addonSlug() === $slug) {
                $target = $bootstrap;
                break;
            }
        }

        if ($target === null) {
            return 0;
        }

        $candidates = $this->collectCandidates([$target]);
        $selected = $this->applyGlobalBudget($candidates, [$slug]);

        return $this->upsertFacts($selected);
    }

    /**
     * Run all registered bootstraps with global budget selection.
     *
     * Called by the unified cron job and on retroactive detection.
     */
    public function runAll(): int
    {
        $bootstraps = $this->discoverBootstraps();
        if (empty($bootstraps)) {
            return 0;
        }

        // Filter by refresh interval — skip addons not due for refresh
        $due = $this->filterDueBootstraps($bootstraps);
        if (empty($due)) {
            return 0;
        }

        $slugs = array_map(fn ($b) => $b->addonSlug(), $due);
        $candidates = $this->collectCandidates($due);
        $selected = $this->applyGlobalBudget($candidates, $slugs);

        $count = $this->upsertFacts($selected);

        // Update timestamps for executed addons
        $this->updateTimestamps($slugs);

        return $count;
    }

    /**
     * Run all bootstraps ignoring refresh intervals.
     *
     * Used for initial seeding on first boot.
     */
    public function runAllForced(): int
    {
        $bootstraps = $this->discoverBootstraps();
        if (empty($bootstraps)) {
            return 0;
        }

        $slugs = array_map(fn ($b) => $b->addonSlug(), $bootstraps);
        $candidates = $this->collectCandidates($bootstraps);
        $selected = $this->applyGlobalBudget($candidates, $slugs);

        $count = $this->upsertFacts($selected);
        $this->updateTimestamps($slugs);

        return $count;
    }

    // ──────────────────────────────────────────────
    //  Candidate collection
    // ──────────────────────────────────────────────

    /**
     * Collect fact candidates from multiple bootstraps.
     *
     * Enforces MAX_CANDIDATES_PER_ADDON and tags each fact with its addon slug.
     *
     * @param MemoryBootstrapInterface[] $bootstraps
     * @return array<int, array{content: string, fact_type: string, importance: int, addon_slug: string, structured_data?: array}>
     */
    private function collectCandidates(array $bootstraps): array
    {
        $all = [];

        foreach ($bootstraps as $bootstrap) {
            $slug = $bootstrap->addonSlug();

            try {
                $facts = $bootstrap->generateBaselineFacts();
            } catch (\Throwable $e) {
                if (defined('WP_DEBUG') && WP_DEBUG) {
                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                    error_log("[LolaCore] MemoryBootstrap error for '{$slug}': " . $e->getMessage());
                }
                continue;
            }

            if (!is_array($facts)) {
                continue;
            }

            // Sort by importance DESC and cap at MAX_CANDIDATES_PER_ADDON
            usort($facts, fn ($a, $b) => ($b['importance'] ?? 0) <=> ($a['importance'] ?? 0));
            $facts = array_slice($facts, 0, self::MAX_CANDIDATES_PER_ADDON);

            // Tag each fact with addon slug
            foreach ($facts as $fact) {
                if (empty($fact['content']) || empty($fact['fact_type'])) {
                    continue;
                }
                $fact['addon_slug'] = $slug;
                $all[] = $fact;
            }
        }

        return $all;
    }

    // ──────────────────────────────────────────────
    //  Global budget selection
    // ──────────────────────────────────────────────

    /**
     * Apply adaptive global budget: guaranteed minimum per addon + competitive pool.
     *
     * @param array $candidates All candidates from all addons.
     * @param string[] $activeSlugs Slugs of addons that contributed candidates.
     * @return array Selected facts within budget.
     */
    private function applyGlobalBudget(array $candidates, array $activeSlugs): array
    {
        /** @var int $maxFacts */
        $maxFacts = (int) \apply_filters('lolacore_max_baseline_facts', self::MAX_BASELINE_FACTS);
        $addonCount = count(array_unique($activeSlugs));

        if ($addonCount === 0 || empty($candidates)) {
            return [];
        }

        // Phase 1: Guaranteed minimum per addon
        $guaranteed = [];
        $remaining = [];

        // Group candidates by addon
        $byAddon = [];
        foreach ($candidates as $fact) {
            $byAddon[$fact['addon_slug']][] = $fact;
        }

        // Each addon gets up to MIN_PER_ADDON guaranteed slots (highest importance first)
        foreach ($byAddon as $slug => $addonFacts) {
            $take = array_slice($addonFacts, 0, self::MIN_PER_ADDON);
            $rest = array_slice($addonFacts, self::MIN_PER_ADDON);

            $guaranteed = array_merge($guaranteed, $take);
            $remaining = array_merge($remaining, $rest);
        }

        // Phase 2: Fill competitive pool from remaining candidates
        $poolSize = max(0, $maxFacts - count($guaranteed));
        usort($remaining, fn ($a, $b) => ($b['importance'] ?? 0) <=> ($a['importance'] ?? 0));
        $competitive = array_slice($remaining, 0, $poolSize);

        return array_merge($guaranteed, $competitive);
    }

    // ──────────────────────────────────────────────
    //  Upsert (insert or update by hash)
    // ──────────────────────────────────────────────

    /**
     * Write facts to the memory table with deduplication by content hash.
     *
     * If a fact with the same bootstrap_hash already exists, it is updated
     * instead of duplicated.
     *
     * @param array $facts Selected facts to persist.
     * @return int Number of facts written/updated.
     */
    private function upsertFacts(array $facts): int
    {
        global $wpdb;
        $table = $wpdb->prefix . 'lolacore_memory';
        $count = 0;

        foreach ($facts as $fact) {
            $hash = $this->computeHash($fact['addon_slug'], $fact['content']);

            // Merge structured_data with bootstrap metadata
            $structured = $fact['structured_data'] ?? [];
            $structured['bootstrap_hash'] = $hash;
            $structured['bootstrap_addon'] = $fact['addon_slug'];

            // Check if this fact already exists by hash
            $existingId = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is built from $wpdb->prefix constant.
                $wpdb->prepare(
                    "SELECT id FROM {$table} WHERE source = %s AND structured_data LIKE %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is built from $wpdb->prefix constant.
                    self::SOURCE_TAG,
                    '%' . $wpdb->esc_like($hash) . '%'
                )
            );

            if ($existingId) {
                // Update existing fact — content and importance may have changed
                $this->memory->updateFact((int) $existingId, [
                    'content'         => $fact['content'],
                    'importance'      => $fact['importance'] ?? 5,
                    'structured_data' => $structured,
                ]);
            } else {
                // Insert new baseline fact
                $this->memory->saveFact(
                    $fact['fact_type'] ?? 'site_state',
                    $fact['content'],
                    $fact['importance'] ?? 5,
                    self::SOURCE_TAG,
                    null, // no session
                    $structured
                );
            }

            $count++;
        }

        return $count;
    }

    /**
     * Compute a deterministic hash for deduplication.
     *
     * Uses addon slug + normalized content to identify "the same" fact
     * across refreshes even if exact wording changes slightly.
     */
    private function computeHash(string $addonSlug, string $content): string
    {
        // Normalize: strip numbers and whitespace so "3 pending orders"
        // and "5 pending orders" map to the same slot.
        $normalized = preg_replace('/\d+/', 'N', $content);
        $normalized = preg_replace('/\s+/', ' ', trim($normalized ?? ''));

        return md5($addonSlug . ':' . $normalized);
    }

    // ──────────────────────────────────────────────
    //  Cron scheduling
    // ──────────────────────────────────────────────

    /**
     * Schedule the unified refresh cron if not already scheduled.
     */
    public function scheduleRefresh(): void
    {
        if (!\wp_next_scheduled(self::CRON_HOOK)) {
            \wp_schedule_event(time(), 'six_hours', self::CRON_HOOK);
        }
    }

    /**
     * Unschedule the cron job (called on plugin deactivation).
     */
    public static function unschedule(): void
    {
        $next = \wp_next_scheduled(self::CRON_HOOK);
        if ($next) {
            \wp_unschedule_event($next, self::CRON_HOOK);
        }
    }

    /**
     * Handle the cron callback: run all due bootstraps.
     */
    public function handleCron(): void
    {
        $this->runAll();
    }

    /**
     * Register the 'six_hours' cron interval if WordPress doesn't have it.
     *
     * @param array $schedules Existing cron schedules.
     * @return array Modified schedules.
     */
    public static function registerCronInterval(array $schedules): array
    {
        if (!isset($schedules['six_hours'])) {
            $schedules['six_hours'] = [
                'interval' => 21600,
                'display'  => 'Every Six Hours',
            ];
        }
        return $schedules;
    }

    // ──────────────────────────────────────────────
    //  Refresh interval tracking
    // ──────────────────────────────────────────────

    /**
     * Filter bootstraps that are due for a refresh based on their interval.
     *
     * @param MemoryBootstrapInterface[] $bootstraps
     * @return MemoryBootstrapInterface[]
     */
    private function filterDueBootstraps(array $bootstraps): array
    {
        $timestamps = \get_option(self::TIMESTAMPS_OPTION, []);
        if (!is_array($timestamps)) {
            $timestamps = [];
        }

        $now = time();
        $due = [];

        foreach ($bootstraps as $bootstrap) {
            $slug = $bootstrap->addonSlug();
            $interval = $bootstrap->refreshInterval();
            $lastRun = $timestamps[$slug] ?? 0;

            // If never run, or interval is 0 and never run, or interval elapsed
            if ($lastRun === 0 || ($interval > 0 && ($now - $lastRun) >= $interval)) {
                $due[] = $bootstrap;
            }
        }

        return $due;
    }

    /**
     * Record the current timestamp for each executed addon.
     *
     * @param string[] $slugs Addon slugs that were just executed.
     */
    private function updateTimestamps(array $slugs): void
    {
        $timestamps = \get_option(self::TIMESTAMPS_OPTION, []);
        if (!is_array($timestamps)) {
            $timestamps = [];
        }

        $now = time();
        foreach ($slugs as $slug) {
            $timestamps[$slug] = $now;
        }

        \update_option(self::TIMESTAMPS_OPTION, $timestamps, false);
    }
}
