<?php

namespace DokanPro\Modules\Subscription;

use WC_Product;
use WeDevs\Dokan\ProductCategory\Categories;
use WeDevs\Dokan\ProductCategory\Helper as CategoryHelper;
use WeDevs\Dokan\ProductEditor\Elements;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Enforces a subscription pack's product limits — its allowed categories and its gallery image cap.
 *
 * Both used to be applied only by the legacy vendor dashboard's own form handling, so the product
 * editor introduced in 5.0 and every REST client ignored them. They are now checked wherever a
 * product is saved, under one rule: a save may not introduce a violation or make an existing one
 * worse, and enforcement never deletes what the vendor already has. See docs/adr/0013.
 *
 * @since 5.0.18
 */
class ProductLimits {

    /**
     * Allowed category ids already resolved this request, keyed by vendor id.
     *
     * @since 5.0.18
     *
     * @var array
     */
    protected static $allowed_categories = [];

    /**
     * Gallery image limits already resolved this request, keyed by vendor id.
     *
     * @since 5.0.18
     *
     * @var array
     */
    protected static $gallery_limits = [];

    /**
     * Register the hooks.
     *
     * @since 5.0.18
     */
    public function __construct() {
        // Covers every product endpoint (dokan/v1..v3, wc/v3, batch); last, so no other callback sees the WP_Error.
        add_filter( 'woocommerce_rest_pre_insert_product_object', [ $this, 'validate_rest_save' ], PHP_INT_MAX, 3 );

        // After Module::restrict_gallery_image_on_product_create() at 21, so its own error wins on the create form.
        add_filter( 'dokan_new_product_popup_args', [ $this, 'validate_legacy_create' ], 22, 2 );
        add_filter( 'dokan_can_add_product', [ $this, 'validate_legacy_add' ] );
        add_filter( 'dokan_can_edit_product', [ $this, 'validate_legacy_edit' ] );

        // Show the limits in the product editor, so the vendor sees them before the save rejects anything.
        add_filter( 'dokan_rest_product_categories_tree', [ $this, 'filter_categories_tree' ] );
        add_filter( 'dokan_product_editor_prepared_schema', [ $this, 'annotate_gallery_field' ], 20, 2 );

        add_action( 'dokan_vendor_purchased_subscription', [ __CLASS__, 'flush' ] );
        add_action( 'dokan_subscription_cancelled', [ __CLASS__, 'flush' ] );
    }

    /**
     * Reject a REST product save that breaks the vendor's subscription limits.
     *
     * @since 5.0.18
     *
     * @param WC_Product|mixed $product  Product assembled from the request, not yet saved.
     * @param mixed            $request  Full details about the request.
     * @param bool             $creating True when the product is being created.
     *
     * @return WC_Product|WP_Error|mixed
     */
    public function validate_rest_save( $product, $request = null, $creating = false ) {
        if ( ! $product instanceof WC_Product || ! self::applies_to_current_user() ) {
            return $product;
        }

        $categories = isset( $request['categories'] ) ? $product->get_category_ids() : null;
        $gallery    = isset( $request['images'] ) ? count( $product->get_gallery_image_ids() ) : null;

        // Only hydrate the stored product when a limit is actually being checked; a batch of price edits pays nothing.
        $stored = ( null !== $categories || null !== $gallery ) && ! $creating && $product->get_id()
            ? wc_get_product( $product->get_id() )
            : null;

        // Judged on the assembled product, so the check does not depend on how a client spells the fields.
        $error = $this->validate( $categories, $gallery, $stored instanceof WC_Product ? $stored : null );

        return $error ?? $product;
    }

    /**
     * Reject a legacy dashboard product creation in a category the vendor's pack does not allow.
     *
     * @since 5.0.18
     *
     * @param WP_Error|string|mixed $errors Error from an earlier callback, or an empty string.
     * @param array|mixed           $data   Submitted form data.
     *
     * @return WP_Error|string|mixed
     */
    public function validate_legacy_create( $errors, $data ) {
        if ( is_wp_error( $errors ) || ! is_array( $data ) || ! self::applies_to_current_user() ) {
            return $errors;
        }

        $category_ids = $this->read_posted_categories( $data );

        if ( null === $category_ids ) {
            return $errors;
        }

        // The gallery cap on this path is already enforced by Module::restrict_gallery_image_on_product_create().
        return $this->validate_categories( $category_ids, [] ) ?? $errors;
    }

