<?php

namespace WeDevs\DokanPro\Modules\Auction;

use WC_Product;
use WP_Error;
use WeDevs\Dokan\ProductEditor\Elements;

defined( 'ABSPATH' ) || exit;

/**
 * Integrates the Auction product type with the new Dokan Product Form Manager.
 *
 * Auction products were historically created through a separate legacy template
 * (templates/auction/new-auction-product.php) saved on `template_redirect`. The new
 * vendor dashboard uses a schema-driven React editor (/dashboard/new/#/products/create)
 * that persists through WooCommerce core REST and never fires the classic
 * `woocommerce_process_product_meta` hook the auction plugin relies on.
 *
 * This class bridges that gap, entirely via the editor's documented extension points:
 *  - registers `auction` as a selectable product type,
 *  - adds the auction fields + section to the form schema and layout (shown only when
 *    the product type is `auction`),
 *  - resolves saved `_auction_*` meta back into the form when editing, and
 *  - persists the auction meta on save by reusing WooCommerce Simple Auctions'
 *    own `product_save_data()` writer.
 *
 * @since 5.0.8
 */
class ProductEditor {

    /**
     * Form schema section id for the auction options block.
     */
    const SECTION_AUCTION = 'auction_options';

    /**
     * Auction product type slug.
     */
    const PRODUCT_TYPE = 'auction';

    /**
     * Class constructor.
     *
     * @since 5.0.8
     */
    public function __construct() {
        // Make `auction` selectable in the editor's Product Type dropdown. Priority 15 so it
        // runs after Dokan Pro's set_default_product_types() (priority 10), which otherwise
        // replaces the list with simple/variable/external/grouped only.
        add_filter( 'dokan_product_types', [ $this, 'register_product_type' ], 15 );

        // Add the auction section + fields to the schema and place the section in the layout.
        add_filter( 'dokan_product_editor_schema', [ $this, 'add_fields' ] );
        add_filter( 'dokan_product_editor_layouts', [ $this, 'add_layout' ] );

        // Restrict the auction form to the same fields as the legacy auction page: keep only
        // the allow-listed fields/sections (plus auction fields) and hide everything else for
        // the auction type. Hooked on prepared_schema at a late priority so it runs AFTER
        // FormSettings::form_schema_response (admin overrides, priority 10) — otherwise the
        // admin-saved empty `visibilities` would wipe ours.
        add_filter( 'dokan_product_editor_prepared_schema', [ $this, 'restrict_to_legacy_fields' ], 99 );

        // Resolve saved `_auction_*` meta back into the form when editing an auction product.
        add_filter( 'dokan_product_editor_schema_value', [ $this, 'resolve_field_value' ], 10, 3 );

        // Server-side validation backstop for required auction fields and the date order,
        // since admins can hide fields (the client validator skips hidden fields).
        add_filter( 'woocommerce_rest_pre_insert_product_object', [ $this, 'validate_auction_on_save' ], 10, 2 );

        // Persist auction meta on save. The REST editor never fires
        // woocommerce_process_product_meta, so bridge to WCSA's writer here. New products
        // are pre-created as an auto-draft and saved via update, so hook both actions.
        add_action( 'dokan_new_product_added', [ $this, 'save_auction_data' ], 10, 2 );
        add_action( 'dokan_product_updated', [ $this, 'save_auction_data' ], 10, 2 );

        // Send the vendor back to the Auction page (not the generic product list)
        // after creating/editing an auction in the new editor.
        add_filter( 'dokan_product_editor_args', [ $this, 'set_editor_redirect_url' ] );
    }

    /**
     * Redirect the editor back to the Auction page after creating an auction.
     *
     * The new editor reads `products_url` from this response and navigates there
     * once a new product is created. When the product-type field is preselected
     * to `auction` (from the `?type=auction` hint the "Add New Auction Product"
     * button passes), point that URL at the Auction page instead.
     *
     * @since 5.0.8
     *
     * @param array $args Editor args (includes `products_url` and `form_items`).
     *
     * @return array
     */
    public function set_editor_redirect_url( $args ) {
        foreach ( (array) ( $args['form_items'] ?? [] ) as $field ) {
            if ( Elements::TYPE === ( $field['id'] ?? '' ) && self::PRODUCT_TYPE === ( $field['value'] ?? '' ) ) {
                $args['products_url'] = dokan_get_navigation_url( 'new' ) . '#/auction';
                break;
            }
        }

        return $args;
    }

