<?php

/**
 * Gutenberg Integration hooks.
 *
 * Registers the `arraysubs/content-restricted` block and applies the gating at
 * render time by delegating to the already-registered [arraysubs_restrict] /
 * [arraysubs_visibility] shortcode callbacks (with prerendered="true"). No
 * restriction logic is reimplemented here — this is the Gutenberg twin of
 * ElementorIntegration\Services\Hooks::applyRestriction().
 *
 * Build note: only the editor JS/CSS bundle is compiled to public/build (manual
 * webpack entry). block.json + render.php stay in this feature's block/ source
 * directory, and register_block_type() points there. The editor script/style are
 * referenced from block.json as already-registered handle strings, so the build
 * never has to copy block.json/render.php into public/build.
 *
 * @package ArraySubs\Features\GutenbergIntegration\Services
 */

namespace ArraySubs\Features\GutenbergIntegration\Services;

defined('ABSPATH') || exit;

use ArraySubs\Supports\Config;

/**
 * Class Hooks
 */
class Hooks
{
    /**
     * Auto-load flag for AbstractLoader.
     *
     * @var bool
     */
    public static $loadable = true;

    /**
     * Editor script handle (referenced by block.json `editorScript`).
     *
     * @var string
     */
    const SCRIPT_HANDLE = 'arraysubs-content-restricted-editor';

    /**
     * Editor style handle (referenced by block.json `editorStyle`).
     *
     * @var string
     */
    const STYLE_HANDLE = 'arraysubs-content-restricted-editor';

    /**
     * Block name (must match block.json `name`).
     *
     * @var string
     */
    const BLOCK_NAME = 'arraysubs/content-restricted';

    /**
     * Hooks constructor.
     */
    public function __construct()
    {
        add_filter('block_categories_all', [$this, 'registerBlockCategory']);
        add_action('init', [$this, 'registerBlock']);
    }

    /**
     * Add the "ArraySubs" block category (so the block has a home in the inserter).
     *
     * @param array $categories Existing block categories.
     * @return array
     */
    public function registerBlockCategory($categories): array
    {
        if (!is_array($categories)) {
            return $categories;
        }

        foreach ($categories as $category) {
            if (isset($category['slug']) && $category['slug'] === 'arraysubs') {
                return $categories; // Already present.
            }
        }

        array_unshift($categories, [
            'slug'  => 'arraysubs',
            'title' => esc_html__('ArraySubs', 'arraysubs'),
            'icon'  => null,
        ]);

        return $categories;
    }

    /**
     * Register the editor assets and the block type.
     *
     * @return void
     */
    public function registerBlock(): void
    {
        if (!function_exists('register_block_type')) {
            return;
        }

        $block_dir   = dirname(__DIR__) . '/block';
        $build_url   = Config::get('plugin.public_url') . 'build/gutenberg/content-restricted';
        $build_path  = Config::get('plugin.public_path') . 'build/gutenberg/content-restricted';
        $asset_file  = $build_path . '.asset.php';

        // Dependencies + version come from the webpack-generated asset file. Fall
        // back to a sensible dependency set if the bundle has not been built yet.
        $asset = file_exists($asset_file)
            ? require $asset_file
            : [
                'dependencies' => [
                    'react-jsx-runtime',
                    'wp-blocks',
                    'wp-block-editor',
                    'wp-components',
                    'wp-element',
                    'wp-i18n',
                    'wp-api-fetch',
                    'wp-url',
                ],
                'version' => Config::get('plugin.version'),
            ];

        wp_register_script(
            self::SCRIPT_HANDLE,
            $build_url . '.js',
            $asset['dependencies'] ?? [],
            $asset['version'] ?? Config::get('plugin.version'),
            true
        );

        // The editor needs the REST select-options path plus the static option
        // lists (status + roles) that Elementor builds server-side.
        wp_localize_script(self::SCRIPT_HANDLE, 'ArraySubsGutenberg', [
            'restPath' => '/arraysubs/v1/members-access/select-options',
            'statuses' => self::getStatusOptions(),
            'roles'    => self::getRoleOptions(),
        ]);

        // Editor CSS is emitted next to the JS bundle when present.
        if (file_exists($build_path . '.css')) {
            wp_register_style(
                self::STYLE_HANDLE,
                $build_url . '.css',
                [],
                $asset['version'] ?? Config::get('plugin.version')
            );

            // editorStyle is referenced as a handle string (not a `file:` path),
            // so WordPress won't auto-load the build's -rtl.css sibling. Wire it
            // up explicitly for RTL locales.
            if (file_exists($build_path . '-rtl.css')) {
                wp_style_add_data(self::STYLE_HANDLE, 'rtl', 'replace');
            }
        }

        // block.json references the handles above (editorScript/editorStyle) and
        // render.php (which sits alongside it in the source dir).
        register_block_type($block_dir);

        // Load JS translations for the editor strings.
        wp_set_script_translations(
            self::SCRIPT_HANDLE,
            'arraysubs',
            Config::get('plugin.path') . 'languages'
        );
    }

