<?php

/**
 * LolaCore - SkillRegistry
 *
 * Indexes all skills loaded from the four sources, resolves naming
 * collisions via the precedence rule (filesystem > db > addon > built_in),
 * applies compatibility flags (min_lolacore_version, requires_addons,
 * requires_plugins) and exposes the two-layer cache (transient + config
 * snapshot) so subsequent requests skip the full scan.
 *
 * Cache invariants:
 *   - Hot layer: transient `lolacore_skill_registry` with embedded fs_hash.
 *   - Cold layer: config row `skills.registry_snapshot` (longtext) with
 *     embedded fs_hash. Capped at 1 MB serialized.
 *   - Both layers self-invalidate on fs_hash mismatch.
 *
 * @package LolaCore
 * @license GPL-2.0-or-later
 */

declare(strict_types=1);

namespace LolaCore\Skills;

use LolaCore\Skills\Exceptions\SkillParseError;
use LolaCore\Skills\Exceptions\SkillValidationError;

class SkillRegistry
{
    public const TRANSIENT_KEY        = 'lolacore_skill_registry';
    public const SNAPSHOT_CONFIG_KEY  = 'skills.registry_snapshot';
    public const CACHE_TTL_CONFIG_KEY = 'skills.cache_ttl_min';
    public const DEFAULT_CACHE_TTL_MIN = 60;
    public const SNAPSHOT_BYTE_LIMIT  = 1_048_576; // 1 MB

    /**
     * Master toggle for the Skills feature (Beta). When '0', getActive()
     * returns an empty array regardless of per-skill state, so neither
     * built-ins nor user-imported skills are ever injected into the prompt.
     * Default '1' preserves the behavior shipped in SMD-04/05.
     */
    public const SECTION_ENABLED_CONFIG_KEY = 'skills.section_enabled';
    public const SECTION_ENABLED_DEFAULT    = '1';

    private SkillLoader $loader;

    /** @var array<string, Skill> Indexed by sanitized name. */
    private array $skills = [];

    /** @var array<int, SkillParseError|SkillValidationError> */
    private array $errors = [];

    private bool $built = false;

    public function __construct(SkillLoader $loader)
    {
        $this->loader = $loader;
    }

    /**
     * Load skills using the two-layer cache when available, else full scan.
     */
    public function load(): void
    {
        $hot = $this->getFromCache();
        if ($hot !== null) {
            $this->populateFromData($hot);
            return;
        }

        $cold = $this->getFromSnapshot();
        if ($cold !== null) {
            $this->populateFromData($cold);
            $this->saveToCache($this->serializeForCache());
            return;
        }

        $this->build();
        $data = $this->serializeForCache();
        $this->saveToCache($data);
        $this->saveSnapshot($data);
    }

    /**
     * Full scan: load from sources, apply precedence + compatibility flags.
     */
    public function build(): void
    {
        $this->skills = [];
        $this->errors = [];

        $result = $this->loader->loadFromAllSources();
        $errors = is_array($result['errors'] ?? null) ? $result['errors'] : [];
        $skills = is_array($result['skills'] ?? null) ? $result['skills'] : [];

        $this->errors = $errors;

        // Walk in precedence order — later sources override earlier sources.
        $bucketed = [
            Skill::ORIGIN_BUILT_IN   => [],
            Skill::ORIGIN_ADDON      => [],
            Skill::ORIGIN_DB         => [],
            Skill::ORIGIN_FILESYSTEM => [],
        ];

        foreach ($skills as $skill) {
            $origin = $skill->getOrigin();
            if (!isset($bucketed[$origin])) {
                continue;
            }
            $bucketed[$origin][] = $skill;
        }

        foreach (
            [
                Skill::ORIGIN_BUILT_IN,
                Skill::ORIGIN_ADDON,
                Skill::ORIGIN_DB,
                Skill::ORIGIN_FILESYSTEM,
            ] as $origin
        ) {
            foreach ($bucketed[$origin] as $skill) {
                $name = $skill->getName();
                if ($name === '') {
                    continue;
                }
                $this->skills[$name] = $this->applyRuntimeFlags($skill);
            }
        }

        $this->built = true;
    }

    /**
     * Invalidate the hot cache layer. Snapshot self-invalidates via fs_hash.
     */
    public function invalidate(): void
    {
        if (function_exists('delete_transient')) {
            delete_transient(self::TRANSIENT_KEY);
        }
    }

