<?php
/**
 * Editor Settings Service
 *
 * Detects installed page builders/editors and manages editor settings.
 * Baseline known editors load from data/editors-registry.json (shared with frontend).
 *
 * **Registry extensions:** Filter `fleekdash_editor_registry` receives the associative array of editor
 * definitions (`editor_id => config`) after JSON is merged with the minimal built-in fallback. Return an
 * array of the same shape; invalid entries are dropped. Empty or invalid filter output keeps the
 * pre-filter array. See `EditorSettingsRegistry::getDefinitions()`.
 *
 * @package FleekDash\Services
 * @since 2.4.3
 */

namespace FleekDash\Services;

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

class EditorSettingsService implements EditorSettingsServiceInterface
{
    public const OPTION_KEY = 'fleekdash_editor_settings';

    private const CACHE_GROUP = 'fleekdash_editor';

    private const CACHE_TTL_DETECTED = 300;

    private const CACHE_TTL_POST_TYPES = 300;

    private ?EditorSettingsIncomingMerger $incomingMerger = null;

    /**
     * Build detected editor entry
     */
    private function buildEditorEntry(string $id, array $config, bool $builtin = false): array
    {
        $entry = [
            'id' => $id,
            'name' => $config['name'],
            'is_active' => true,
            'edit_url_template' => $config['edit_url_template'],
        ];
        if ($builtin) {
            $entry['builtin'] = true;
        }
        return $entry;
    }

    /**
     * Non-global cache key scoped by blog (multisite-safe).
     */
    private static function editorListCacheKey(string $name): string
    {
        $blogId = \function_exists('get_current_blog_id') ? (int) \get_current_blog_id() : 1;

        return 'fd_editor_' . $blogId . '_' . $name;
    }

    /**
     * Clear cached editor detection and post-type lists (e.g. after plugin/theme changes).
     */
    public static function bustEditorCaches(): void
    {
        \wp_cache_delete(self::editorListCacheKey('detected'), self::CACHE_GROUP);
        \wp_cache_delete(self::editorListCacheKey('post_types_raw'), self::CACHE_GROUP);
        \wp_cache_delete(self::editorListCacheKey('post_types'), self::CACHE_GROUP);
    }

    /**
     * Register hooks to invalidate editor list caches when site plugins or theme change.
     */
    public static function registerEditorCachesInvalidationHooks(): void
    {
        \add_action('activated_plugin', [self::class, 'bustEditorCaches']);
        \add_action('deactivated_plugin', [self::class, 'bustEditorCaches']);
        \add_action('after_switch_theme', [self::class, 'bustEditorCaches']);
    }

    /**
     * Get detected editors (installed and active)
     *
     * @return array{id: string, name: string, is_active: bool, edit_url_template: string, builtin?: bool}[]
     */
    public function getDetectedEditors(): array
    {
        $cacheKey = self::editorListCacheKey('detected');
        $cached = \wp_cache_get($cacheKey, self::CACHE_GROUP);
        if ($cached !== false && \is_array($cached)) {
            return $cached;
        }

        $detected = $this->computeDetectedEditors(
            EditorPluginActivationHelper::listActivePluginFilePaths(),
        );

        \wp_cache_set($cacheKey, $detected, self::CACHE_GROUP, self::CACHE_TTL_DETECTED);

        return $detected;
    }

    /**
     * @param list<string> $active_plugin_files
     * @return array{id: string, name: string, is_active: bool, edit_url_template: string, builtin?: bool}[]
     */
    private function computeDetectedEditors(array $active_plugin_files): array
    {
        $detected = [];
        foreach (EditorSettingsRegistry::getTuples() as $id => $tuple) {
            $config = EditorSettingsRegistry::normalizeEditorConfig($tuple);
            if ($config['builtin']) {
                $detected[] = $this->buildEditorEntry($id, $config, true);
                continue;
            }

            $patterns = $config['plugin_patterns'] ?? [];
            if (
                $patterns === []
                || !EditorPluginActivationHelper::matchesAnyConfiguredPrefix(
                    $patterns,
                    $active_plugin_files,
                )
            ) {
                continue;
            }

            $detected[] = $this->buildEditorEntry($id, $config);
        }

        return $detected;
    }

    private function getIncomingMerger(): EditorSettingsIncomingMerger
    {
        if ($this->incomingMerger === null) {
            $this->incomingMerger = new EditorSettingsIncomingMerger(
                fn (string $url): bool => $this->isSafeRelativeUrl($url),
            );
        }

        return $this->incomingMerger;
    }

    /**
     * Validate that URL is a safe relative path (prevents open redirect / XSS).
     *
     * Keep rules aligned with editorSettingsAPI.isSafeRelativeUrl() in
     * src/__wordpress-bridge__/api/editor-settings.ts. Security fixes belong in both places;
     * server-side validation is authoritative for persisted data.
     *
     * Protected (not private) so PHPUnit can cover this via a test subclass without reflection.
     */
    protected function isSafeRelativeUrl(string $url): bool
    {
        $url = \trim($url);

        return $url !== ''
            && $url[0] === '/'
            && !(\strlen($url) > 1 && $url[1] === '/')
            && \strpos($url, '://') === false
            && !$this->urlContainsDangerousScheme($url);
    }

