<?php

namespace Storeplugin\PDFInvoice\Framework\Abstracts;

defined('ABSPATH') || exit;

use Storeplugin\PDFInvoice\Framework\Contracts\SettingInterface;
use Storeplugin\PDFInvoice\Framework\Fields\FieldValidator;
use Storeplugin\PDFInvoice\Framework\Fields\Types\RepeaterField;
use Storeplugin\PDFInvoice\Framework\Forms\Internals\Panel;
use Storeplugin\PDFInvoice\Framework\Forms\Internals\Tab;
use Storeplugin\PDFInvoice\Framework\Rest\SettingOptionRegistrar;
use Storeplugin\PDFInvoice\Framework\Utils\FieldState;
use Storeplugin\PDFInvoice\Config;
use Storeplugin\PDFInvoice\Asset;

/**
 * Abstract base class for settings panels.
 *
 * Provides common functionality for building WordPress admin settings pages.
 * Concrete implementations must provide their own page rendering logic.
 */
abstract class AbstractSetting implements SettingInterface
{
    protected $tabs = [];
    protected $groups = [];
    protected $pageTitle;
    protected $menuTitle;
    protected $slug;
    protected $optionName;
    protected $capability;
    protected $icon;
    protected $position;
    protected $renderCallback = null;
    protected static $panelSlugs = [];
    protected static $optionNames = [];
    protected static $optionNamesDirty = false;
    protected static $optionNamesPersistHookAdded = false;
    protected $hookSuffix = '';

    /**
     * Menu type: 'menu' for top-level, 'submenu' for submenu
     */
    protected $menuType = 'menu';

    /**
     * Parent slug for submenu registration
     */
    protected $parentSlug = null;

    /**
     * Pending group title for builder pattern
     */
    protected $groupTitle = '';

    /**
     * Pending group description for builder pattern
     */
    protected $groupDescription = '';

    /**
     * Cached flattened group definitions for this request.
     *
     * @var array<int, array>|null
     */
    protected $allGroupsCache = null;

    /**
     * Cached flattened field definitions for this request.
     *
     * @var array<int, array>|null
     */
    protected $allFieldsCache = null;

    /**
     * Get the option key (used for database storage, nonces, and POST data)
     *
     * @return string
     */
    protected function getOptionKey()
    {
        return $this->optionName;
    }

    /**
     * Get the option key for REST registration.
     *
     * @internal Used by framework REST registrars.
     * @return string
     */
    public function restOptionName(): string
    {
        return (string) $this->getOptionKey();
    }

    /**
     * Get the human-readable label for REST registration.
     *
     * @internal Used by framework REST registrars.
     * @return string
     */
    public function restLabel(): string
    {
        return (string) $this->pageTitle;
    }

    /**
     * Get the REST description for the registered option.
     *
     * @internal Used by framework REST registrars.
     * @return string
     */
    public function restDescription(): string
    {
        return (string) $this->menuTitle;
    }

    /**
     * Load option values for this settings page.
     *
     * Extension point for settings pages that store values in custom backends.
     *
     * @return array<string, mixed>
     */
    protected function loadOptionValues(): array
    {
        $options = \get_option($this->getOptionKey(), []);
        return is_array($options) ? $options : [];
    }

    /**
     * Persist option values for this settings page.
     *
     * Extension point for settings pages that store values in custom backends.
     *
     * @param array<string, mixed> $newOptions     Sanitized values to persist.
     * @param array<string, mixed> $currentOptions Previously loaded values.
     * @return void
     */
    protected function persistOptionValues(array $newOptions, array $currentOptions): void
    {
        if ($newOptions !== $currentOptions) {
            \update_option($this->getOptionKey(), $newOptions);
        }
    }

    /**
     * Invalidate cached structure payloads after builder mutations.
     *
     * @return void
     */
    protected function invalidateStructureCache(): void
    {
        $this->allGroupsCache = null;
        $this->allFieldsCache = null;
    }

    /**
     * Get per-panel AJAX action used to load deferred sections.
     *
     * @return string
     */
    protected function getSectionLoadAjaxAction(): string
    {
        return Config::ajax('load_setting_section_') . \sanitize_key($this->slug);
    }

    /**
     * Get nonce action for deferred section loading.
     *
     * @return string
     */
    protected function getSectionLoadNonceAction(): string
    {
        return $this->getOptionKey() . '_load_section';
    }