    /**
     * Register `auction` as a product type for the new product editor.
     *
     * @since 5.0.8
     *
     * @param array $types Product types keyed by slug => label.
     *
     * @return array
     */
    public function register_product_type( $types ) {
        // Only expose `auction` for the NEW React editor. The same
        // `dokan_product_types` filter also feeds the legacy product form
        // (templates/products/*), where auctions are created through their own
        // dedicated page (new-auction-product) — so in legacy editor mode we
        // must not leak the type into that dropdown.
        if ( is_array( $types ) && ! $this->is_legacy_editor_preferred() ) {
            $types[ self::PRODUCT_TYPE ] = __( 'Auction', 'dokan' );
        }

        return $types;
    }

    /**
     * Whether the vendor product editor is set to the legacy UI.
     *
     * Reads the global `dokan_appearance.vendor_product_editor` setting through
     * the shared switcher, so the auction integration mirrors core's
     * new-vs-legacy decision exactly.
     *
     * @since 5.0.8
     *
     * @return bool
     */
    private function is_legacy_editor_preferred(): bool {
        return dokan_get_container()
            ->get( \WeDevs\Dokan\Admin\Dashboard\LegacySwitcher::class )
            ->is_product_editor_legacy_preferred();
    }

    /**
     * Append the auction section and fields to the form schema.
     *
     * Every field is gated to the `auction` product type via a dependency on the
     * `type` field, so the section auto-hides for other product types (empty layout
     * containers are pruned by the editor).
     *
     * @since 5.0.8
     *
     * @param array $fields Flat list of sections + fields.
     *
     * @return array
     */
    public function add_fields( array $fields ): array {
        $fields[] = [
            'id'          => self::SECTION_AUCTION,
            'type'        => 'section',
            'label'       => __( 'Auction Options', 'dokan' ),
            'description' => __( 'Manage your auction product data', 'dokan' ),
            'visibility'  => true,
        ];

        // General auction settings.
        $fields[] = $this->field(
            '_auction_item_condition', __( 'Item condition', 'dokan' ), 'select', 10, [
				'options' => [
					'new'  => __( 'New', 'dokan' ),
					'used' => __( 'Used', 'dokan' ),
				],
				'value'   => 'new',
			]
        );

        $fields[] = $this->field(
            '_auction_type', __( 'Auction type', 'dokan' ), 'select', 20, [
				'options' => [
					'normal'  => __( 'Normal', 'dokan' ),
					'reverse' => __( 'Reverse', 'dokan' ),
				],
				'value'   => 'normal',
			]
        );

        $fields[] = $this->field(
            '_auction_proxy', __( 'Enable proxy bidding for this auction product', 'dokan' ), 'checkbox', 30, [
				// Matches the legacy form: enabled by default unless proxy bidding is forced site-wide.
				'value' => 'no' === get_option( 'simple_auctions_proxy_auction_on', 'no' ),
			]
        );

        // Sealed bidding is only available when the store enables it (matches the legacy form).
        if ( 'yes' === get_option( 'simple_auctions_sealed_on' ) ) {
            $fields[] = $this->field( '_auction_sealed', __( 'Enable sealed bidding for this auction product', 'dokan' ), 'checkbox', 40 );
        }

        // Pricing. Use the 'text' variant so price fields route through the editor's
        // PriceEdit component (currency prefix + WC price formatting). Placeholder mirrors
        // the legacy auction form's wc_format_localized_price( '9.99' ). (start/reserved
        // price get PriceEdit because their id contains "price"; bid increment is plain text.)
        $price_placeholder = wc_format_localized_price( '9.99' );
        $fields[] = $this->field(
            '_auction_start_price', __( 'Start Price', 'dokan' ), 'text', 50, [
				'required'     => true,
				'is_mandatory' => true,
				'placeholder'  => $price_placeholder,
			]
        );

        $fields[] = $this->field(
            '_auction_bid_increment', __( 'Bid increment', 'dokan' ), 'text', 60, [
				'required'     => true,
				'is_mandatory' => true,
				'placeholder'  => $price_placeholder,
			]
        );

        $fields[] = $this->field(
            '_auction_reserved_price', __( 'Reserved price', 'dokan' ), 'text', 70, [
				'placeholder' => $price_placeholder,
			]
        );

        // Buy it now price. Rendered inside Auction Options (the legacy form groups it with
        // the other auction prices). The id is the legacy POST key WCSA reads, so it persists
        // with no save bridge. Optional, like the legacy form (the required price is Start Price).
        $fields[] = $this->field(
            '_regular_price', __( 'Buy it now price', 'dokan' ), 'text', 75, [
				'placeholder' => $price_placeholder,
			]
        );

        // Dates.
        $fields[] = $this->field(
            '_auction_dates_from', __( 'Auction Start date', 'dokan' ), 'datetime', 80, [
				'required'     => true,
				'is_mandatory' => true,
			]
        );

        $fields[] = $this->field(
            '_auction_dates_to', __( 'Auction End date', 'dokan' ), 'datetime', 90, [
				'required'     => true,
				'is_mandatory' => true,
			]
        );

        // Extend-on-bid settings.
        $fields[] = $this->field( '_auction_extend_enable', __( 'Extend auction on bid?', 'dokan' ), 'checkbox', 100 );

        $extend_deps = $this->checkbox_dependency( '_auction_extend_enable' );

        $fields[] = $this->field(
            '_auction_extend_in_time', __( 'Extend auctions in last * seconds.', 'dokan' ), 'text', 110, [
				'dependencies' => $extend_deps,
			]
        );

        $fields[] = $this->field(
            '_auction_extend_for_time', __( 'Extend auctions for * seconds.', 'dokan' ), 'text', 120, [
				'dependencies' => $extend_deps,
			]
        );

        // Automatic relist settings.
        $fields[] = $this->field( '_auction_automatic_relist', __( 'Enable automatic relisting for this auction', 'dokan' ), 'checkbox', 130 );

        $relist_deps = $this->checkbox_dependency( '_auction_automatic_relist' );

        $fields[] = $this->field(
            '_auction_relist_fail_time', __( 'Relist if fail after n hours', 'dokan' ), 'text', 140, [
				'dependencies' => $relist_deps,
			]
        );

        $fields[] = $this->field(
            '_auction_relist_not_paid_time', __( 'Relist if not paid after n hours', 'dokan' ), 'text', 150, [
				'dependencies' => $relist_deps,
			]
        );

        $fields[] = $this->field(
            '_auction_relist_duration', __( 'Relist auction duration in h', 'dokan' ), 'text', 160, [
				'dependencies' => $relist_deps,
			]
        );

        return $fields;
    }

