<?php

/**
 * LolaCore - PlaybookLoader
 *
 * Scans the four playbook sources (built-in, addon-registered, user
 * filesystem and DB) and returns parsed Playbook objects alongside
 * per-file errors. A failure in one file does NOT block the rest.
 *
 * @package LolaCore
 * @license GPL-2.0-or-later
 */

declare(strict_types=1);

namespace LolaCore\Playbooks;

use LolaCore\Database\Schema;
use LolaCore\Playbooks\Exceptions\PlaybookParseError;
use LolaCore\Playbooks\Exceptions\PlaybookValidationError;
use LolaCore\Skills\Exceptions\SkillParseError;
use LolaCore\Skills\YamlParser;

class PlaybookLoader
{
    public const FILESYSTEM_SENTINEL = 'NODIR';
    private const DB_CACHE_KEY       = 'lolacore_playbooks_db_active';
    private const DB_CACHE_GROUP     = 'lolacore';

    private YamlParser $parser;
    private PlaybookValidator $validator;

    private ?string $builtInPathOverride    = null;
    private ?string $filesystemPathOverride = null;

    public function __construct(YamlParser $parser, PlaybookValidator $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;
    }

    /**
     * @return array{playbooks: array<int,Playbook>, errors: array<int,PlaybookParseError|PlaybookValidationError>}
     */
    public function loadFromAllSources(): array
    {
        $playbooks = [];
        $errors    = [];

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

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

    /**
     * @return Playbook|PlaybookParseError|PlaybookValidationError
     */
    public function parsePlaybookFile(string $path, string $origin): Playbook|PlaybookParseError|PlaybookValidationError
    {
        if (!is_file($path) || !is_readable($path)) {
            return new PlaybookParseError(
                sprintf('Playbook file not readable: %s', $path),
                1,
                'unreadable',
                $path
            );
        }

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

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

    /**
     * Return raw markdown (frontmatter + body) for a playbook slug, or null when not found.
     */
    public function readRawContentBySlug(string $slug): ?string
    {
        $slug = $this->sanitizeSlug($slug);
        if ($slug === '') {
            return null;
        }

        $fromDb = $this->readRawContentFromDatabase($slug);
        if ($fromDb !== null) {
            return $fromDb;
        }

        return $this->readRawContentFromFiles($slug);
    }

    public function computeFilesystemHash(): string
    {
        $mtimes = [];

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

        foreach ($this->getAddonPaths() as $path) {
            if (is_dir($path)) {
                foreach ($this->globPlaybookFiles($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->globPlaybookFiles($fsPath) as $file) {
                $mtimes[$file] = (int) @filemtime($file);
            }
        }

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

    /**
     * @return Playbook|PlaybookParseError|PlaybookValidationError
     */
    public function parsePlaybookContent(string $content, string $origin, string $sourcePath): Playbook|PlaybookParseError|PlaybookValidationError
    {
        $extracted = $this->extractFrontmatter($content, $sourcePath);
        if ($extracted instanceof PlaybookParseError) {
            return $extracted;
        }
        [$frontmatterBlock, $body] = $extracted;

        try {
            $parsed = $this->parser->parse($frontmatterBlock, $sourcePath);
        } catch (SkillParseError $e) {
            return new PlaybookParseError(
                $e->getMessage(),
                $e->getLineNumber(),
                $e->getToken(),
                $sourcePath !== '' ? $sourcePath : $e->getFilePath()
            );
        }

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

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

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

    /**
     * @param array<int,Playbook>                                              $playbooks
     * @param array<int,PlaybookParseError|PlaybookValidationError>             $errors
     */
    private function loadFromAddons(array &$playbooks, 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
                    error_log(sprintf('[LolaCore] Addon "%s" registered non-existent playbooks path: %s', $slug, $path));
                }
                continue;
            }
            $this->loadFromDirectory($path, Playbook::ORIGIN_ADDON, $playbooks, $errors);
        }
    }

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

    /**
     * @param array<int,Playbook>                                              $playbooks
     * @param array<int,PlaybookParseError|PlaybookValidationError>             $errors
     */
    private function loadFromDatabase(array &$playbooks, array &$errors): void
    {
        $rows = $this->fetchDbRows();
        if ($rows === null) {
            return;
        }

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

            $result = $this->parsePlaybookContent($content, Playbook::ORIGIN_DB, $virtual);
            if ($result instanceof Playbook) {
                $playbooks[] = $status !== Playbook::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_playbooks';

        if (!Schema::tableExists('lolacore_playbooks')) {
            return [];
        }

        $sql    = $wpdb->prepare(
            "SELECT playbook_slug, content, status FROM {$table} WHERE status IN (%s, %s) ORDER BY id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            'active',
            'inactive'
        );

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

        $output = defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A';
        $suppress = $wpdb->suppress_errors();
        $rows     = $wpdb->get_results($sql, $output); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $wpdb->suppress_errors($suppress);
        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,Playbook>                                              $playbooks
     * @param array<int,PlaybookParseError|PlaybookValidationError>             $errors
     */
    private function loadFromDirectory(string $dir, string $origin, array &$playbooks, array &$errors): void
    {
        foreach ($this->globPlaybookFiles($dir) as $path) {
            $result = $this->parsePlaybookFile($path, $origin);
            if ($result instanceof Playbook) {
                $playbooks[] = $result;
            } else {
                $errors[] = $result;
            }
        }
    }

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

    /**
     * @return array{0:string, 1:string}|PlaybookParseError
     */
    private function extractFrontmatter(string $content, string $sourcePath): array|PlaybookParseError
    {
        $content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content;
        $lines   = preg_split("/\r\n|\r|\n/", $content);
        if ($lines === false || count($lines) === 0) {
            return new PlaybookParseError(
                'Empty playbook file',
                1,
                'empty',
                $sourcePath
            );
        }

        if (trim($lines[0]) !== '---') {
            return new PlaybookParseError(
                'Playbook 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 PlaybookParseError(
                'Playbook 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 readRawContentFromDatabase(string $slug): ?string
    {
        if (!isset($GLOBALS['wpdb']) || !is_object($GLOBALS['wpdb'])) {
            return null;
        }
        $wpdb  = $GLOBALS['wpdb'];
        $prefix = property_exists($wpdb, 'prefix') ? $wpdb->prefix : '';
        $table  = $prefix . 'lolacore_playbooks';

        if (!Schema::tableExists('lolacore_playbooks')) {
            return null;
        }

        if (!method_exists($wpdb, 'prepare') || !method_exists($wpdb, 'get_row')) {
            return null;
        }

        $output = defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A';
        $suppress = $wpdb->suppress_errors();
        $row      = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
            $wpdb->prepare("SELECT content FROM {$table} WHERE playbook_slug = %s", $slug), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $output
        );
        $wpdb->suppress_errors($suppress);

        if (!is_array($row) || !isset($row['content'])) {
            return null;
        }

        $content = (string) $row['content'];
        return $content !== '' ? $content : null;
    }

    private function readRawContentFromFiles(string $slug): ?string
    {
        $dirs = [];
        $builtIn = $this->getBuiltInPath();
        if ($builtIn !== null) {
            $dirs[] = $builtIn;
        }
        foreach ($this->getAddonPaths() as $path) {
            $dirs[] = $path;
        }
        $fsPath = $this->getFilesystemPath();
        if ($fsPath !== null) {
            $dirs[] = $fsPath;
        }

        foreach ($dirs as $dir) {
            if (!is_dir($dir)) {
                continue;
            }

            $direct = rtrim($dir, "/\\") . DIRECTORY_SEPARATOR . $slug . '.md';
            if (is_readable($direct)) {
                $content = @file_get_contents($direct);
                if ($content !== false) {
                    return $content;
                }
            }

            foreach ($this->globPlaybookFiles($dir) as $path) {
                $parsed = $this->parsePlaybookFile($path, Playbook::ORIGIN_BUILT_IN);
                if ($parsed instanceof Playbook && $parsed->getSlug() === $slug) {
                    $content = @file_get_contents($path);
                    return $content !== false ? $content : null;
                }
            }
        }

        return null;
    }

    private function sanitizeSlug(string $slug): string
    {
        if (function_exists('sanitize_title')) {
            $sanitized = sanitize_title($slug);
            if (is_string($sanitized) && $sanitized !== '') {
                return $sanitized;
            }
        }

        $slug = mb_strtolower($slug);
        $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
        return trim($slug, '-');
    }

    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 . 'playbooks';
        }
        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-playbooks';
        }
        return null;
    }

    /**
     * @return array<string,string>
     */
    private function getAddonPaths(): array
    {
        if (!function_exists('apply_filters')) {
            return [];
        }
        $registered = apply_filters('lolacore_register_playbooks', []);
        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;
    }
}