    /**
     * Constructor.
     *
     * @param string $pageTitle   Page title
     * @param string $menuTitle   Menu title
     * @param string $slug        Menu slug
     * @param string $optionName  Option key used for storage
     * @param string $capability  Capability required to access the page
     * @param string $icon        Icon for the menu item
     * @param int    $position    Position of the menu item
     */
    protected function __construct(
        string $pageTitle,
        string $menuTitle,
        string $slug,
        string $optionName,
        string $capability = 'manage_options',
        string $icon = 'dashicons-admin-generic',
        int $position = 2
    ) {
        $slug = \sanitize_key($slug);
        if ($slug === '') {
            throw new \InvalidArgumentException('Settings panel slug cannot be empty.');
        }

        $optionName = \sanitize_key($optionName);
        if ($optionName === '') {
            throw new \InvalidArgumentException('Settings panel optionName cannot be empty.');
        }
        if (static::isRegisteredOptionName($optionName)) {
            if (\function_exists('_doing_it_wrong')) {
                \_doing_it_wrong(
                    __METHOD__,
                    \sprintf(
                        'Duplicate settings optionName "%s" detected. Use a unique optionName per settings page to avoid options collisions.',
                        esc_html($optionName)
                    ),
                    esc_html('2.0.1')
                );
            }

            throw new \InvalidArgumentException(
                \sprintf(
                    'Settings panel optionName "%s" is already registered. Use a unique optionName per settings page.',
                    esc_html($optionName)
                )
            );
        }

        $this->pageTitle = $pageTitle;
        $this->menuTitle = $menuTitle;
        $this->slug = $slug;
        $this->optionName = $optionName;
        $this->capability = $capability;
        $this->icon = $icon;
        $this->position = $position;
        Config::registerAdminPageSlug($slug);
        static::registerSlug($slug);
        static::registerOptionName($optionName);
    }

    /**
     * Register a settings panel slug for asset loading.
     */
    protected static function registerSlug(string $slug): void
    {
        $slug = \sanitize_key($slug);
        if (!$slug) {
            return;
        }
        if (!in_array($slug, static::$panelSlugs, true)) {
            static::$panelSlugs[] = $slug;
        }
    }

    /**
     * Check if a slug belongs to a registered settings panel.
     */
    public static function isRegisteredSlug(string $slug): bool
    {
        $slug = \sanitize_key($slug);
        return $slug && in_array($slug, static::$panelSlugs, true);
    }

    /**
     * Register a settings panel option key.
     */
    protected static function registerOptionName(string $optionName): void
    {
        $optionName = \sanitize_key($optionName);
        if ($optionName === '') {
            return;
        }
        if (!in_array($optionName, static::$optionNames, true)) {
            static::$optionNames[] = $optionName;
            static::$optionNamesDirty = true;
        }
    }

    /**
     * Check if an option key belongs to a registered settings panel.
     */
    public static function isRegisteredOptionName(string $optionName): bool
    {
        $optionName = \sanitize_key($optionName);
        return $optionName !== '' && in_array($optionName, static::$optionNames, true);
    }

    /**
     * Get registered settings option keys for this request.
     *
     * @return array<int, string>
     */
    public static function getRegisteredOptionNames(): array
    {
        return array_values(static::$optionNames);
    }

    /**
     * Persist registered settings option keys into a DB registry.
     */
    public static function persistOptionNames(): void
    {
        if (!static::$optionNamesDirty) {
            return;
        }

        $registryKey = Config::option('setting_groups');
        $stored = \get_option($registryKey, []);
        $stored = is_array($stored) ? $stored : [];

        $sanitize = static function ($name): string {
            return \sanitize_key((string) $name);
        };

        $stored = array_values(array_filter(array_map($sanitize, $stored), static function (string $name): bool {
            return $name !== '';
        }));
        $stored = array_values(array_unique($stored));
        sort($stored, SORT_STRING);

        $registered = array_values(array_filter(array_map($sanitize, static::$optionNames), static function (string $name): bool {
            return $name !== '';
        }));
        $registered = array_values(array_unique($registered));

        $merged = array_values(array_unique(array_merge($stored, $registered)));
        sort($merged, SORT_STRING);

        if ($merged !== $stored) {
            if (!\update_option($registryKey, $merged, false)) {
                return;
            }
        }

        static::$optionNamesDirty = false;
    }

    /**
     * Register a one-time hook to persist option-name registry.
     */
    private static function registerOptionNamesPersistenceHook(): void
    {
        if (static::$optionNamesPersistHookAdded || !\is_admin()) {
            return;
        }

        \add_action('shutdown', [static::class, 'persistOptionNames']);
        static::$optionNamesPersistHookAdded = true;
    }

    /**
     * Add ads callback to render ads column
     *
     * @param callable $callback Callback function to render ads content
     * @return self
     */
    public function withSidebarCallback(callable $callback): self
    {
        $this->renderCallback = $callback;
        return $this;
    }

    /**
     * Register as a top-level admin menu
     *
     * @return self
     */
    public function withAdminMenu(): self
    {
        $this->menuType = 'menu';
        $this->parentSlug = null;
        return $this;
    }

    /**
     * Register as a submenu under a parent menu
     *
     * @param string $parentSlug Parent menu slug (e.g., 'options-general.php', 'tools.php', or custom slug)
     * @return self
     */
    public function withAdminSubmenu(string $parentSlug): self
    {
        $this->menuType = 'submenu';
        $this->parentSlug = $parentSlug;
        return $this;
    }