    /**
     * Place the auction section card in the primary column of the editor layout.
     *
     * @since 5.0.8
     *
     * @param array $layouts Flat layout nodes.
     *
     * @return array
     */
    public function add_layout( array $layouts ): array {
        $layouts[] = [
            'id'        => self::SECTION_AUCTION,
            'parent_id' => Elements::PRIMARY_COLUMN,
            'priority'  => 35,
            'layout'    => [
                'type'       => 'card',
                'withHeader' => true,
            ],
        ];

        return $layouts;
    }

    /**
     * Restrict the auction form to the fields present on the legacy auction page.
     *
     * The schema-driven editor shows every field whose visibility map omits `auction`
     * (it falls back to `visibility: true`), which leaks unrelated fields and other
     * plugins'/modules' sections (RMA, wholesale, min/max, advertisement, custom blocks,
     * attributes, linked products, sale price, stock management, etc.) into the auction
     * form. To mirror the legacy page we keep only an allow-list of fields/sections (plus
     * this plugin's own auction fields) and hide the rest for the auction type.
     *
     * The native price field is hidden for auctions; the auction "Buy it now" price is a
     * dedicated field inside the Auction Options section (see add_fields()).
     *
     * An admin can still deliberately re-enable a field for auctions from Dokan → Product
     * Form Manager: we only set the default when no explicit `auction` value exists, so a
     * saved `visibilities['auction'] = true` (or label/required override) is preserved.
     *
     * @since 5.0.8
     *
     * @param array $fields Flat list of sections + fields.
     *
     * @return array
     */
    public function restrict_to_legacy_fields( array $fields ): array {
        $allowed = $this->allowed_field_ids();

        foreach ( $fields as &$field ) {
            if ( empty( $field['id'] ) ) {
                continue;
            }

            // Keep allow-listed items and this plugin's own auction section/fields.
            if (
                in_array( $field['id'], $allowed, true )
                || self::SECTION_AUCTION === $field['id']
                || in_array( $field['id'], $this->field_keys(), true )
            ) {
                continue;
            }

            $visibilities = isset( $field['visibilities'] ) && is_array( $field['visibilities'] )
                ? $field['visibilities']
                : [];

            // Respect an explicit admin choice; otherwise hide for auctions by default.
            if ( ! array_key_exists( self::PRODUCT_TYPE, $visibilities ) ) {
                $visibilities[ self::PRODUCT_TYPE ] = false;
                $field['visibilities']              = $visibilities;
            }
        }

        unset( $field );

        return $fields;
    }

