<?php

namespace SoftminalB2B\Models;

if (!defined('ABSPATH')) {
    exit;
}

/**
 * How much of something a buyer must order (FEATURES A5).
 *
 * Free ships per-product rules. Pro adds group-wide rules and order-value
 * minimums, which is why `group_id`, the global scope and `min_order_value`
 * exist here from the start rather than arriving as a later migration.
 *
 * @property int|null    $id
 * @property int|null    $group_id
 * @property string      $scope
 * @property int         $object_id
 * @property int|null    $min_qty
 * @property int|null    $max_qty
 * @property int|null    $step_qty
 * @property string|null $min_order_value
 * @property bool        $active
 */
class QuantityRule extends Model
{
    protected $table = 'quantity_rules';
    protected $timestamps = true;

    protected $fillable = [
        'group_id',
        'scope',
        'object_id',
        'min_qty',
        'max_qty',
        'step_qty',
        'min_order_value',
        'active',
        // Nullable rule ownership (DB 1.8.0) — see PriceRule; used by the Dokan
        // bridge's vendor self-service (Phase 5). Admin rules leave them null.
        'owner_id',
        'owner_type',
    ];

    protected $casts = [
        'active' => 'boolean',
    ];

    /** A single product. */
    public const SCOPE_PRODUCT = 'product';

    /** Every product in a category — Pro. */
    public const SCOPE_CATEGORY = 'category';

    /** The whole order — Pro (minimum order value / total units). */
    public const SCOPE_GLOBAL = 'global';

    /**
     * Every product of one marketplace vendor — the Dokan bridge (`object_id` =
     * the vendor's WP user id). Recognised by core so a per-vendor MOQ/step
     * persists and enforces without a schema change; only creatable with the
     * bridge active, never in Free.
     */
    public const SCOPE_VENDOR = 'vendor';

    /**
     * Every scope the schema understands. Category, global and vendor are Pro —
     * the controller gates them; core still recognises the values so Pro (and
     * the Dokan bridge) can create and core can enforce them without a schema
     * change.
     *
     * @return string[]
     */
    public static function scopes(): array
    {
        return [self::SCOPE_PRODUCT, self::SCOPE_CATEGORY, self::SCOPE_VENDOR, self::SCOPE_GLOBAL];
    }

    /** Scopes the free version can create; the rest need Pro. */
    public static function freeScopes(): array
    {
        return [self::SCOPE_PRODUCT];
    }

    public function group()
    {
        return $this->belongsTo(Group::class, 'group_id');
    }

    /**
     * Does this quantity satisfy the rule? Returns null when it does, or a
     * shopper-facing reason when it does not.
     *
     * The message names the product and the number to use, because "invalid
     * quantity" tells a buyer nothing about what to do next.
     */
    public function violationFor(int $qty, string $productName): ?string
    {
        $min  = $this->min_qty === null ? null : (int) $this->min_qty;
        $max  = $this->max_qty === null ? null : (int) $this->max_qty;
        $step = $this->step_qty === null ? null : (int) $this->step_qty;

        if ($min !== null && $qty < $min) {
            return sprintf(
                /* translators: 1: product name, 2: minimum quantity */
                __('%1$s has a minimum order of %2$d. Please increase the quantity.', 'softminal-b2b-wholesale-for-woocommerce'),
                $productName,
                $min
            );
        }

        if ($max !== null && $qty > $max) {
            return sprintf(
                /* translators: 1: product name, 2: maximum quantity */
                __('You can order at most %2$d of %1$s.', 'softminal-b2b-wholesale-for-woocommerce'),
                $productName,
                $max
            );
        }

        if ($step !== null && $step > 1) {
            // Steps count from the minimum, so a "packs of 6, minimum 12" rule
            // accepts 12, 18, 24 rather than 6, 12, 18.
            $offset = $min !== null ? $min : 0;

            if ((($qty - $offset) % $step) !== 0) {
                return sprintf(
                    /* translators: 1: product name, 2: step quantity, 3: nearest valid quantity */
                    __('%1$s is sold in packs of %2$d. Try %3$d instead.', 'softminal-b2b-wholesale-for-woocommerce'),
                    $productName,
                    $step,
                    $this->nearestValid($qty)
                );
            }
        }

        return null;
    }

    /**
     * The closest quantity at or above $qty that satisfies min, step and max.
     *
     * The number this returns is what the violation message tells the shopper to
     * enter, so it must itself pass violationFor() — the two methods therefore
     * share one step offset (min, or 0 when there is no minimum), and when the
     * grid overshoots max we step *back down* to the largest valid quantity at or
     * below max rather than returning a max that may be off the step grid.
     */
    public function nearestValid(int $qty): int
    {
        $min  = $this->min_qty === null ? null : max(1, (int) $this->min_qty);
        $max  = $this->max_qty === null ? null : (int) $this->max_qty;
        $step = $this->step_qty === null ? null : max(1, (int) $this->step_qty);

        // A valid quantity must reach the explicit minimum, or 1 when there is
        // none — no cart line can be zero.
        $floor = $min ?? 1;
        $value = max($qty, $floor);

        if ($step !== null && $step > 1) {
            // Same offset violationFor() counts steps from. Rounding a value of
            // at least 1 up onto this grid already lands on the first valid
            // quantity (the minimum, or the first positive multiple of the pack
            // when there is no minimum), so no extra floor is needed here.
            $offset    = $min ?? 0;
            $remainder = ($value - $offset) % $step;

            if ($remainder !== 0) {
                $value += $step - $remainder;
            }
        }

        if ($max !== null && $value > $max) {
            $value = $max;

            // Round the cap down onto the step grid so the suggestion is valid.
            if ($step !== null && $step > 1) {
                $offset = $min ?? 0;
                $value -= (($value - $offset) % $step);
            }

            if ($value < $floor) {
                $value = $floor;
            }
        }

        return $value;
    }
}