    /**
     * Register as a submenu under a post type menu.
     *
     * @param string $postType Post type slug.
     * @return self
     */
    public function withAdminSubmenuPostType(string $postType): self
    {
        $postType = \sanitize_key($postType);
        if (!$postType) {
            return $this;
        }

        $parentSlug = $postType === 'post' ? 'edit.php' : 'edit.php?post_type=' . $postType;
        return $this->withAdminSubmenu($parentSlug);
    }

    /**
     * Add a tab to the sidebar
     *
     * @param string         $id       Unique tab ID
     * @param string         $title    Display title
     * @param string         $icon     Dashicon slug
     * @param array|callable $sublinks Array of sublinks or callback function receiving Tab instance
     * @return self
     */
    public function addTab(string $id, string $title, string $icon, $sublinks = []): self
    {
        $tabId = \sanitize_title($id);
        if ($tabId === '') {
            return $this;
        }

        // Handle callback-based tab configuration
        if (is_callable($sublinks)) {
            $tab = Tab::create($tabId, $title, $icon);
            $sublinks($tab);
            $this->tabs[$tabId] = $tab->toArray();
            $this->invalidateStructureCache();
            return $this;
        }

        // Handle array-based tab configuration (existing behavior)
        $processedSublinks = [];

        foreach ($sublinks as $key => $value) {
            if (is_string($key)) {
                $description = '';
                $panels = [];

                if (is_array($value)) {
                    if (array_key_exists('panels', $value) || array_key_exists('description', $value)) {
                        $description = isset($value['description']) ? (string) $value['description'] : '';
                        $panels = isset($value['panels']) && is_array($value['panels']) ? $value['panels'] : [];
                    } else {
                        $panels = $value;
                    }
                }

                $processedSublinks[] = [
                    'id'     => \sanitize_title($key),
                    'title'  => $key,
                    'description' => $description,
                    'panels' => $panels,
                ];
            }
        }

        $this->tabs[$tabId] = [
            'id'       => $tabId,
            'title'    => $title,
            'icon'     => $icon,
            'sublinks' => $processedSublinks,
        ];
        $this->invalidateStructureCache();

        return $this;
    }

    /**
     * Set the title for the next group
     *
     * @param string $title Group title
     * @return self
     */
    public function withGroupTitle(string $title): self
    {
        $this->groupTitle = $title;
        return $this;
    }

    /**
     * Set the description for the next group
     *
     * @param string $description Group description
     * @return self
     */
    public function withGroupDescription(string $description): self
    {
        $this->groupDescription = $description;
        return $this;
    }

    /**
     * Add a group directly (without tab navigation)
     *
     * @param callable|array $config Callback function or array of sections
     * @return self
     */
    public function addGroup($config = []): self
    {
        $title = $this->groupTitle;
        $description = $this->groupDescription;

        // Reset pending values
        $this->groupTitle = '';
        $this->groupDescription = '';

        // Handle callback-based group configuration
        if (is_callable($config)) {
            $section = Panel::create($title, $description);
            $config($section);
            $this->groups[] = $section->toArray();
            $this->invalidateStructureCache();
            return $this;
        }

        // Handle array-based group configuration
        $this->groups[] = [
            'id'          => \sanitize_title($title),
            'title'       => $title,
            'description' => $description,
            'panels'      => $config,
        ];
        $this->invalidateStructureCache();

        return $this;
    }

    /**
     * Check if panel has tabs
     *
     * @return bool
     */
    public function hasTabs(): bool
    {
        return !empty($this->tabs);
    }

    /**
     * Flatten all tabs/sublinks into a single array of groups
     *
     * @return array
     */
    protected function getAllGroups(): array
    {
        if (is_array($this->allGroupsCache)) {
            return $this->allGroupsCache;
        }

        $groups = [];

        // Add groups from tabs
        foreach ($this->tabs as $tab) {
            foreach ($tab['sublinks'] as $sublink) {
                $groups[] = [
                    'id'     => $tab['id'] . '-' . $sublink['id'],
                    'title'  => $sublink['title'],
                    'description' => $sublink['description'] ?? '',
                    'panels' => $sublink['panels']
                ];
            }
        }

        // Add direct groups (tab-less)
        foreach ($this->groups as $group) {
            $groups[] = [
                'id'          => $group['id'],
                'title'       => $group['title'],
                'description' => $group['description'] ?? '',
                'panels'      => $group['panels']
            ];
        }

        $this->allGroupsCache = $groups;

        return $this->allGroupsCache;
    }