    /**
     * Resolve a saved auction meta value back into the form when editing.
     *
     * Checkbox fields are normalised to a boolean the editor understands; everything
     * else is returned as stored.
     *
     * @since 5.0.8
     *
     * @param mixed      $value      Resolved value so far (null when unresolved).
     * @param string     $field_name The schema field id (== meta key for auction fields).
     * @param WC_Product $product    The product being edited.
     *
     * @return mixed
     */
    public function resolve_field_value( $value, $field_name, $product ) {
        if ( ! $product instanceof WC_Product || ! in_array( $field_name, $this->field_keys(), true ) ) {
            return $value;
        }

        // Buy-it-now maps to the product's regular price prop, not a custom meta key.
        if ( '_regular_price' === $field_name ) {
            return $product->get_regular_price();
        }

        $meta = $product->get_meta( $field_name, true );

        if ( in_array( $field_name, $this->checkbox_keys(), true ) ) {
            return in_array( $meta, [ 'yes', '1', 1, true ], true );
        }

        return $meta;
    }

    /**
     * Persist auction meta when a product is saved through the new editor.
     *
     * Reuses WooCommerce Simple Auctions' product_save_data(), which reads $_POST.
     * ProductControllerV3::populate_post_data() has already merged the request params
     * into $_POST (keyed by field id == meta key, including `_regular_price` for buy-it-now),
     * so we only need to (1) set the legacy `product-type` key WCSA gates on, and
     * (2) coerce checkbox booleans.
     *
     * @since 5.0.8
     *
     * @param int   $product_id Saved product ID.
     * @param array $params     Resolved request params.
     *
     * @return void
     */
    public function save_auction_data( $product_id, $params ) {
        $type = is_array( $params ) && isset( $params['type'] ) ? $params['type'] : '';

        if ( self::PRODUCT_TYPE !== $type ) {
            return;
        }

        global $woocommerce_auctions;

        if ( empty( $woocommerce_auctions ) || ! method_exists( $woocommerce_auctions, 'product_save_data' ) ) {
            return;
        }

        // WCSA gates on the legacy hyphenated key; the editor sends `type`.
        $_POST['product-type'] = self::PRODUCT_TYPE; // phpcs:ignore WordPress.Security.NonceVerification.Missing

        // Checkboxes arrive as booleans; WCSA expects 'yes' (or the key absent / 'no').
        $this->normalize_checkbox_post( '_auction_proxy' );
        $this->normalize_checkbox_post( '_auction_sealed' );
        $this->normalize_checkbox_post( '_auction_extend_enable', 'no' );
        $this->normalize_checkbox_post( '_auction_automatic_relist', 'no' );

        $woocommerce_auctions->product_save_data( $product_id );

        $this->persist_submitted_images( $product_id, is_array( $params ) ? $params : [] );
    }