    /**
     * Gate already-rendered inner-block HTML using the existing shortcodes.
     *
     * Builds shortcode attributes from block attributes and invokes the
     * registered shortcode callback directly (against the global
     * $shortcode_tags), passing the inner HTML as the second argument with
     * prerendered="true". Reusing the live callback — rather than building a
     * shortcode string and running do_shortcode() — keeps inner content that
     * contains literal "[" characters from being mis-parsed, exactly as
     * ElementorIntegration\Services\Hooks::callShortcode() does.
     *
     * @param array  $a       Block attributes.
     * @param string $content Already-rendered inner-block HTML.
     * @return string
     */
    public static function gate(array $a, string $content): string
    {
        $mode = (($a['mode'] ?? 'restrict') === 'visibility') ? 'visibility' : 'restrict';

        if ($mode === 'visibility') {
            $show = $a['visShow'] ?? 'logged_in';

            return self::callShortcode('arraysubs_visibility', [
                'show'        => in_array($show, ['logged_in', 'logged_out'], true) ? $show : 'logged_in',
                // Defence-in-depth: authors with unfiltered_html bypass save-time
                // KSES, so sanitize the fallback before it is echoed.
                'fallback'    => wp_kses_post((string) ($a['visFallback'] ?? '')),
                // The inner HTML is already block-rendered by WordPress.
                'prerendered' => 'true',
            ], $content);
        }

        $feature = self::featureAttributes($a);

        $status     = self::toCsv($a['status'] ?? '');
        $products   = self::toCsv($a['products'] ?? '');
        $variations = self::toCsv($a['variations'] ?? '');
        $purchased  = self::toCsv($a['purchased'] ?? '');
        $roles      = self::toCsv($a['roles'] ?? '');
        $lifetime   = (isset($a['lifetimeSpent']) && $a['lifetimeSpent'] !== '' && $a['lifetimeSpent'] !== null)
            ? (string) $a['lifetimeSpent']
            : '';

        // A restricted block with no real condition is "no restriction". Don't
        // let the login_required default silently block logged-out visitors —
        // mirror the shortcode's own empty-rules semantics.
        $has_rule = ($status !== '' || $products !== '' || $variations !== '' || $purchased !== ''
            || $lifetime !== '' || $feature['feature'] !== '' || $roles !== '');

        $atts = [
            'status'         => $status,
            'products'       => $products,
            'variations'     => $variations,
            'purchased'      => $purchased,
            'lifetime_spent' => $lifetime,
            'feature'        => $feature['feature'],
            'feature_min'    => $feature['feature_min'],
            'feature_op'     => $feature['feature_op'],
            'aggregation'    => $feature['aggregation'],
            'roles'          => $roles,
            'condition'      => (($a['condition'] ?? 'and') === 'or') ? 'or' : 'and',
            'message'        => (string) ($a['message'] ?? ''),
            // Force empty: redirect-on-deny is unsafe at render time (output has
            // already started inside the_content). Matches Elementor.
            'redirect'       => '',
            'login_required' => ($has_rule && !empty($a['loginRequired'])) ? 'true' : 'false',
            'show_to_admins' => !empty($a['showToAdmins']) ? 'true' : 'false',
            // The inner HTML is already block-rendered by WordPress.
            'prerendered'    => 'true',
        ];

        return self::callShortcode('arraysubs_restrict', $atts, $content);
    }