    /**
     * Flatten all fields once for save and validation routines.
     *
     * @return array<int, array>
     */
    protected function getAllFields(): array
    {
        if (is_array($this->allFieldsCache)) {
            return $this->allFieldsCache;
        }

        $fields = [];
        foreach ($this->getAllGroups() as $group) {
            $panels = isset($group['panels']) && is_array($group['panels']) ? $group['panels'] : [];
            foreach ($panels as $panel) {
                $panelFields = isset($panel['fields']) && is_array($panel['fields']) ? $panel['fields'] : [];
                foreach ($panelFields as $field) {
                    if (is_array($field)) {
                        $fields[] = $field;
                    }
                }
            }
        }

        $this->allFieldsCache = $fields;

        return $this->allFieldsCache;
    }

    /**
     * Get all configured fields for REST registration.
     *
     * @internal Used by framework REST registrars.
     * @return array<int, array>
     */
    public function restFields(): array
    {
        return $this->getAllFields();
    }

    /**
     * Resolve a group by its flattened ID.
     *
     * @param string $groupId Flattened group identifier.
     * @return array|null
     */
    protected function getGroupById(string $groupId): ?array
    {
        $groupId = \sanitize_key($groupId);
        if ($groupId === '') {
            return null;
        }

        foreach ($this->getAllGroups() as $group) {
            if (($group['id'] ?? '') === $groupId) {
                return $group;
            }
        }

        return null;
    }

    /**
     * Render a group's panel markup as HTML.
     *
     * @param array $group Flattened group payload.
     * @return string
     */
    protected function renderGroupPanelsHtml(array $group): string
    {
        $panels = isset($group['panels']) && is_array($group['panels']) ? $group['panels'] : [];
        $options = $this->loadOptionValues();

        \ob_start();
        $this->renderPanels($panels, $options);
        return (string) \ob_get_clean();
    }

    /**
     * Load deferred section content for tabbed settings pages.
     *
     * @internal WordPress callback method - do not call directly
     */
    public function loadGroupPanelsAjax(): void
    {
        if (!\current_user_can($this->capability)) {
            \wp_send_json_error([
                'message' => \__('You do not have permission to perform this action.', 'storeinvoice-print-pdf-invoices-and-packing-slips'),
            ], 403);
        }

        $nonce = isset($_POST['nonce']) ? \sanitize_text_field(\wp_unslash($_POST['nonce'])) : '';
        if (!$nonce || !\wp_verify_nonce($nonce, $this->getSectionLoadNonceAction())) {
            \wp_send_json_error([
                'message' => \__('Security check failed.', 'storeinvoice-print-pdf-invoices-and-packing-slips'),
            ], 403);
        }

        $groupId = isset($_POST['group_id']) ? \sanitize_key(\wp_unslash($_POST['group_id'])) : '';
        if ($groupId === '') {
            \wp_send_json_error([
                'message' => \__('Section ID is required.', 'storeinvoice-print-pdf-invoices-and-packing-slips'),
            ], 400);
        }

        $group = $this->getGroupById($groupId);
        if (!$group) {
            \wp_send_json_error([
                'message' => \__('Section not found.', 'storeinvoice-print-pdf-invoices-and-packing-slips'),
            ], 404);
        }

        \wp_send_json_success([
            'html' => $this->renderGroupPanelsHtml($group),
        ]);
    }

    /**
     * Build dashicons class names from a provided icon slug.
     *
     * @param mixed  $icon    Icon slug (with or without dashicons- prefix).
     * @param string $default Fallback icon slug.
     * @return string
     */
    protected function getDashiconClass($icon, string $default = 'admin-generic'): string
    {
        $icon = is_string($icon) ? trim($icon) : '';
        $icon = $icon !== '' ? $icon : $default;
        $icon = \sanitize_key($icon);

        if (strpos($icon, 'dashicons-') === 0) {
            $icon = substr($icon, 10);
        }

        return 'dashicons dashicons-' . $icon;
    }

    /**
     * Save settings
     *
     * @internal WordPress callback method - do not call directly
     */
    public function saveSettings(): void
    {
        if (!$this->isCurrentSettingsPageRequest()) {
            return;
        }

        $optionKey = $this->getOptionKey();
        if (isset($_POST[$optionKey . '_save']) && \check_admin_referer($optionKey . '_action', $optionKey . '_nonce')) {
            if (!\current_user_can($this->capability)) {
                return;
            }

            $options = $this->loadOptionValues();
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized per-field via sanitizeField() in the loop below.
            $rawInput = isset($_POST[$optionKey]) && is_array($_POST[$optionKey]) ? \wp_unslash($_POST[$optionKey]) : [];
            FieldValidator::clearErrors();
            $newOptions = $this->buildSanitizedOptionValues($rawInput, $options);

            if (FieldValidator::hasErrors()) {
                foreach (FieldValidator::getErrors() as $fieldId => $message) {
                    \add_settings_error(
                        Config::option('messages'),
                        Config::prefix() . '_validation_' . \sanitize_key((string) $fieldId),
                        $message,
                        'error'
                    );
                }
                FieldValidator::clearErrors();
                return;
            }

            $this->persistOptionValues($newOptions, $options);
            FieldValidator::clearErrors();
            \add_settings_error(Config::option('messages'), Config::prefix() . '_message', 'Settings Saved', 'updated');
        }
    }