    /**
     * Re-apply the vendor's submitted featured/gallery images for auction products.
     *
     * WooCommerce Simple Auctions' WC_Product_Auction overrides get_image_id() and
     * get_gallery_image_ids() so that, outside wp-admin, they ignore the object's own
     * props and read straight from the database (get_post_thumbnail_id() / the
     * `_product_image_gallery` meta). The new editor saves through the front-end REST
     * API, where is_admin() is false — so when WooCommerce's data store calls those
     * getters to persist `_thumbnail_id`/`_product_image_gallery`, it reads back the
     * *old* values and writes them over the freshly uploaded ones, dropping the image.
     *
     * This runs after the WC save (on dokan_new_product_added / dokan_product_updated)
     * and rewrites the meta directly from the submitted, WC-shaped `images` array, so
     * the vendor's images survive. The first entry is the featured image and the rest
     * are gallery images, matching WooCommerce's own set_product_images() convention.
     *
     * @since 5.0.8
     *
     * @param int   $product_id Saved product ID.
     * @param array $params     Resolved request params (contain the WC `images` array).
     *
     * @return void
     */
    private function persist_submitted_images( $product_id, array $params ): void {
        // No image data submitted with this save — leave whatever is stored untouched.
        if ( ! array_key_exists( 'images', $params ) ) {
            return;
        }

        $images   = is_array( $params['images'] ) ? array_values( array_filter( (array) $params['images'] ) ) : [];
        $featured = isset( $images[0]['id'] ) ? absint( $images[0]['id'] ) : 0;

        if ( $featured ) {
            set_post_thumbnail( $product_id, $featured );
        } else {
            delete_post_thumbnail( $product_id );
        }

        $gallery = [];
        foreach ( array_slice( $images, 1 ) as $image ) {
            if ( ! empty( $image['id'] ) ) {
                $gallery[] = absint( $image['id'] );
            }
        }

        update_post_meta( $product_id, '_product_image_gallery', implode( ',', $gallery ) );

        clean_post_cache( $product_id );
    }

    /**
     * Validate required auction fields on the REST save, before the product is written.
     *
     * The client form marks Start Price / Bid increment / Start & End date as required, but
     * that check is skipped for any field an admin has hidden — and there is otherwise no
     * server backstop. This enforces them (and that the end date is later than the start)
     * and aborts the save with a 400 + message the editor surfaces.
     *
     * @since 5.0.8
     *
     * @param WC_Product|mixed $product Product object being saved.
     * @param \WP_REST_Request $request The REST request.
     *
     * @return WC_Product|WP_Error|mixed
     */
    public function validate_auction_on_save( $product, $request ) {
        // Gate on the product's real type, not the request param: a partial
        // update (or a client that omits `type`) must still be validated, and a
        // non-auction write must never be.
        if ( ! $product instanceof WC_Product || self::PRODUCT_TYPE !== $product->get_type() ) {
            return $product;
        }

        $errors = [];

        $start_price = wc_format_decimal( $this->resolve_auction_field( $product, $request, '_auction_start_price' ) );
        if ( ! is_numeric( $start_price ) ) {
            $errors[] = __( 'Auction start price is required and must be a number.', 'dokan' );
        }

        $bid_increment = wc_format_decimal( $this->resolve_auction_field( $product, $request, '_auction_bid_increment' ) );
        if ( ! is_numeric( $bid_increment ) ) {
            $errors[] = __( 'Auction bid increment is required and must be a number.', 'dokan' );
        }

        $date_from = trim( (string) $this->resolve_auction_field( $product, $request, '_auction_dates_from' ) );
        $date_to   = trim( (string) $this->resolve_auction_field( $product, $request, '_auction_dates_to' ) );

        if ( '' === $date_from ) {
            $errors[] = __( 'Auction start date is required.', 'dokan' );
        }

        if ( '' === $date_to ) {
            $errors[] = __( 'Auction end date is required.', 'dokan' );
        }

        if ( '' !== $date_from && '' !== $date_to ) {
            $from_ts = strtotime( $date_from );
            $to_ts   = strtotime( $date_to );

            if ( $from_ts && $to_ts && $to_ts <= $from_ts ) {
                $errors[] = __( 'Auction end date must be later than the start date.', 'dokan' );
            }
        }

        if ( ! empty( $errors ) ) {
            return new WP_Error(
                'dokan_auction_validation_failed',
                implode( ' ', $errors ),
                [ 'status' => 400 ]
            );
        }

        return $product;
    }