    /**
     * Substring checks for dangerous schemes; paired with TS dangerous-protocol loop in editor-settings.ts.
     */
    private function urlContainsDangerousScheme(string $url): bool
    {
        $lower = \strtolower($url);
        $danger = ['javascript:', 'data:', 'vbscript:'];
        foreach ($danger as $d) {
            if (\strpos($lower, $d) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Build edit URL for a post (returns empty string if URL is unsafe).
     *
     * @param array<int, array{id: string, name: string, is_active: bool, edit_url_template: string, builtin?: bool}>|null $detected_editors Pre-built list from getDetectedEditors() to avoid repeated detection in hot paths.
     */
    public function getEditUrl(
        string $editor_id,
        int $post_id,
        string $post_type = 'post',
        ?array $detected_editors = null,
    ): string {
        $editors = $detected_editors ?? $this->getDetectedEditors();
        foreach ($editors as $editor) {
            if ($editor['id'] === $editor_id) {
                $url = \str_replace('{post_id}', (string) $post_id, $editor['edit_url_template']);
                return $this->isSafeRelativeUrl($url) ? $url : '';
            }
        }

        $settings = $this->getSettings();
        foreach ($settings['custom_editors'] ?? [] as $custom) {
            if ($custom['id'] === $editor_id) {
                $url = \str_replace(['{post_id}', '{post_type}'], [(string) $post_id, $post_type], $custom['edit_url_template'] ?? '');
                return $this->isSafeRelativeUrl($url) ? $url : '';
            }
        }

        return '';
    }

    /**
     * Default shape for editor settings option (merge baseline).
     *
     * @return array<string, mixed>
     */
    private static function defaultEditorSettingsSchema(): array
    {
        return [
            'open_behavior' => 'fleek_window',
            'default_editor' => 'gutenberg',
            'per_post_type' => [],
            'custom_editors' => [],
            'editor_order' => [],
            'disable_gutenberg' => false,
            'disable_gutenberg_post_types' => [],
        ];
    }

    /**
     * Get current editor settings
     */
    public function getSettings(): array
    {
        $saved = \get_option(self::OPTION_KEY, []);
        if (!\is_array($saved)) {
            $saved = [];
        }

        return \array_merge(self::defaultEditorSettingsSchema(), $saved);
    }

    /**
     * Update editor settings (with input validation).
     *
     * WordPress update_option() returns false when the new value equals the stored value;
     * in that case persistence is still successful.
     */
    public function updateSettings(array $settings): bool
    {
        $merged = $this->getIncomingMerger()->mergeInto($this->getSettings(), $settings);
        $updated = (bool) \call_user_func_array('\update_option', [self::OPTION_KEY, $merged, false]);
        if ($updated) {
            return true;
        }
        $raw = \get_option(self::OPTION_KEY);
        if (!\is_array($raw)) {
            return false;
        }
        $normalized = \array_merge(self::defaultEditorSettingsSchema(), $raw);

        return $normalized == $merged;
    }

    /**
     * Default post types excluded from editor settings (internal, non-editable).
     * Integrations may add or replace via {@see getExcludedPostTypes()}.
     */
    private const DEFAULT_EXCLUDED_POST_TYPES = [
        'attachment',
        'revision',
        'nav_menu_item',
        'wp_template',
        'wp_template_part',
        'wp_block',
        'wp_navigation',
        'acf-field-group',
        'acf-field',
        'custom_css',
        'customize_changeset',
        'oembed_cache',
        'user_request',
    ];

    /**
     * Post type slugs excluded from the editor settings post-type picker.
     *
     * Filter: `fleekdash_excluded_post_types` — pass string[] of slugs; non-string entries are skipped.
     *
     * @return list<string>
     */
    private function getExcludedPostTypes(): array
    {
        $defaults = self::DEFAULT_EXCLUDED_POST_TYPES;
        $filtered = \apply_filters('fleekdash_excluded_post_types', $defaults);
        if (!\is_array($filtered)) {
            return $defaults;
        }
        $out = [];
        foreach ($filtered as $slug) {
            if (!\is_string($slug)) {
                continue;
            }
            $slug = \trim($slug);
            if ($slug !== '') {
                $out[] = $slug;
            }
        }

        return \array_values(\array_unique($out));
    }

    /**
     * All public post types with show_ui=true (cached). Exclusions are applied after cache
     * so `fleekdash_excluded_post_types` always runs without stale filtered lists.
     *
     * @return array<int, array{value: string, label: string}>
     */
    private function getAllUiPostTypeOptions(): array
    {
        $cacheKey = self::editorListCacheKey('post_types_raw');
        $cached = \wp_cache_get($cacheKey, self::CACHE_GROUP);
        if ($cached !== false && \is_array($cached)) {
            return $cached;
        }

        $post_types = \get_post_types(['show_ui' => true], 'objects');
        $result = [];

        foreach ($post_types as $post_type) {
            $name = $post_type->name;
            $label = $post_type->labels->singular_name ?? $post_type->label ?? $name;
            $label = (\is_string($label) && \trim($label) !== '')
                ? \trim($label)
                : \ucwords(\str_replace(['-', '_'], ' ', $name));
            $result[] = [
                'value' => $name,
                'label' => $label,
            ];
        }

        \wp_cache_set($cacheKey, $result, self::CACHE_GROUP, self::CACHE_TTL_POST_TYPES);

        return $result;
    }

    /**
     * Get all post types available for editor assignment (show_ui, editable)
     *
     * @return array<int, array{value: string, label: string}>
     */
    public function getAvailablePostTypes(): array
    {
        $excluded = $this->getExcludedPostTypes();
        $excludedSet = \array_fill_keys($excluded, true);
        $candidates = $this->getAllUiPostTypeOptions();
        $result = [];

        foreach ($candidates as $row) {
            $name = $row['value'];
            if (isset($excludedSet[$name])) {
                continue;
            }
            $result[] = $row;
        }

        \usort($result, static function ($a, $b) {
            $order = ['post', 'page', 'product'];
            $idxA = \array_search($a['value'], $order, true);
            $idxB = \array_search($b['value'], $order, true);
            $cmp = 0;
            if ($idxA !== false && $idxB !== false) {
                $cmp = $idxA <=> $idxB;
            } elseif ($idxA !== false) {
                $cmp = -1;
            } elseif ($idxB !== false) {
                $cmp = 1;
            } else {
                $cmp = \strcasecmp($a['label'], $b['label']);
            }
            return $cmp;
        });

        return $result;
    }

    /**
     * Get full editor settings response (for API)
     */
    public function getFullResponse(): array
    {
        $settings = $this->getSettings();
        $detected = $this->getDetectedEditors();
        $registry = EditorSettingsRegistry::getDefinitions();
        $tuples = EditorSettingsRegistry::getTuples();
        $known = [];
        foreach (\array_keys($tuples) as $id) {
            $def = isset($registry[$id]) && \is_array($registry[$id]) ? $registry[$id] : [];
            $install = $def['install_url'] ?? null;
            $kind = isset($def['install_kind']) && \is_string($def['install_kind'])
                ? $def['install_kind']
                : 'plugin';
            if (!\in_array($kind, ['plugin', 'theme'], true)) {
                $kind = 'plugin';
            }
            $known[$id] = [
                'install_url' => (\is_string($install) && $install !== '') ? $install : null,
                'is_frontend' => !empty($def['is_frontend']),
                'install_kind' => $kind,
            ];
        }

        return [
            'settings' => $settings,
            'detected_editors' => $detected,
            'known_editors' => \array_keys($tuples),
            'known_editors_info' => $known,
            'available_post_types' => $this->getAvailablePostTypes(),
        ];
    }

    /**
     * Late priority so FleekDash can override plugins that disable the block editor (e.g. Classic Editor)
     * while still honoring per–post-type classic lists when Classic mode is enabled in FleekDash settings.
     */
    private const USE_BLOCK_EDITOR_FILTER_PRIORITY = 1000;

    /**
     * Register Disable Gutenberg filters (call from plugins_loaded or init).
     */
    public static function registerDisableGutenbergFilter(): void
    {
        \add_filter(
            'use_block_editor_for_post_type',
            static function ($use_block_editor, $post_type): bool {
                return self::resolveUseBlockEditorForPostType(
                    (bool) $use_block_editor,
                    (string) $post_type,
                );
            },
            self::USE_BLOCK_EDITOR_FILTER_PRIORITY,
            2,
        );

        \add_filter(
            'use_block_editor_for_post',
            static function ($use_block_editor, $post): bool {
                if (!($post instanceof \WP_Post)) {
                    return (bool) $use_block_editor;
                }

                return self::resolveUseBlockEditorForPostType(
                    (bool) $use_block_editor,
                    (string) $post->post_type,
                );
            },
            self::USE_BLOCK_EDITOR_FILTER_PRIORITY,
            2,
        );
    }

    private static function resolveUseBlockEditorForPostType(
        bool $use_block_editor,
        string $post_type,
    ): bool {
        $decision = self::fleekdashBlockEditorDecisionForPostType($post_type);
        if ($decision === null) {
            return $use_block_editor;
        }

        return $decision;
    }

    /**
     * @return bool|null null = keep value from earlier filters
     */
    private static function fleekdashBlockEditorDecisionForPostType(string $post_type): ?bool
    {
        $raw = \get_option(self::OPTION_KEY, []);
        $out = null;
        if (\is_array($raw) && !empty($raw['disable_gutenberg'])) {
            $disabled_types = $raw['disable_gutenberg_post_types'] ?? [];
            if (\is_array($disabled_types) && $disabled_types !== []) {
                if (
                    \in_array($post_type, $disabled_types, true)
                    || \in_array('*', $disabled_types, true)
                ) {
                    $out = false;
                } elseif (
                    \function_exists('post_type_supports')
                    && \post_type_supports($post_type, 'editor')
                ) {
                    $out = true;
                }
            }
        }

        return $out;
    }
}