    /**
     * Translate the guided "How to check it" mode into the shortcode's raw
     * feature attributes. Ports ElementorIntegration's featureAttributes().
     *
     * @param array $a Block attributes.
     * @return array{feature:string,feature_min:string,feature_op:string,aggregation:string}
     */
    private static function featureAttributes(array $a): array
    {
        $feature = trim((string) ($a['feature'] ?? ''));

        if ($feature === '') {
            return ['feature' => '', 'feature_min' => '', 'feature_op' => '>=', 'aggregation' => 'sum'];
        }

        $match  = $a['featureMatch'] ?? 'has';
        $amount = (string) ($a['featureValue'] ?? '');

        switch ($match) {
            case 'min_total':
                return ['feature' => $feature, 'feature_min' => ($amount !== '' ? $amount : '1'), 'feature_op' => '>=', 'aggregation' => 'sum'];

            case 'min_single':
                return ['feature' => $feature, 'feature_min' => ($amount !== '' ? $amount : '1'), 'feature_op' => '>=', 'aggregation' => 'max'];

            case 'custom':
                return [
                    'feature'     => $feature,
                    'feature_min' => $amount,
                    'feature_op'  => (string) ($a['featureOp'] ?? '>='),
                    'aggregation' => (string) ($a['aggregation'] ?? 'sum'),
                ];

            case 'has':
            default:
                return ['feature' => $feature, 'feature_min' => '1', 'feature_op' => '>=', 'aggregation' => 'any'];
        }
    }

    /**
     * Invoke a registered shortcode callback directly with an attributes array.
     *
     * @param string $tag     Shortcode tag.
     * @param array  $atts    Attributes.
     * @param string $content Inner content.
     * @return string
     */
    private static function callShortcode(string $tag, array $atts, string $content): string
    {
        global $shortcode_tags;

        if (isset($shortcode_tags[$tag]) && is_callable($shortcode_tags[$tag])) {
            return (string) call_user_func($shortcode_tags[$tag], $atts, $content, $tag);
        }

        return $content;
    }

    /**
     * Subscription status options (short slugs the shortcode expects), shaped as
     * { value, label } for the editor SelectControl. Ports Elementor's
     * getStatusOptions().
     *
     * @return array<int,array{value:string,label:string}>
     */
    private static function getStatusOptions(): array
    {
        $options = [];

        if (function_exists('arraysubs_get_subscription_statuses')) {
            foreach (arraysubs_get_subscription_statuses() as $slug => $label) {
                $short = preg_replace('/^arraysubs-/', '', $slug);
                $options[] = ['value' => $short, 'label' => $label];
            }
        }

        return $options;
    }

    /**
     * WordPress role options, shaped as { value, label } for the editor
     * SelectControl. Ports Elementor's getRoleOptions().
     *
     * @return array<int,array{value:string,label:string}>
     */
    private static function getRoleOptions(): array
    {
        $options = [];

        if (function_exists('wp_roles')) {
            foreach (wp_roles()->get_names() as $slug => $label) {
                $options[] = ['value' => $slug, 'label' => $label];
            }
        }

        return $options;
    }

    /**
     * Normalise a block attribute (array or scalar) to a comma-separated string.
     *
     * @param mixed $value Attribute value.
     * @return string
     */
    private static function toCsv($value): string
    {
        if (is_array($value)) {
            $value = array_filter(array_map('strval', $value), static function ($v) {
                return $v !== '';
            });

            return implode(',', $value);
        }

        return (string) $value;
    }
}