    /**
     * Resolve an auction field value across every write shape.
     *
     * Looks in order at: the flat top-level param (what the React editor sends),
     * the WooCommerce `meta_data[]` array (the REST standard used by the mobile
     * app, imports and third parties — `_`-prefixed keys are protected, so they
     * may not survive onto the product object), and finally the meta already
     * stored on the product (so a partial update of a valid auction passes).
     *
     * @since 5.0.8
     *
     * @param WC_Product       $product Prepared product object.
     * @param \WP_REST_Request $request The REST request.
     * @param string           $key     Auction meta key.
     *
     * @return string
     */
    private function resolve_auction_field( $product, $request, $key ) {
        $value = $request->get_param( $key );

        if ( null !== $value && '' !== $value ) {
            return (string) $value;
        }

        foreach ( (array) $request->get_param( 'meta_data' ) as $meta ) {
            $meta = (array) $meta;

            if ( isset( $meta['key'], $meta['value'] ) && $key === $meta['key'] && '' !== $meta['value'] ) {
                return (string) $meta['value'];
            }
        }

        return (string) $product->get_meta( $key );
    }

    /**
     * Build a single auction field definition with the shared defaults.
     *
     * @since 5.0.8
     *
     * @param string $id       Field id (auction meta key).
     * @param string $label    Field label.
     * @param string $variant  Field variant (select|number|datetime|checkbox).
     * @param int    $priority Ordering priority within the section.
     * @param array  $extra    Extra/overriding field properties.
     *
     * @return array
     */
    private function field( string $id, string $label, string $variant, int $priority, array $extra = [] ): array {
        return array_merge(
            [
                'id'           => $id,
                'section_id'   => self::SECTION_AUCTION,
                'type'         => 'field',
                'label'        => $label,
                'variant'      => $variant,
                'required'     => false,
                'visibility'   => true,
                'priority'     => $priority,
                'dependencies' => $this->type_dependency(),
            ],
            $extra
        );
    }

    /**
     * Dependency that shows a field only for the auction product type.
     *
     * @since 5.0.8
     *
     * @return array
     */
    private function type_dependency(): array {
        return [
            [
                'comparison' => '==',
                'key'        => Elements::TYPE,
                'value'      => self::PRODUCT_TYPE,
            ],
        ];
    }

    /**
     * Dependency that shows a field for the auction type AND when a checkbox is enabled.
     *
     * @since 5.0.8
     *
     * @param string $checkbox_key The controlling checkbox field id.
     *
     * @return array
     */
    private function checkbox_dependency( string $checkbox_key ): array {
        return [
            [
                'comparison' => '==',
                'key'        => Elements::TYPE,
                'value'      => self::PRODUCT_TYPE,
            ],
            [
                'comparison' => '==',
                'key'        => $checkbox_key,
                'value'      => true,
            ],
        ];
    }

