<?php

/**
 * LolaCore - SkillLoader
 *
 * Scans the four skill sources (built-in, addon-registered, user
 * filesystem and DB) and returns the parsed/validated Skill objects
 * alongside any per-file errors. A failure in one file does NOT block
 * the rest — errors are accumulated and returned.
 *
 * Caching is intentionally NOT implemented in this sprint (SMD-02).
 *
 * @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 SkillLoader
{
    public const FILESYSTEM_SENTINEL = 'NODIR';
    private const DB_CACHE_KEY       = 'lolacore_skills_db_active';
    private const DB_CACHE_GROUP     = 'lolacore';

    private YamlParser $parser;
    private SkillValidator $validator;

    /** Override paths for testing — when set, replaces the production paths. */
    private ?string $builtInPathOverride   = null;
    private ?string $filesystemPathOverride = null;

    public function __construct(YamlParser $parser, SkillValidator $validator)
    {
        $this->parser    = $parser;
        $this->validator = $validator;
    }

    public function setBuiltInPath(?string $path): void
    {
        $this->builtInPathOverride = $path;
    }

    public function setFilesystemPath(?string $path): void
    {
        $this->filesystemPathOverride = $path;
    }

    /**
     * Load skills from all four sources.
     *
     * @return array{skills: array<int,Skill>, errors: array<int,SkillParseError|SkillValidationError>}
     */
    public function loadFromAllSources(): array
    {
        $skills = [];
        $errors = [];

        $this->loadFromBuiltIn($skills, $errors);
        $this->loadFromAddons($skills, $errors);
        $this->loadFromFilesystem($skills, $errors);
        $this->loadFromDatabase($skills, $errors);

        return [
            'skills' => $skills,
            'errors' => $errors,
        ];
    }

    /**
     * Parse a single `.md` skill file from disk.
     *
     * @return Skill|SkillParseError|SkillValidationError
     */
    public function parseSkillFile(string $path, string $origin): Skill|SkillParseError|SkillValidationError
    {
        if (!is_file($path) || !is_readable($path)) {
            return new SkillParseError(
                sprintf("Skill file not readable: %s", $path),
                1,
                'unreadable',
                $path
            );
        }

        $content = @file_get_contents($path);
        if ($content === false) {
            return new SkillParseError(
                sprintf("Failed to read skill file: %s", $path),
                1,
                'read-failed',
                $path
            );
        }

        return $this->parseSkillContent($content, $origin, $path);
    }

    /**
     * Compute a stable hash of the filesystem sources for cache invalidation.
     *
     * Combines paths and mtimes for built-in, addon-registered and user
     * filesystem sources. Returns a sentinel-aware sha256 hash. If the
     * user filesystem dir does not exist, uses 'NODIR' as a marker.
     */
    public function computeFilesystemHash(): string
    {
        $mtimes = [];

        $builtIn = $this->getBuiltInPath();
        if ($builtIn !== null && is_dir($builtIn)) {
            foreach ($this->globSkillFiles($builtIn) as $path) {
                $mtimes[$path] = (int) @filemtime($path);
            }
        }

        foreach ($this->getAddonPaths() as $path) {
            if (is_dir($path)) {
                foreach ($this->globSkillFiles($path) as $file) {
                    $mtimes[$file] = (int) @filemtime($file);
                }
            }
        }

        $fsPath = $this->getFilesystemPath();
        if ($fsPath === null || !is_dir($fsPath)) {
            $mtimes[self::FILESYSTEM_SENTINEL] = 0;
        } else {
            foreach ($this->globSkillFiles($fsPath) as $file) {
                $mtimes[$file] = (int) @filemtime($file);
            }
        }

        ksort($mtimes);
        return hash('sha256', serialize($mtimes));
    }

    /**
     * @param array<int,Skill>                                       $skills
     * @param array<int,SkillParseError|SkillValidationError>        $errors
     */
    private function loadFromBuiltIn(array &$skills, array &$errors): void
    {
        $path = $this->getBuiltInPath();
        if ($path === null || !is_dir($path)) {
            return;
        }
        $this->loadFromDirectory($path, Skill::ORIGIN_BUILT_IN, $skills, $errors);
    }

    /**
     * @param array<int,Skill>                                       $skills
     * @param array<int,SkillParseError|SkillValidationError>        $errors
     */
    private function loadFromAddons(array &$skills, array &$errors): void
    {
        foreach ($this->getAddonPaths() as $slug => $path) {
            if (!is_dir($path)) {
                if (defined('WP_DEBUG') && WP_DEBUG) {
                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional debug log behind WP_DEBUG guard.
                    error_log(sprintf('[LolaCore] Addon "%s" registered non-existent skills path: %s', $slug, $path));
                }
                continue;
            }
            $this->loadFromDirectory($path, Skill::ORIGIN_ADDON, $skills, $errors);
        }
    }

    /**
     * @param array<int,Skill>                                       $skills
     * @param array<int,SkillParseError|SkillValidationError>        $errors
     */
    private function loadFromFilesystem(array &$skills, array &$errors): void
    {
        $path = $this->getFilesystemPath();
        if ($path === null || !is_dir($path)) {
            return;
        }
        $this->loadFromDirectory($path, Skill::ORIGIN_FILESYSTEM, $skills, $errors);
    }

    /**
     * @param array<int,Skill>                                       $skills
     * @param array<int,SkillParseError|SkillValidationError>        $errors
     */
    private function loadFromDatabase(array &$skills, array &$errors): void
    {
        $rows = $this->fetchDbRows();
        if ($rows === null) {
            return;
        }

        foreach ($rows as $row) {
            $name    = (string) ($row['skill_name'] ?? '');
            $content = (string) ($row['content'] ?? '');
            $status  = (string) ($row['status'] ?? Skill::STATUS_ACTIVE);
            $virtual = $name !== '' ? sprintf('db://%s', $name) : 'db://(unnamed)';

            $result = $this->parseSkillContent($content, Skill::ORIGIN_DB, $virtual);
            if ($result instanceof Skill) {
                // The DB column is the source of truth for status — never the YAML
                // frontmatter inside content. Drafts must flow through the registry
                // so the admin UI can render them with their Approve/Edit/Delete row.
                $skills[] = $status !== Skill::STATUS_ACTIVE
                    ? $result->withStatus($status)
                    : $result;
            } else {
                $errors[] = $result;
            }
        }
    }

    /**
     * @return array<int,array<string,mixed>>|null
     */
    private function fetchDbRows(): ?array
    {
        if (!isset($GLOBALS['wpdb']) || !is_object($GLOBALS['wpdb'])) {
            return null;
        }
        $wpdb = $GLOBALS['wpdb'];

        if (function_exists('wp_cache_get')) {
            $cached = wp_cache_get(self::DB_CACHE_KEY, self::DB_CACHE_GROUP);
            if (is_array($cached)) {
                return $cached;
            }
        }

        $prefix = property_exists($wpdb, 'prefix') ? $wpdb->prefix : '';
        $table  = $prefix . 'lolacore_skills';
        // Load both active and inactive skills so the admin can see them all.
        // Archived rows stay hidden (the user jubilated them).
        $sql = $wpdb->prepare(
            "SELECT skill_name, content, status FROM {$table} WHERE status IN (%s, %s) ORDER BY id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is built from $wpdb->prefix constant.
            'active',
            'inactive'
        );

        if (!method_exists($wpdb, 'get_results')) {
            return null;
        }

        $output = defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A';
        $rows   = @$wpdb->get_results($sql, $output); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query prepared above; $table from $wpdb->prefix.
        if (!is_array($rows)) {
            $rows = [];
        }

        if (function_exists('wp_cache_set')) {
            wp_cache_set(self::DB_CACHE_KEY, $rows, self::DB_CACHE_GROUP);
        }

        return $rows;
    }

    /**
     * @param array<int,Skill>                                       $skills
     * @param array<int,SkillParseError|SkillValidationError>        $errors
     */
    private function loadFromDirectory(string $dir, string $origin, array &$skills, array &$errors): void
    {
        foreach ($this->globSkillFiles($dir) as $path) {
            $result = $this->parseSkillFile($path, $origin);
            if ($result instanceof Skill) {
                $skills[] = $result;
            } else {
                $errors[] = $result;
            }
        }
    }

    /**
     * @return array<int,string>
     */
    private function globSkillFiles(string $dir): array
    {
        $pattern = rtrim($dir, "/\\") . DIRECTORY_SEPARATOR . '*.md';
        $files   = glob($pattern);
        if (!is_array($files)) {
            return [];
        }
        sort($files);
        return $files;
    }

    /**
     * Parse a raw markdown string with frontmatter.
     *
     * @return Skill|SkillParseError|SkillValidationError
     */
    private function parseSkillContent(string $content, string $origin, string $sourcePath): Skill|SkillParseError|SkillValidationError
    {
        $extracted = $this->extractFrontmatter($content, $sourcePath);
        if ($extracted instanceof SkillParseError) {
            return $extracted;
        }
        [$frontmatterBlock, $body] = $extracted;

        try {
            $parsed = $this->parser->parse($frontmatterBlock, $sourcePath);
        } catch (SkillParseError $e) {
            return $e;
        }

        try {
            $validated = $this->validator->validate($parsed, $sourcePath);
        } catch (SkillValidationError $e) {
            return $e;
        }

        return Skill::fromParsed($validated, $body, $origin);
    }

    /**
     * Extract the YAML frontmatter block and body from a markdown file.
     *
     * @return array{0:string, 1:string}|SkillParseError
     */
    private function extractFrontmatter(string $content, string $sourcePath): array|SkillParseError
    {
        $content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content;
        $lines   = preg_split("/\r\n|\r|\n/", $content);
        if ($lines === false || count($lines) === 0) {
            return new SkillParseError(
                'Empty skill file',
                1,
                'empty',
                $sourcePath
            );
        }

        if (trim($lines[0]) !== '---') {
            return new SkillParseError(
                'Skill file is missing opening frontmatter delimiter "---"',
                1,
                'no-frontmatter',
                $sourcePath
            );
        }

        $closeIdx = null;
        $total    = count($lines);
        for ($i = 1; $i < $total; $i++) {
            if (trim($lines[$i]) === '---') {
                $closeIdx = $i;
                break;
            }
        }

        if ($closeIdx === null) {
            return new SkillParseError(
                'Skill file is missing closing frontmatter delimiter "---"',
                $total,
                'no-frontmatter-close',
                $sourcePath
            );
        }

        $frontmatter = implode("\n", array_slice($lines, 1, $closeIdx - 1));
        $body        = implode("\n", array_slice($lines, $closeIdx + 1));

        return [$frontmatter, ltrim($body, "\r\n")];
    }

    private function getBuiltInPath(): ?string
    {
        if ($this->builtInPathOverride !== null) {
            return $this->builtInPathOverride;
        }
        if (defined('LOLACORE_PLUGIN_DIR')) {
            return rtrim((string) constant('LOLACORE_PLUGIN_DIR'), "/\\") . DIRECTORY_SEPARATOR . 'skills';
        }
        return null;
    }

    private function getFilesystemPath(): ?string
    {
        if ($this->filesystemPathOverride !== null) {
            return $this->filesystemPathOverride;
        }
        if (defined('WP_CONTENT_DIR')) {
            return rtrim((string) constant('WP_CONTENT_DIR'), "/\\") . DIRECTORY_SEPARATOR . 'lolacore-skills';
        }
        return null;
    }

    /**
     * Filter: lolacore_register_skills
     *
     * Allows addons to register their own skills directory.
     *
     * @param array<string,string> $paths Associative array: ['addon-slug' => '/absolute/path/to/skills/']
     * @return array<string,string> Modified paths array.
     *
     * Example (in addon Bootstrap.php):
     *   add_filter('lolacore_register_skills', function(array $paths): array {
     *       $paths['lolacore-woocommerce-pro'] = plugin_dir_path(LOLACORE_WC_PRO_FILE) . 'skills/';
     *       return $paths;
     *   });
     *
     * @return array<string,string>
     */
    private function getAddonPaths(): array
    {
        if (!function_exists('apply_filters')) {
            return [];
        }
        $registered = apply_filters('lolacore_register_skills', []);
        if (!is_array($registered)) {
            return [];
        }
        $paths = [];
        foreach ($registered as $slug => $path) {
            if (is_string($path) && $path !== '') {
                $paths[is_string($slug) ? $slug : $path] = $path;
            }
        }
        return $paths;
    }
}