    /**
     * Sanitize option payloads for REST-based setting updates.
     *
     * The settings REST API persists an entire option value at once, so invalid
     * fields fall back to the current stored value to preserve the framework's
     * existing save semantics.
     *
     * @internal Used as register_setting() sanitize callback.
     * @param mixed $value
     * @return array<string, mixed>
     */
    public function restSanitizeOptionValue($value): array
    {
        $rawInput = is_array($value) ? $value : [];
        $options = $this->loadOptionValues();
        FieldValidator::clearErrors();

        $sanitized = $this->buildSanitizedOptionValues($rawInput, $options);

        FieldValidator::clearErrors();

        return $sanitized;
    }

    /**
     * Build the option payload to persist from raw input.
     *
     * @param array<string, mixed> $rawInput
     * @param array<string, mixed> $options
     * @return array<string, mixed>
     */
    protected function buildSanitizedOptionValues(array $rawInput, array $options): array
    {
        $newOptions = [];

        foreach ($this->getAllFields() as $field) {
            $id = isset($field['id']) ? \sanitize_key((string) $field['id']) : '';
            if ($id === '') {
                continue;
            }
            $type = $field['type'] ?? 'text';
            $default = $field['default'] ?? '';
            $candidateValue = null;
            $hasCandidateValue = false;
            $shouldValidate = false;

            if ($this->isFieldLocked($field)) {
                $lockedValue = $this->resolveLockedFieldValue($field, $rawInput, $options);
                $candidateValue = $lockedValue['value'];
                $hasCandidateValue = $lockedValue['has_value'];
                $shouldValidate = !empty($lockedValue['should_validate']);
            } else {
                if (!array_key_exists($id, $rawInput)) {
                    if (in_array($type, ['checkbox', 'switch'], true) && array_key_exists('_nxtf_rendered_' . $id, $rawInput)) {
                        if (isset($field['options']) && is_array($field['options'])) {
                            $candidateValue = [];
                        } else {
                            $candidateValue = '0';
                        }
                        $hasCandidateValue = true;
                        $shouldValidate = true;
                    } elseif ($type === 'repeater' && array_key_exists('_nxtf_rendered_' . $id, $rawInput)) {
                        $candidateValue = [];
                        $hasCandidateValue = true;
                        $shouldValidate = true;
                    } else {
                        if (array_key_exists($id, $options)) {
                            $candidateValue = $options[$id];
                            $hasCandidateValue = true;
                        } elseif ($default !== '' && $default !== false) {
                            $candidateValue = $default;
                            $hasCandidateValue = true;
                        }
                    }
                } else {
                    if ($type === 'repeater' && is_array($rawInput[$id]) && !empty($field['fields'])) {
                        $currentRows = (array_key_exists($id, $options) && is_array($options[$id])) ? $options[$id] : [];
                        $candidateValue = $this->sanitizeRepeaterRows($rawInput[$id], $field['fields'], $currentRows);
                    } else {
                        $candidateValue = $this->sanitizeField($rawInput[$id], (string) $type);
                    }
                    $hasCandidateValue = true;
                    $shouldValidate = true;
                }
            }

            if (!$hasCandidateValue) {
                continue;
            }

            $validationField = $field;
            if (array_key_exists($id, $options)) {
                $validationField['_current_value'] = $options[$id];
            }
            $validationField['_input_context'] = $rawInput;

            if ($shouldValidate && !FieldValidator::validate($candidateValue, (string) $type, $validationField)) {
                continue;
            }

            $newOptions[$id] = $candidateValue;
        }

        return $newOptions;
    }

    /**
     * Ensure only the currently viewed settings panel processes saves.
     *
     * @return bool
     */
    private function isCurrentSettingsPageRequest(): bool
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading current admin page slug from the URL to determine if this settings panel should handle the save; no state change.
        $currentPage = isset($_GET['page']) ? \sanitize_key((string) \wp_unslash($_GET['page'])) : '';
        $currentSlug = \sanitize_key((string) $this->slug);