    /**
     * Field/section ids kept for auctions — the same set as the legacy auction page,
     * plus the editor's essential sidebar fields. Everything else is hidden for auctions
     * by restrict_to_legacy_fields(). The auction section + fields are always kept and
     * are not listed here.
     *
     * @since 5.0.8
     *
     * @return string[]
     */
    private function allowed_field_ids(): array {
        return [
            // Sections kept (their non-allowed child fields are still hidden individually).
            // Inventory is intentionally omitted — the legacy new-auction page has no SKU/
            // inventory fields, so the whole section stays hidden for auctions.
            Elements::SECTION_GENERAL,
            Elements::SECTION_SHIPPING,
            Elements::SECTION_DOWNLOADABLE,
            Elements::SECTION_OTHERS,
            // General details. (Permalink/slug and Brands are intentionally omitted — not on
            // the legacy new-auction form.)
            Elements::NAME,
            Elements::TYPE,
            Elements::CATEGORIES,
            Elements::TAGS,
            Elements::FEATURED_IMAGE_ID,
            Elements::GALLERY_IMAGE_IDS,
            // Native price is intentionally omitted: it is hidden for auctions and replaced
            // by the dedicated `_regular_price` "Buy it now price" field in Auction Options.
            Elements::SHORT_DESCRIPTION,
            Elements::DESCRIPTION,
            Elements::DOWNLOADABLE,
            Elements::VIRTUAL,
            // Downloadable files (shown when "Downloadable" is checked).
            Elements::DOWNLOADS,
            Elements::DOWNLOAD_LIMIT,
            Elements::DOWNLOAD_EXPIRY,
            // Shipping & tax.
            Elements::DISABLE_SHIPPING_META,
            Elements::WEIGHT,
            Elements::DIMENSIONS_LENGTH,
            Elements::DIMENSIONS_WIDTH,
            Elements::DIMENSIONS_HEIGHT,
            Elements::SHIPPING_CLASS,
            Elements::TAX_STATUS,
            Elements::TAX_CLASS,
            Elements::OVERWRITE_SHIPPING_META,
            Elements::ADDITIONAL_SHIPPING_COST_META,
            Elements::ADDITIONAL_SHIPPING_QUANTITY_META,
            Elements::ADDITIONAL_SHIPPING_PROCESSING_TIME_META,
            // Sidebar essentials needed to publish a product. The legacy auction edit page's
            // "Other Options" shows only Product Status + Visibility; reviews and purchase
            // note are not on the legacy auction form, so they stay hidden for auctions.
            Elements::STATUS,
            Elements::CATALOG_VISIBILITY,
        ];
    }

    /**
     * Normalise a checkbox value already present in $_POST to WCSA's expected format.
     *
     * @since 5.0.8
     *
     * @param string      $key        The $_POST key (auction meta key).
     * @param string|null $when_false Value to set when unchecked, or null to remove the key.
     *
     * @return void
     */
    private function normalize_checkbox_post( string $key, $when_false = null ): void {
        // phpcs:disable WordPress.Security.NonceVerification.Missing
        $is_true = isset( $_POST[ $key ] ) && filter_var( wp_unslash( $_POST[ $key ] ), FILTER_VALIDATE_BOOLEAN );

        if ( $is_true ) {
            $_POST[ $key ] = 'yes';
        } elseif ( null !== $when_false ) {
            $_POST[ $key ] = $when_false;
        } else {
            unset( $_POST[ $key ] );
        }
        // phpcs:enable WordPress.Security.NonceVerification.Missing
    }

    /**
     * All field ids (== meta/POST keys) managed by the editor integration: the auction
     * meta fields plus `_regular_price` (the auction "Buy it now" price).
     *
     * @since 5.0.8
     *
     * @return string[]
     */
    private function field_keys(): array {
        return [
            '_regular_price',
            '_auction_item_condition',
            '_auction_type',
            '_auction_proxy',
            '_auction_sealed',
            '_auction_start_price',
            '_auction_bid_increment',
            '_auction_reserved_price',
            '_auction_dates_from',
            '_auction_dates_to',
            '_auction_extend_enable',
            '_auction_extend_in_time',
            '_auction_extend_for_time',
            '_auction_automatic_relist',
            '_auction_relist_fail_time',
            '_auction_relist_not_paid_time',
            '_auction_relist_duration',
        ];
    }

    /**
     * Auction field ids rendered as checkboxes.
     *
     * @since 5.0.8
     *
     * @return string[]
     */
    private function checkbox_keys(): array {
        return [
            '_auction_proxy',
            '_auction_sealed',
            '_auction_extend_enable',
            '_auction_automatic_relist',
        ];
    }
}
