<?php

namespace Storeplugin\PDFInvoice\Framework\Abstracts;

defined('ABSPATH') || exit;

use Storeplugin\PDFInvoice\Framework\Contracts\MetaInterface;
use Storeplugin\PDFInvoice\Framework\Fields\FieldRenderer;
use Storeplugin\PDFInvoice\Framework\Fields\FieldValidator;
use Storeplugin\PDFInvoice\Framework\Fields\Types\RepeaterField;
use Storeplugin\PDFInvoice\Framework\Traits\SupportsRegisteredPostMeta;
use Storeplugin\PDFInvoice\Framework\Utils\FieldState;
use Storeplugin\PDFInvoice\Config;
use Storeplugin\PDFInvoice\Asset;

/**
 * Abstract base class for meta boxes.
 *
 * Provides common functionality for building WordPress meta boxes using the
 * Template Method pattern. Concrete implementations supply screen registration
 * and value reading.
 */
abstract class AbstractMeta implements MetaInterface
{
    use SupportsRegisteredPostMeta;

    protected $id;
    protected $title;
    protected $screens = [];
    protected $context;
    protected $priority;
    protected $optionGroup;
    protected $sections = [];

    /**
     * Pending description consumed by the next addSection() call.
     *
     * @var string
     */
    protected $sectionDescription = '';

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

    /**
     * Constructor.
     *
     * @param string      $id          Unique metabox ID
     * @param string      $title       Display title
     * @param string      $optionGroup Prefix for form field names (required)
     * @param array       $screens     Post types / screens this metabox targets
     * @param string      $context     Where the box should show up ('normal', 'side', 'advanced')
     * @param string      $priority    Priority within the context ('default', 'high', 'low')
     */
    protected function __construct(string $id, string $title, string $optionGroup, array $screens = [], string $context = 'normal', string $priority = 'default')
    {
        $this->id = $id;
        $this->title = $title;
        $this->setScreens($screens);
        $this->context = $context;
        $this->priority = $priority;

        $optionGroup = \sanitize_key($optionGroup);
        if ($optionGroup === '') {
            throw new \InvalidArgumentException('Meta optionGroup cannot be empty. Pass a unique optionGroup for each metabox.');
        }

        $this->optionGroup = $optionGroup;
    }

    /**
     * Set target screens for this metabox.
     *
     * @param array $screens
     *
     * @return void
     */
    protected function setScreens(array $screens): void
    {
        $this->screens = [];

        foreach ($screens as $screen) {
            if (!is_scalar($screen)) {
                continue;
            }

            $screen = trim((string) $screen);
            if ($screen === '') {
                continue;
            }

            $this->screens[] = $screen;
        }

        $this->screens = array_values(array_unique($this->screens));
    }

    /**
     * Set a description for the next section added via addSection().
     *
     * @param string $description Section description text
     *
     * @return self
     */
    public function withSectionDescription(string $description): self
    {
        $this->sectionDescription = $description;
        return $this;
    }

    /**
     * Add a section of fields to the metabox.
     *
     * @param string $title  Section title
     * @param array  $fields Array of field definitions
     *
     * @return self
     */
    public function addSection($title, $fields): self
    {
        $this->sections[] = [
            'title'       => $title,
            'description' => $this->sectionDescription,
            'fields'      => $fields,
        ];

        $this->sectionDescription = '';
        $this->invalidateStructureCache();

        return $this;
    }