        return $currentPage !== '' && $currentSlug !== '' && $currentPage === $currentSlug;
    }

    /**
     * Recursive sanitization for array or string
     *
     * @param mixed  $value Field value
     * @param string $type  Field type
     * @return mixed
     */
    protected function sanitizeField($value, string $type = 'text')
    {
        if (is_array($value)) {
            return array_map(function ($item) use ($type) {
                return $this->sanitizeField($item, $type);
            }, $value);
        }

        if ($type === 'wysiwyg') {
            return \wp_kses_post($value);
        }

        if ($type === 'textarea') {
            return \sanitize_textarea_field($value);
        }

        return \sanitize_text_field($value);
    }

    /**
     * Sanitize repeater rows with per-sub-field type awareness.
     *
     * @param array $rows         Raw rows from POST data.
     * @param array $subFieldDefs Sub-field definitions from the repeater config.
     * @param array $currentRows  Previously saved repeater rows keyed by row index.
     * @return array Sanitized rows.
     */
    protected function sanitizeRepeaterRows(
        array $rows,
        array $subFieldDefs,
        array $currentRows = [],
        bool $allowLockedSubmittedValues = false
    ): array
    {
        // Pre-normalize subfield metadata once instead of per-row.
        $normalizedFields = [];
        foreach ($subFieldDefs as $subField) {
            if (!is_array($subField)) {
                continue;
            }

            $subId = \sanitize_key((string) ($subField['id'] ?? ''));
            if ($subId === '') {
                continue;
            }

            $normalizedFields[] = [
                'id'         => $subId,
                'type'       => (string) ($subField['type'] ?? 'text'),
                'label'      => $subField['label'] ?? $subId,
                'isLocked'   => $this->isFieldLocked($subField),
                'isToggle'   => in_array((string) ($subField['type'] ?? 'text'), ['checkbox', 'switch'], true),
                'toggleOff'  => isset($subField['options']) && is_array($subField['options']) ? [] : '0',
                'definition' => $subField,
            ];
        }

        $sanitized = [];
        $currentRowMap = $this->getRepeaterCurrentRowsByKey($currentRows);

        foreach ($rows as $rowIndex => $row) {
            if (!is_array($row)) {
                continue;
            }

            $sanitizedRow = [];
            $currentRow = $this->resolveCurrentRepeaterRow($row, $rowIndex, $currentRowMap);
            $rowNum = (int) $rowIndex + 1;

            foreach ($normalizedFields as $nf) {
                if ($nf['isLocked']) {
                    $lockedValue = $this->resolveLockedRepeaterSubFieldValue(
                        $nf['definition'],
                        $row,
                        $currentRow,
                        $allowLockedSubmittedValues
                    );
                    if (!$lockedValue['has_value']) {
                        continue;
                    }

                    if (!empty($lockedValue['should_validate'])) {
                        $validationField = $nf['definition'];
                        $validationField['id'] = $nf['id'] . '_row_' . $rowNum;
                        $validationField['label'] = $nf['label'] . ' (Row ' . $rowNum . ')';
                        if (array_key_exists($nf['id'], $currentRow)) {
                            $validationField['_current_value'] = $currentRow[$nf['id']];
                        }
                        $validationField['_input_context'] = $row;

                        FieldValidator::validate($lockedValue['value'], $nf['type'], $validationField);
                    }

                    $sanitizedRow[$nf['id']] = $lockedValue['value'];
                    continue;
                }

                if (array_key_exists($nf['id'], $row)) {
                    if ($nf['type'] === 'repeater' && is_array($row[$nf['id']]) && !empty($nf['definition']['fields'])) {
                        $currentNestedRows = [];
                        if (isset($currentRow[$nf['id']]) && is_array($currentRow[$nf['id']])) {
                            $currentNestedRows = $currentRow[$nf['id']];
                        }

                        $sanitizedValue = $this->sanitizeRepeaterRows(
                            $row[$nf['id']],
                            $nf['definition']['fields'],
                            $currentNestedRows,
                            $allowLockedSubmittedValues
                        );
                    } else {
                        $sanitizedValue = $this->sanitizeField($row[$nf['id']], $nf['type']);
                    }

                    // Validate the sanitized value per sub-field type.
                    $validationField = $nf['definition'];
                    $validationField['id'] = $nf['id'] . '_row_' . $rowNum;
                    $validationField['label'] = $nf['label'] . ' (Row ' . $rowNum . ')';
                    if (array_key_exists($nf['id'], $currentRow)) {
                        $validationField['_current_value'] = $currentRow[$nf['id']];
                    }
                    $validationField['_input_context'] = $row;
                    FieldValidator::validate($sanitizedValue, $nf['type'], $validationField);

                    $sanitizedRow[$nf['id']] = $sanitizedValue;
                } elseif ($nf['isToggle']) {
                    $sanitizedRow[$nf['id']] = $nf['toggleOff'];
                } elseif ($nf['type'] === 'repeater' && array_key_exists('_nxtf_rendered_' . $nf['id'], $row)) {
                    $sanitizedRow[$nf['id']] = [];
                }
            }

            if (!empty($sanitizedRow)) {
                $sanitized[] = $sanitizedRow;
            }
        }

        return $sanitized;
    }

    /**
     * Resolve the value to preserve for a locked repeater sub-field.
     *
     * Existing stored values win. For new rows, readonly fields can reuse their
     * submitted value while disabled fields fall back to an explicit default.
     *
     * @param array $field
     * @param array $row
     * @param array $currentRow
     * @return array{has_value:bool,value:mixed,should_validate:bool}
     */
    private function resolveLockedRepeaterSubFieldValue(
        array $field,
        array $row,
        array $currentRow,
        bool $allowSubmittedValue = false
    ): array
    {
        $id = \sanitize_key((string) ($field['id'] ?? ''));
        if ($id === '') {
            return ['has_value' => false, 'value' => null, 'should_validate' => false];
        }

        if (array_key_exists($id, $currentRow)) {
            return ['has_value' => true, 'value' => $currentRow[$id], 'should_validate' => false];
        }

        $isDisabled = FieldState::isTruthy($field['disabled'] ?? false);
        if (($allowSubmittedValue || !$isDisabled) && array_key_exists($id, $row)) {
            return [
                'has_value' => true,
                'value' => $this->sanitizeValueForFieldDefinition($field, $row[$id], $allowSubmittedValue),
                'should_validate' => !$isDisabled,
            ];
        }

        if (array_key_exists('default', $field)) {
            return [
                'has_value' => true,
                'value' => $this->sanitizeValueForFieldDefinition($field, $field['default'], $allowSubmittedValue),
                'should_validate' => false,
            ];
        }

        $implicitValue = FieldState::getImplicitValue(array_merge($field, ['_input_context' => $row]));
        $implicitValue['should_validate'] = false;

        return $implicitValue;
    }

    /**
     * Sanitize a value using the given field definition.
     *
     * @param array $field
     * @param mixed $value
     * @return mixed
     */
    private function sanitizeValueForFieldDefinition(array $field, $value, bool $allowLockedSubmittedValues = false)
    {
        $type = (string) ($field['type'] ?? 'text');

        if ($type === 'repeater' && is_array($value) && !empty($field['fields'])) {
            return $this->sanitizeRepeaterRows(
                $value,
                $this->getSanitizableRepeaterSubFields($field),
                [],
                $allowLockedSubmittedValues
            );
        }

        return $this->sanitizeField($value, $type);
    }

    /**
     * Resolve the value to keep for a locked top-level settings field.
     *
     * @param array $field
     * @param array $rawInput
     * @param array $options
     * @return array{has_value:bool,value:mixed,should_validate:bool}
     */
    private function resolveLockedFieldValue(array $field, array $rawInput, array $options): array
    {
        $id = \sanitize_key((string) ($field['id'] ?? ''));
        if ($id === '') {
            return ['has_value' => false, 'value' => null, 'should_validate' => false];
        }

        if (array_key_exists($id, $options)) {
            return ['has_value' => true, 'value' => $options[$id], 'should_validate' => false];
        }

        $type = (string) ($field['type'] ?? 'text');
        if ($type === 'repeater' && array_key_exists('default', $field)) {
            return [
                'has_value' => true,
                'value' => $this->sanitizeValueForFieldDefinition($field, $field['default'], true),
                'should_validate' => false,
            ];
        }

        $isDisabled = FieldState::isTruthy($field['disabled'] ?? false);
        if (!$isDisabled && array_key_exists($id, $rawInput)) {
            return [
                'has_value' => true,
                'value' => $this->sanitizeValueForFieldDefinition($field, $rawInput[$id]),
                'should_validate' => true,
            ];
        }

        if (array_key_exists('default', $field)) {
            return [
                'has_value' => true,
                'value' => $this->sanitizeValueForFieldDefinition($field, $field['default']),
                'should_validate' => false,
            ];
        }

        if (
            array_key_exists('_nxtf_rendered_' . $id, $rawInput)
            && in_array($type, ['checkbox', 'switch', 'repeater'], true)
        ) {
            $implicitValue = FieldState::getImplicitValue(array_merge($field, ['_input_context' => $rawInput]));
            $implicitValue['should_validate'] = false;

            return $implicitValue;
        }

        return ['has_value' => false, 'value' => null, 'should_validate' => false];
    }

    /**
     * Apply repeater-level lock semantics to child field definitions for save-time sanitization.
     *
     * @param array $field
     * @return array
     */
    private function getSanitizableRepeaterSubFields(array $field): array
    {
        $subFields = isset($field['fields']) && is_array($field['fields']) ? $field['fields'] : [];
        if ($subFields === []) {
            return [];
        }

        $isDisabled = FieldState::isTruthy($field['disabled'] ?? false);
        $isReadonly = !$isDisabled && FieldState::isTruthy($field['readonly'] ?? false);
        if (!$isDisabled && !$isReadonly) {
            return $subFields;
        }

        foreach ($subFields as $index => $subField) {
            if (!is_array($subField)) {
                continue;
            }

            $subFields[$index] = FieldState::applyRepeaterLockToSubField($subField, $isDisabled, $isReadonly);
        }

        return $subFields;
    }

    /**
     * Determine whether the field should be treated as immutable during save.
     *
     * @param array $field
     * @return bool
     */
    protected function isFieldLocked(array $field): bool
    {
        return FieldState::isLocked($field);
    }

    /**
     * Build a map of current repeater rows keyed by their stable row token.
     *
     * @param array $currentRows
     * @return array<string, array>
     */
    private function getRepeaterCurrentRowsByKey(array $currentRows): array
    {
        $rowMap = [];

        foreach ($currentRows as $rowIndex => $currentRow) {
            if (!is_array($currentRow)) {
                continue;
            }

            $rowMap[RepeaterField::buildRowKey($currentRow, (string) $rowIndex)] = $currentRow;
        }

        return $rowMap;
    }

    /**
     * Resolve the pre-save repeater row that matches the submitted row.
     *
     * @param array              $row
     * @param int|string         $rowIndex
     * @param array<string,array> $currentRowMap
     * @return array
     */
    private function resolveCurrentRepeaterRow(array $row, $rowIndex, array $currentRowMap): array
    {
        $rowKey = isset($row[RepeaterField::ROW_KEY_FIELD]) && is_scalar($row[RepeaterField::ROW_KEY_FIELD])
            ? (string) $row[RepeaterField::ROW_KEY_FIELD]
            : '';

        if ($rowKey !== '' && isset($currentRowMap[$rowKey]) && is_array($currentRowMap[$rowKey])) {
            return $currentRowMap[$rowKey];
        }

        $fallbackKey = RepeaterField::buildRowKey($row, (string) $rowIndex);

        return isset($currentRowMap[$fallbackKey]) && is_array($currentRowMap[$fallbackKey])
            ? $currentRowMap[$fallbackKey]
            : [];
    }

    /**
     * Initialize the admin menu
     *
     * @internal WordPress callback method - do not call directly
     */
    public function initMenu(): void
    {
        if ($this->menuType === 'submenu' && $this->parentSlug) {
            $this->hookSuffix = \add_submenu_page(
                $this->parentSlug,
                $this->pageTitle,
                $this->menuTitle,
                $this->capability,
                $this->slug,
                [$this, 'renderPage'],
                $this->position
            );
        } else {
            $this->hookSuffix = \add_menu_page(
                $this->pageTitle,
                $this->menuTitle,
                $this->capability,
                $this->slug,
                [$this, 'renderPage'],
                $this->icon,
                $this->position
            );
        }

        if ($this->hookSuffix) {
            // Run save and enqueue only when this specific settings page is loaded.
            \add_action('load-' . $this->hookSuffix, [$this, 'saveSettings']);
            \add_action('load-' . $this->hookSuffix, [$this, 'enqueuePanelAssets']);
        }
    }

    /**
     * Enqueue settings assets for this panel page.
     *
     * @internal WordPress callback method - do not call directly
     */
    public function enqueuePanelAssets(): void
    {
        $this->enqueueAssets();
    }

    /**
     * Render panels for a group
     *
     * @param array $panels Panels configuration array
     * @param array $options Current option values
     */
    abstract protected function renderPanels(array $panels, array $options): void;

    /**
     * Render the option panel page
     *
     * @internal WordPress callback method - do not call directly
     */
    abstract public function renderPage(): void;

    /**
     * Render the sidebar callback content
     */
    protected function renderSidebarCallback(): void
    {
        if (!$this->renderCallback || !is_callable($this->renderCallback)) {
            return;
        }

        call_user_func($this->renderCallback);
    }

    /**
     * Enqueue necessary assets for the settings panel
     *
     * @return void
     */
    protected function enqueueAssets(): void
    {
        Asset::registerFrameworkAssets();
        \wp_enqueue_style('dashicons');
        \wp_enqueue_style(Config::asset('framework'));
        \wp_enqueue_script(Config::asset('options'));
    }

    /**
     * Render the option panel
     *
     * @param int|null $priority Priority for the admin_menu hook. Lower numbers run earlier.
     *                      Parent menus should use lower priority than submenus.
     */
    public function render(?int $priority = null): void
    {
        if ($priority === null) {
            $priority = ($this->menuType === 'submenu') ? 20 : 10;
        }

        \add_action('admin_menu', [$this, 'initMenu'], $priority);
        \add_action('wp_ajax_' . $this->getSectionLoadAjaxAction(), [$this, 'loadGroupPanelsAjax']);
        if (\did_action('rest_api_init')) {
            $this->registerRestSetting();
        } else {
            \add_action('rest_api_init', [$this, 'registerRestSetting']);
        }
        static::registerOptionNamesPersistenceHook();
    }

    /**
     * Register this settings panel for REST exposure.
     *
     * @internal WordPress callback method - do not call directly
     * @return void
     */
    public function registerRestSetting(): void
    {
        SettingOptionRegistrar::register($this);
    }
}