    /**
     * @return array<string, Skill>
     */
    public function getAll(): array
    {
        return $this->skills;
    }

    public function getActive(): array
    {
        if ($this->readConfig(self::SECTION_ENABLED_CONFIG_KEY, self::SECTION_ENABLED_DEFAULT) !== '1') {
            return [];
        }

        $active = array_values(array_filter(
            $this->skills,
            static fn(Skill $s) => $s->isActivatable()
        ));

        $maxActive = (int) $this->readConfig('skills.max_active', '30');
        if ($maxActive > 0 && count($active) > $maxActive) {
            return array_slice($active, 0, $maxActive);
        }

        return $active;
    }

    /**
     * @return array<int, Skill>
     */
    public function getByStatus(string $status): array
    {
        return array_values(array_filter(
            $this->skills,
            static fn(Skill $s) => $s->getStatus() === $status
        ));
    }

    /**
     * @return array<int, SkillParseError|SkillValidationError>
     */
    public function getErrors(): array
    {
        return $this->errors;
    }

    // -----------------------------------------------------------------------
    // Cache — hot transient layer
    // -----------------------------------------------------------------------

    /**
     * @return array<string,mixed>|null
     */
    public function getFromCache(): ?array
    {
        if (!function_exists('get_transient')) {
            return null;
        }
        $cached = get_transient(self::TRANSIENT_KEY);
        if (!is_array($cached) || !isset($cached['fs_hash'], $cached['data'])) {
            return null;
        }
        $currentHash = $this->loader->computeFilesystemHash();
        if ($cached['fs_hash'] !== $currentHash) {
            if (function_exists('delete_transient')) {
                delete_transient(self::TRANSIENT_KEY);
            }
            return null;
        }
        return is_array($cached['data']) ? $cached['data'] : null;
    }

    public function saveToCache(array $data): void
    {
        if (!function_exists('set_transient')) {
            return;
        }
        $ttlMin = (int) $this->readConfig(self::CACHE_TTL_CONFIG_KEY, (string) self::DEFAULT_CACHE_TTL_MIN);
        if ($ttlMin <= 0) {
            $ttlMin = self::DEFAULT_CACHE_TTL_MIN;
        }
        set_transient(
            self::TRANSIENT_KEY,
            [
                'data'    => $data,
                'fs_hash' => $this->loader->computeFilesystemHash(),
            ],
            $ttlMin * 60
        );
    }

    // -----------------------------------------------------------------------
    // Cache — cold snapshot layer (wp_lolacore_config row)
    // -----------------------------------------------------------------------

    /**
     * @return array<string,mixed>|null
     */
    public function getFromSnapshot(): ?array
    {
        $raw = $this->readConfig(self::SNAPSHOT_CONFIG_KEY, null);
        if (!is_string($raw) || $raw === '') {
            return null;
        }
        $decoded = json_decode($raw, true);
        if (!is_array($decoded) || !isset($decoded['fs_hash'], $decoded['data'])) {
            return null;
        }
        $currentHash = $this->loader->computeFilesystemHash();
        if ($decoded['fs_hash'] !== $currentHash) {
            return null;
        }
        return is_array($decoded['data']) ? $decoded['data'] : null;
    }