    /**
     * Reject a legacy dashboard add-form submission that breaks the vendor's subscription limits.
     *
     * Reached by the simple-auction add form, and by Lite's own add form while Pro hides it.
     *
     * @since 5.0.18
     *
     * @param array|mixed $errors Messages collected so far; null when Module::seller_add_products() found no product cap.
     *
     * @return array|mixed
     */
    public function validate_legacy_add( $errors ) {
        if ( ! self::applies_to_current_user() ) {
            return $errors;
        }

        [ $category_ids, $gallery_count ] = $this->read_legacy_post();

        $error = $this->validate( $category_ids, $gallery_count, null );

        if ( $error ) {
            // seller_add_products() at priority 1 returns nothing when the pack has no product cap, so $errors may be null here.
            $errors   = is_array( $errors ) ? $errors : [];
            $errors[] = rtrim( $error->get_error_message(), '.' );
        }

        return $errors;
    }

    /**
     * Reject a legacy dashboard edit that breaks the vendor's subscription limits.
     *
     * @since 5.0.18
     *
     * @param array|mixed $errors Messages collected so far.
     *
     * @return array|mixed
     */
    public function validate_legacy_edit( $errors ) {
        if ( ! is_array( $errors ) || ! self::applies_to_current_user() ) {
            return $errors;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- handle_product_update() verifies the nonce before this filter fires.
        $stored = wc_get_product( isset( $_POST['dokan_product_id'] ) ? absint( $_POST['dokan_product_id'] ) : 0 );

        if ( ! $stored instanceof WC_Product ) {
            return $errors;
        }

        [ $category_ids, $gallery_count ] = $this->read_legacy_post();

        $error = $this->validate( $category_ids, $gallery_count, $stored );

        if ( $error ) {
            // The legacy form appends its own full stop to every message it renders.
            $errors[] = rtrim( $error->get_error_message(), '.' );
        }

        return $errors;
    }

    /**
     * Read the categories and gallery the legacy dashboard forms post.
     *
     * @since 5.0.18
     *
     * @return array{0: int[]|null, 1: int|null} Null entries when the form leaves that field alone.
     */
    protected function read_legacy_post(): array {
        // phpcs:disable WordPress.Security.NonceVerification.Missing -- every caller sits behind a filter the form handler fires after verifying its nonce.
        $posted = [];

        foreach ( [ 'chosen_product_cat', 'product_cat' ] as $key ) {
            if ( isset( $_POST[ $key ] ) ) {
                $posted[ $key ] = array_map( 'absint', (array) wp_unslash( $_POST[ $key ] ) );
            }
        }

        return [
            $this->read_posted_categories( $posted ),
            isset( $_POST['product_image_gallery'] ) ? count( wp_parse_id_list( sanitize_text_field( wp_unslash( $_POST['product_image_gallery'] ) ) ) ) : null,
        ];
        // phpcs:enable WordPress.Security.NonceVerification.Missing
    }

    /**
     * Read the categories a legacy form submission applies, the way Lite's handlers read them.
     *
     * `chosen_product_cat` wins; without it every handler falls back to `product_cat`, and while the
     * category selection style is single only the first entry is applied.
     *
     * @since 5.0.18
     *
     * @param array $data Submitted form data.
     *
     * @return int[]|null Null when the submission carries no category field at all.
     */
    protected function read_posted_categories( array $data ): ?array {
        if ( isset( $data['chosen_product_cat'] ) ) {
            $ids = $data['chosen_product_cat'];
        } elseif ( isset( $data['product_cat'] ) ) {
            $ids = $data['product_cat'];
        } else {
            return null;
        }

        $ids = array_values( array_filter( array_map( 'absint', (array) $ids ) ) );

        return CategoryHelper::product_category_selection_is_single() ? array_slice( $ids, 0, 1 ) : $ids;
    }

    /**
     * Offer the product editor's category picker the same categories the legacy picker offers.
     *
     * @since 5.0.18
     *
     * @param array|mixed $tree Nested category tree.
     *
     * @return array|mixed
     */
    public function filter_categories_tree( $tree ) {
        if ( ! is_array( $tree ) || ! self::applies_to_current_user() ) {
            return $tree;
        }

        $allowed = self::get_allowed_category_ids( dokan_get_current_user_id() );

        return empty( $allowed ) ? $tree : $this->prune_categories_tree( $tree, array_flip( $allowed ) );
    }

    /**
     * Give the product editor's gallery field the pack's cap and a note the vendor can read.
     *
     * Runs after the Product Form Manager's own overrides at priority 10, so its description survives.
     *
     * @since 5.0.18
     *
     * @param array|mixed $items      Prepared form schema.
     * @param int         $product_id Product being edited, 0 when creating.
     *
     * @return array|mixed
     */
    public function annotate_gallery_field( $items, $product_id = 0 ) {
        if ( ! is_array( $items ) || ! self::applies_to_current_user() ) {
            return $items;
        }

        $limit = self::get_gallery_image_limit( dokan_get_current_user_id() );

        if ( $limit < 0 ) {
            return $items;
        }

        $product = $product_id ? wc_get_product( $product_id ) : null;
        $current = $product instanceof WC_Product ? count( $product->get_gallery_image_ids() ) : 0;

        foreach ( $items as $key => $item ) {
            if ( 'field' === ( $item['type'] ?? '' ) && Elements::GALLERY_IMAGE_IDS === ( $item['id'] ?? '' ) ) {
                // `max` is what the editor's gallery field reads to stop offering uploads at the cap.
                $items[ $key ]['max']         = $limit;
                $items[ $key ]['description'] = trim( ( $item['description'] ?? '' ) . ' ' . self::gallery_note( $limit, $current ) );
                break;
            }
        }

        return $items;
    }

    /**
     * Forget every limit resolved so far.
     *
     * @since 5.0.18
     *
     * @return void
     */
    public static function flush() {
        self::$allowed_categories = [];
        self::$gallery_limits     = [];
    }

    /**
     * Whether the pack's limits apply to the current user at all.
     *
     * @since 5.0.18
     *
     * @return bool
     */
    public static function applies_to_current_user(): bool {
        // The setting gates enforcement, not data — with it off nothing is restricted (docs/adr/0010).
        if ( ! Helper::is_subscription_module_enabled() ) {
            return false;
        }

        // Admins and shop managers are exempt; a seller check alone misses them, as Dokan grants admins `dokandar`.
        if ( current_user_can( 'manage_woocommerce' ) ) {
            return false;
        }

        // Not gated on dokan_is_seller_enabled(): a seller switched off for selling can still reach wc/v3, and their pack still binds them.
        return dokan_is_user_seller( dokan_get_current_user_id() );
    }

    /**
     * Get the category ids a vendor's subscription pack allows.
     *
     * @since 5.0.18
     *
     * @param int $vendor_id Vendor to resolve the pack for.
     *
     * @return int[] Empty when the pack places no category restriction on the vendor.
     */
    public static function get_allowed_category_ids( int $vendor_id ): array {
        if ( isset( self::$allowed_categories[ $vendor_id ] ) ) {
            return self::$allowed_categories[ $vendor_id ];
        }

        self::$allowed_categories[ $vendor_id ] = [];

        $subscription = dokan()->vendor->get( $vendor_id )->subscription;

        // The pack binds the vendor for as long as it is assigned, expired or not; product slots are ProductStatusChanger's concern.
        if ( ! $subscription || ! $subscription->has_subscription() ) {
            return self::$allowed_categories[ $vendor_id ];
        }

        // A per-vendor override wins over the pack's own selection.
        $override = get_user_meta( $vendor_id, 'vendor_allowed_categories', true );
        $selected = ! empty( $override ) ? $override : $subscription->get_allowed_product_categories();
        $selected = array_filter( wp_parse_id_list( (array) apply_filters( 'dokan_pro_subscription_allowed_categories', $selected ) ) );

        if ( empty( $selected ) ) {
            return self::$allowed_categories[ $vendor_id ];
        }

        $categories = new Categories();
        $categories->get_all_categories();

        $allowed = $selected;

        // Each selection carries its ancestors and its whole sub-tree, as the legacy multistep picker always offered.
        foreach ( $selected as $category_id ) {
            array_push( $allowed, ...$categories->get_parents( $category_id ), ...$categories->get_children( $category_id ) );
        }

        self::$allowed_categories[ $vendor_id ] = array_values( array_filter( wp_parse_id_list( $allowed ) ) );

        return self::$allowed_categories[ $vendor_id ];
    }

    /**
     * Get the number of gallery images a vendor's subscription pack allows.
     *
     * @since 5.0.18
     *
     * @param int $vendor_id Vendor to resolve the pack for.
     *
     * @return int Negative when the pack places no gallery restriction on the vendor.
     */
    public static function get_gallery_image_limit( int $vendor_id ): int {
        if ( ! isset( self::$gallery_limits[ $vendor_id ] ) ) {
            $subscription = dokan()->vendor->get( $vendor_id )->subscription;

            self::$gallery_limits[ $vendor_id ] = $subscription && $subscription->is_gallery_image_upload_restricted()
                ? (int) $subscription->gallery_image_upload_count()
                : -1;
        }

        return self::$gallery_limits[ $vendor_id ];
    }

    /**
     * Check a save against the pack's limits.
     *
     * @since 5.0.18
     *
     * @param array|null      $category_ids  Categories the product will have; null when the save leaves them alone.
     * @param int|null        $gallery_count Gallery images the product will have; null when the save leaves them alone.
     * @param WC_Product|null $stored        The product as stored now; null when creating.
     *
     * @return WP_Error|null Null when the save is within the pack's limits.
     */
    protected function validate( ?array $category_ids, ?int $gallery_count, ?WC_Product $stored ): ?WP_Error {
        if ( null !== $category_ids ) {
            $error = $this->validate_categories( $category_ids, $stored ? $stored->get_category_ids() : [] );

            if ( $error ) {
                return $error;
            }
        }

        if ( null !== $gallery_count ) {
            return $this->validate_gallery( $gallery_count, $stored ? count( $stored->get_gallery_image_ids() ) : null );
        }

        return null;
    }

    /**
     * Reject only the disallowed categories the save adds.
     *
     * @since 5.0.18
     *
     * @param array $category_ids Categories the product will have after the save.
     * @param array $stored_ids   Categories the product has now; empty when creating.
     *
     * @return WP_Error|null
     */
    protected function validate_categories( array $category_ids, array $stored_ids ): ?WP_Error {
        $allowed = self::get_allowed_category_ids( dokan_get_current_user_id() );

        if ( empty( $allowed ) ) {
            return null;
        }

        // A disallowed category the product already carried is left alone, so a product that predates the pack stays editable.
        $added = array_diff( wp_parse_id_list( $category_ids ), $allowed, wp_parse_id_list( $stored_ids ) );

        if ( empty( $added ) ) {
            return null;
        }

        $names = wp_list_pluck(
            get_terms(
                [
                    'taxonomy'   => 'product_cat',
                    'include'    => array_values( $added ),
                    'hide_empty' => false,
                ]
            ),
            'name'
        );

        return new WP_Error(
            'dokan_pro_subscription_category_not_allowed',
            sprintf(
                /* translators: %s: comma separated list of category names the subscription pack does not allow. */
                __( 'Your subscription plan does not allow these product categories: %s. Please pick a category your plan allows.', 'dokan' ),
                implode( ', ', $names )
            ),
            [ 'status' => 400 ]
        );
    }

    /**
     * Reject a gallery that grows past the pack's cap.
     *
     * @since 5.0.18
     *
     * @param int      $count        Gallery images the product will have after the save.
     * @param int|null $stored_count Gallery images the product has now; null when creating.
     *
     * @return WP_Error|null
     */
    protected function validate_gallery( int $count, ?int $stored_count ): ?WP_Error {
        $limit = self::get_gallery_image_limit( dokan_get_current_user_id() );

        if ( $limit < 0 || $count <= $limit ) {
            return null;
        }

        // A product already over the cap may keep or reduce its gallery; images are never deleted for the vendor.
        if ( null !== $stored_count && $count <= $stored_count ) {
            return null;
        }

        return new WP_Error(
            'dokan_pro_subscription_gallery_limit_exceeded',
            sprintf(
                /* translators: 1: number of gallery images the subscription pack allows, 2: number of gallery images submitted. */
                _n(
                    'Your subscription plan allows %1$d gallery image, but %2$d were submitted. Please remove the extra images.',
                    'Your subscription plan allows %1$d gallery images, but %2$d were submitted. Please remove the extra images.',
                    $limit,
                    'dokan'
                ),
                $limit,
                $count
            ),
            [ 'status' => 400 ]
        );
    }

    /**
     * Build the gallery field's plan note.
     *
     * @since 5.0.18
     *
     * @param int $limit   Gallery images the pack allows.
     * @param int $current Gallery images the product has.
     *
     * @return string
     */
    public static function gallery_note( int $limit, int $current ): string {
        if ( $current > $limit ) {
            return sprintf(
                /* translators: 1: number of gallery images the subscription pack allows, 2: number of gallery images the product has. */
                __( 'Your plan allows %1$d gallery images, but this product has %2$d. Only the first %1$d are shown to customers, and you cannot add more until you remove the extras.', 'dokan' ),
                $limit,
                $current
            );
        }

        return sprintf(
            /* translators: %d: number of gallery images the subscription pack allows. */
            _n( 'Your plan allows %d gallery image.', 'Your plan allows up to %d gallery images.', $limit, 'dokan' ),
            $limit
        );
    }

    /**
     * Keep only the allowed categories in a nested category tree.
     *
     * @since 5.0.18
     *
     * @param array $tree    Nested category tree.
     * @param array $allowed Allowed category ids as array keys.
     *
     * @return array
     */
    protected function prune_categories_tree( array $tree, array $allowed ): array {
        $pruned = [];

        // Dropping a branch never hides an allowed category: the allowed set already carries every ancestor.
        foreach ( $tree as $node ) {
            if ( ! isset( $allowed[ absint( $node['term_id'] ?? 0 ) ] ) ) {
                continue;
            }

            if ( is_array( $node['children'] ?? null ) ) {
                $node['children'] = $this->prune_categories_tree( $node['children'], $allowed );
            }

            $pruned[] = $node;
        }

        return $pruned;
    }
}