    /**
     * Flatten all section fields into a single array (cached).
     *
     * @return array<int, array>
     */
    protected function getAllFields(): array
    {
        if (is_array($this->allFieldsCache)) {
            return $this->allFieldsCache;
        }

        $fields = [];
        foreach ($this->sections as $section) {
            $sectionFields = isset($section['fields']) && is_array($section['fields']) ? $section['fields'] : [];
            foreach ($sectionFields 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();
    }

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

    /**
     * Whether this metabox targets WooCommerce orders.
     *
     * @return bool
     */
    protected function isOrderType(): bool
    {
        return in_array('shop_order', $this->getScreens(), true);
    }

    /**
     * Get the screens (post types or HPOS screens) this metabox should appear on.
     *
     * @return array
     */
    protected function getScreens(): array
    {
        return $this->screens;
    }

    /**
     * Get the post types targeted by this metabox for REST registration.
     *
     * Order-meta boxes are intentionally excluded from register_post_meta()
     * because WooCommerce order storage uses a dedicated REST adapter.
     *
     * @internal Used by framework REST registrars.
     * @return array<int, string>
     */
    public function restPostTypes(): array
    {
        if ($this->isOrderType()) {
            return [];
        }

        return array_values(array_unique(array_filter(array_map('strval', $this->getScreens()))));
    }

    /**
     * Register and initialize the metabox.
     *
     * @return void
     */
    public function render(): void
    {
        if ($this->restPostTypes() !== []) {
            if (\did_action('init')) {
                $this->registerMeta();
            } else {
                \add_action('init', [$this, 'registerMeta']);
            }
        }

        \add_action('add_meta_boxes', [$this, 'addMetaBox']);
        $this->registerSaveHook();
    }

    /**
     * Add the meta box to each registered screen.
     *
     * @internal WordPress callback method — do not call directly.
     */
    public function addMetaBox(): void
    {
        foreach ($this->getScreens() as $screen) {
            \add_meta_box(
                $this->id,
                $this->title,
                [$this, 'renderMetaBoxCallback'],
                $screen,
                $this->context,
                $this->priority
            );
        }
    }

    /**
     * Render the metabox content.
     *
     * @internal WordPress callback method — do not call directly.
     *
     * @param \WP_Post|object $object Current post or order object.
     */
    public function renderMetaBoxCallback($object): void
    {
        \wp_nonce_field($this->id . '_action', $this->id . '_nonce');
        $this->enqueueAssets();

        $values = $this->getMetaValues($object);
        $this->renderSections($values);
    }

    /**
     * Register the save hook appropriate for this metabox type.
     */
    protected function registerSaveHook(): void
    {
        if ($this->isOrderType()) {
            \add_action('woocommerce_process_shop_order_meta', [$this, 'saveMetaBoxData']);
        } else {
            \add_action('save_post', [$this, 'saveMetaBoxData']);
        }
    }

    /**
     * Save metabox data.
     *
     * @internal WordPress callback method — do not call directly.
     *
     * @param int $objectId Post or order ID.
     */
    public function saveMetaBoxData($objectId): void
    {
        $nonce = isset($_POST[$this->id . '_nonce'])
            ? \sanitize_text_field(\wp_unslash($_POST[$this->id . '_nonce']))
            : '';

        if (!$nonce || !\wp_verify_nonce($nonce, $this->id . '_action')) {
            return;
        }

        if (!$this->canSave($objectId)) {
            return;
        }

        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized per-field via sanitizeField() in the loop below.
        $input = isset($_POST[$this->optionGroup]) && is_array($_POST[$this->optionGroup]) ? \wp_unslash($_POST[$this->optionGroup]) : [];

        FieldValidator::clearErrors();
        $data = $this->buildPreparedMetaSavePayload((int) $objectId, $input);

        FieldValidator::clearErrors();
        $this->saveMeta((int) $objectId, $data);
    }

    /**
     * Build the sanitized meta payload that should be persisted for the object.
     *
     * Missing unlocked fields are skipped so existing values are preserved.
     * Locked fields retain the same current/default/implicit fallback behavior
     * across admin and REST save paths.
     *
     * @internal Used by framework save handlers and REST adapters.
     * @param int                 $objectId                    Post or order ID.
     * @param array<string,mixed> $input                       Raw field payload keyed by field id.
     * @param bool                $includeOmittedLockedFields  Whether omitted locked
     *                                                         fields should still
     *                                                         be resolved using
     *                                                         current/default
     *                                                         fallback behavior.
     * @return array<string, array{field:array,value:mixed,current_value:mixed}>
     */
    public function buildPreparedMetaSavePayload(
        int $objectId,
        array $input,
        bool $includeOmittedLockedFields = true
    ): array
    {
        $data = [];

        foreach ($this->getAllFields() as $field) {
            $id = isset($field['id']) ? \sanitize_key((string) $field['id']) : '';
            if ($id === '') {
                continue;
            }

            $type = $field['type'] ?? 'text';
            $currentValue = $this->getCurrentMetaValue($objectId, $id);

            if ($this->isFieldLocked($field)) {
                if (!$includeOmittedLockedFields && !array_key_exists($id, $input)) {
                    continue;
                }

                $lockedValue = $this->resolveLockedFieldValue($field, $input, $currentValue);
                if (!$lockedValue['has_value']) {
                    continue;
                }

                if (!empty($lockedValue['should_validate'])) {
                    $validationField = $field;
                    if ($currentValue !== null) {
                        $validationField['_current_value'] = $currentValue;
                    }
                    $validationField['_input_context'] = $input;

                    if (!FieldValidator::validate($lockedValue['value'], (string) $type, $validationField)) {
                        continue;
                    }
                }

                $data[$id] = [
                    'field' => $field,
                    'value' => $lockedValue['value'],
                    'current_value' => $currentValue,
                ];
                continue;
            }

            if (!array_key_exists($id, $input)) {
                // Handle unchecked checkboxes / switches (only when rendered)
                if (in_array($type, ['checkbox', 'switch'], true) && array_key_exists('_nxtf_rendered_' . $id, $input)) {
                    if (isset($field['options']) && is_array($field['options'])) {
                        $data[$id] = [
                            'field' => $field,
                            'value' => [],
                            'current_value' => $currentValue,
                        ];
                    } else {
                        $data[$id] = [
                            'field' => $field,
                            'value' => '0',
                            'current_value' => $currentValue,
                        ];
                    }
                } elseif ($type === 'repeater' && array_key_exists('_nxtf_rendered_' . $id, $input)) {
                    $data[$id] = [
                        'field' => $field,
                        'value' => [],
                        'current_value' => $currentValue,
                    ];
                }

                continue;
            }

            if ($type === 'repeater' && is_array($input[$id]) && !empty($field['fields'])) {
                $candidateValue = $this->sanitizeRepeaterRows(
                    $input[$id],
                    $field['fields'],
                    is_array($currentValue) ? $currentValue : []
                );
            } else {
                $candidateValue = $this->sanitizeField($input[$id], $type);
            }

            $validationField = $field;
            if ($currentValue !== null) {
                $validationField['_current_value'] = $currentValue;
            }
            $validationField['_input_context'] = $input;

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

            $data[$id] = [
                'field' => $field,
                'value' => $candidateValue,
                'current_value' => $currentValue,
            ];
        }

        return $data;
    }

    /**
     * Read the current stored meta value for a field.
     *
     * @param int    $objectId Post or order ID.
     * @param string $key      Meta key.
     * @return mixed
     */
    protected function getCurrentMetaValue(int $objectId, string $key)
    {
        if ($this->isOrderType()) {
            $order = \wc_get_order($objectId);
            return $order ? $order->get_meta($key, true) : null;
        }

        return \metadata_exists('post', $objectId, $key)
            ? \get_post_meta($objectId, $key, true)
            : null;
    }

    /**
     * Persist meta data for the given object.
     *
     * @param int   $objectId Post or order ID.
     * @param array $data     Associative array of field ID => prepared persistence payload.
     */
    protected function saveMeta(int $objectId, array $data): void
    {
        if ($this->isOrderType()) {
            $order = \wc_get_order($objectId);
            if (!$order) {
                return;
            }

            foreach ($data as $key => $payload) {
                $value = is_array($payload) && array_key_exists('value', $payload) ? $payload['value'] : $payload;
                $order->update_meta_data($key, $value);
            }

            $order->save();
            return;
        }

        foreach ($data as $key => $payload) {
            $field = is_array($payload) && isset($payload['field']) && is_array($payload['field'])
                ? $payload['field']
                : ['id' => $key];
            $value = is_array($payload) && array_key_exists('value', $payload) ? $payload['value'] : null;
            $currentValue = is_array($payload) && array_key_exists('current_value', $payload)
                ? $payload['current_value']
                : $this->getCurrentMetaValue($objectId, $key);

            $this->updateRegisteredPostMeta($objectId, $field, $value, $currentValue);
        }
    }

    /**
     * Check whether saving should proceed for the given object.
     *
     * @param int $objectId Post or order ID.
     *
     * @return bool
     */
    protected function canSave(int $objectId): bool
    {
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return false;
        }

        if ($this->isOrderType()) {
            return \current_user_can('edit_shop_orders');
        }

        if (\wp_is_post_revision($objectId)) {
            return false;
        }

        $postType = \get_post_type($objectId);

        if (!in_array($postType, $this->getScreens(), true)) {
            return false;
        }

        $capability = \is_post_type_hierarchical($postType) ? 'edit_page' : 'edit_post';
        return \current_user_can($capability, $objectId);
    }

    /**
     * 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
    {
        $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 ($subFieldDefs as $subField) {
                if (!is_array($subField)) {
                    continue;
                }

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

                $subType = (string) ($subField['type'] ?? 'text');

                if ($this->isFieldLocked($subField)) {
                    $lockedValue = $this->resolveLockedRepeaterSubFieldValue(
                        $subField,
                        $row,
                        $currentRow,
                        $allowLockedSubmittedValues
                    );
                    if (!$lockedValue['has_value']) {
                        continue;
                    }

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

                        if (!FieldValidator::validate($lockedValue['value'], $subType, $validationField)) {
                            continue;
                        }
                    }

                    $sanitizedRow[$subId] = $lockedValue['value'];
                    continue;
                }

                if (array_key_exists($subId, $row)) {
                    if ($subType === 'repeater' && is_array($row[$subId]) && !empty($subField['fields'])) {
                        $currentNestedRows = [];
                        if (isset($currentRow[$subId]) && is_array($currentRow[$subId])) {
                            $currentNestedRows = $currentRow[$subId];
                        }

                        $sanitizedValue = $this->sanitizeRepeaterRows(
                            $row[$subId],
                            $subField['fields'],
                            $currentNestedRows,
                            $allowLockedSubmittedValues
                        );
                    } else {
                        $sanitizedValue = $this->sanitizeField($row[$subId], $subType);
                    }

                    $validationField = $subField;
                    $validationField['id'] = $subId . '_row_' . $rowNum;
                    $validationField['label'] = ($subField['label'] ?? $subId) . ' (Row ' . $rowNum . ')';
                    if (array_key_exists($subId, $currentRow)) {
                        $validationField['_current_value'] = $currentRow[$subId];
                    }
                    $validationField['_input_context'] = $row;

                    if (FieldValidator::validate($sanitizedValue, $subType, $validationField)) {
                        $sanitizedRow[$subId] = $sanitizedValue;
                    } elseif (array_key_exists($subId, $currentRow)) {
                        $sanitizedRow[$subId] = $currentRow[$subId];
                    }
                } elseif (in_array($subType, ['checkbox', 'switch'], true)) {
                    $sanitizedRow[$subId] = isset($subField['options']) && is_array($subField['options']) ? [] : '0';
                }
            }

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

        return $sanitized;
    }

    /**
     * Resolve the value to keep for a locked top-level meta field.
     *
     * @param array $field
     * @param array $input
     * @param mixed $currentValue
     * @return array{has_value:bool,value:mixed,should_validate:bool}
     */
    protected function resolveLockedFieldValue(array $field, array $input, $currentValue): array
    {
        if ($currentValue !== null) {
            return ['has_value' => true, 'value' => $currentValue, 'should_validate' => false];
        }

        $id = \sanitize_key((string) ($field['id'] ?? ''));
        if ($id === '') {
            return ['has_value' => false, 'value' => null, '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, $input)) {
            return [
                'has_value' => true,
                'value' => $this->sanitizeValueForFieldDefinition($field, $input[$id]),
                'should_validate' => true,
            ];
        }

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

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

        return $implicitValue;
    }

    /**
     * Resolve the value to preserve for a locked repeater sub-field.
     *
     * @param array $field
     * @param array $row
     * @param array $currentRow
     * @return array{has_value:bool,value:mixed,should_validate:bool}
     */
    protected 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
     */
    protected 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);
    }