    public function saveSnapshot(array $data): void
    {
        $payload = [
            'data'    => $data,
            'fs_hash' => $this->loader->computeFilesystemHash(),
        ];
        $encoded = function_exists('wp_json_encode')
            ? wp_json_encode($payload)
            : json_encode($payload);
        if (!is_string($encoded)) {
            return;
        }
        if (strlen($encoded) > self::SNAPSHOT_BYTE_LIMIT) {
            if (defined('WP_DEBUG') && WP_DEBUG && function_exists('error_log')) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log('[LolaCore] Skills snapshot exceeds 1MB — falling back to full scan.');
            }
            return;
        }
        $this->writeConfig(self::SNAPSHOT_CONFIG_KEY, $encoded);
    }

    /**
     * Serialize the current registry to a portable array (no PHP objects).
     *
     * @return array<int, array<string,mixed>>
     */
    public function serializeForCache(): array
    {
        $arr = [];
        foreach ($this->skills as $skill) {
            $arr[] = $skill->toArray();
        }
        return $arr;
    }

    /**
     * Rebuild the registry index from a previously serialized array.
     *
     * @param array<int, array<string,mixed>> $data
     */
    public function populateFromData(array $data): void
    {
        $this->skills = [];
        $this->errors = [];
        foreach ($data as $row) {
            if (!is_array($row)) {
                continue;
            }
            $skill = Skill::fromArray($row);
            $name  = $skill->getName();
            if ($name !== '') {
                $this->skills[$name] = $skill;
            }
        }
        $this->built = true;
    }

    // -----------------------------------------------------------------------
    // Internal — compatibility flags + config helpers
    // -----------------------------------------------------------------------

    /**
     * Apply runtime status flags: incompatibility, unmet dependencies, no triggers.
     *
     * Persisted statuses (active, inactive, archived) are decisions the user
     * made and we never overwrite them — `inactive` in particular means
     * "the user turned this off via the toggle" and must round-trip intact.
     * Only `active` skills get the runtime-flag treatment, because that is
     * the only case where it makes sense to ask "can this fire right now?".
     */
    private function applyRuntimeFlags(Skill $skill): Skill
    {
        if ($skill->getStatus() !== Skill::STATUS_ACTIVE) {
            return $skill;
        }

        if (defined('LOLACORE_VERSION') && $skill->getMinLolaVersion() !== '') {
            $coreVersion = (string) constant('LOLACORE_VERSION');
            if (version_compare($skill->getMinLolaVersion(), $coreVersion, '>')) {
                return $skill->withStatus(Skill::STATUS_INCOMPATIBLE);
            }
        }

        $conditions = $skill->getConditions();
        if (!empty($conditions['requires_addons']) && !$this->addonsAvailable($conditions['requires_addons'])) {
            return $skill->withStatus(Skill::STATUS_UNMET_DEPENDENCIES);
        }
        if (!empty($conditions['requires_plugins']) && !$this->pluginsAvailable($conditions['requires_plugins'])) {
            return $skill->withStatus(Skill::STATUS_UNMET_DEPENDENCIES);
        }

        if (!$skill->hasActivationRules()) {
            return $skill->withStatus(Skill::STATUS_NO_TRIGGERS);
        }

        return $skill;
    }

    /**
     * @param array<int,string> $requiredAddons
     */
    private function addonsAvailable(array $requiredAddons): bool
    {
        $registered = function_exists('apply_filters')
            ? apply_filters('lolacore_addon_domains', [])
            : [];
        if (!is_array($registered)) {
            return false;
        }
        $available = array_keys($registered);
        foreach ($requiredAddons as $needed) {
            if (!in_array((string) $needed, $available, true)) {
                return false;
            }
        }
        return true;
    }

    /**
     * @param array<int,string> $requiredPlugins
     */
    private function pluginsAvailable(array $requiredPlugins): bool
    {
        if (!function_exists('get_option')) {
            return false;
        }
        $active = (array) get_option('active_plugins', []);
        foreach ($requiredPlugins as $needed) {
            if (!in_array((string) $needed, $active, true)) {
                return false;
            }
        }
        return true;
    }

    /**
     * Read a value from the `wp_lolacore_config` table.
     */
    private function readConfig(string $key, ?string $default): ?string
    {
        if (!isset($GLOBALS['wpdb']) || !is_object($GLOBALS['wpdb'])) {
            return $default;
        }
        $wpdb = $GLOBALS['wpdb'];
        if (!method_exists($wpdb, 'get_var') || !method_exists($wpdb, 'prepare')) {
            return $default;
        }
        $prefix = property_exists($wpdb, 'prefix') ? $wpdb->prefix : '';
        $table  = sanitize_key($prefix . 'lolacore_config');
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $value = @$wpdb->get_var($wpdb->prepare("SELECT config_value FROM {$table} WHERE config_key = %s", $key));
        if ($value === null) {
            return $default;
        }
        return (string) $value;
    }

    private function writeConfig(string $key, string $value): void
    {
        if (!isset($GLOBALS['wpdb']) || !is_object($GLOBALS['wpdb'])) {
            return;
        }
        $wpdb = $GLOBALS['wpdb'];
        if (!method_exists($wpdb, 'replace')) {
            return;
        }
        $prefix = property_exists($wpdb, 'prefix') ? $wpdb->prefix : '';
        $table  = sanitize_key($prefix . 'lolacore_config');
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
        @$wpdb->replace($table, ['config_key' => $key, 'config_value' => $value]);
    }
}