    /**
     * Apply repeater-level lock semantics to child field definitions for save-time sanitization.
     *
     * @param array $field
     * @return array
     */
    protected 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]
            : [];
    }

    /**
     * Enqueue CSS and JS assets for the metabox.
     */
    protected function enqueueAssets(): void
    {
        Asset::registerFrameworkAssets();
        \wp_enqueue_style(Config::asset('framework'));
        \wp_enqueue_script(Config::asset('options'));
    }

    /**
     * Render all sections with their fields.
     *
     * @param array $values Current meta values keyed by field ID.
     */
    protected function renderSections(array $values): void
    {
        $context = \sanitize_key((string) $this->context);
        if ($context === '') {
            $context = 'normal';
        }

        echo '<div class="nxtf-metabox-container nxtf-metabox-container--' . \esc_attr($context) . '">';

        $total = count($this->sections);
        $index = 0;

        foreach ($this->sections as $section) {
            echo '<div class="nxtf-metabox-section">';

            if (!empty($section['title'])) {
                echo '<h4 class="nxtf-metabox-title">'
                    . \esc_html($section['title'])
                    . '</h4>';
            }

            if (!empty($section['description'])) {
                echo '<p class="nxtf-metabox-desc">'
                    . \esc_html($section['description'])
                    . '</p>';
            }

            echo '<div class="nxtf-space-y-4">';
            foreach ($section['fields'] as $field) {
                $id = \sanitize_key((string) ($field['id'] ?? ''));
                if ($id === '') {
                    continue;
                }

                $value = array_key_exists($id, $values) ? $values[$id] : null;
                FieldRenderer::render($field, $value, $this->optionGroup);
            }
            echo '</div>';

            echo '</div>';

            $index++;
            if ($index < $total) {
                echo '<hr class="nxtf-section-separator">';
            }
        }

        echo '</div>';
    }

    /**
     * Read current meta values for all fields from the given object.
     *
     * @param \WP_Post|object $object The post or order object passed by WordPress.
     *
     * @return array Associative array keyed by field ID.
     */
    abstract protected function getMetaValues($object): array;

}
