<?php

/*
 * This file is part of the VITICORE2 package.
 *
 * (c) CP Creation <web@cpcreation.fr>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CpCreation\VitiCore\Cart\LegacyOrderImport;

use CpCreation\VitiCore\Cart\CartPricing;
use CpCreation\VitiCore\Cart\CartPricingLine;
use CpCreation\VitiCore\Cart\CartPromo;
use CpCreation\VitiCore\Cart\CartPromos;
use CpCreation\VitiCore\Cart\CartShipping;

final class LegacyPricingSnapshotBuilder
{
    /**
     * @param array{
     *     tax?: int|float|string|null,
     *     view_ht?: bool|int|string|null,
     *     shipping?: int|float|string|null,
     *     shipping_name?: string|null,
     *     promo?: string|null,
     *     promo_value_amount?: int|float|string|null,
     *     promo_value_percent?: int|float|string|null,
     *     promo_with_shipping_fees?: bool|int|string|null,
     *     degressive_value?: int|float|string|null
     * } $cart
     * @param list<array{
     *     item_id?: string|null,
     *     product_id?: string|null,
     *     product_name?: string|null,
     *     variant_id?: string|null,
     *     variant_full_name?: string|null,
     *     type_id?: string|null,
     *     type_name?: string|null,
     *     unit_label?: string|null,
     *     image_path?: string|null,
     *     quantity: int|float|string,
     *     price: int|float|string
     * }> $lines
     *
     * @return array{
     *     cartPricing: array<string, mixed>,
     *     cartShipping: array<string, mixed>,
     *     cartPromos: array<string, mixed>,
     *     legacyTotalTTC: int
     * }
     */
    public function build(array $cart, array $lines): array
    {
        $taxRate = $this->taxRate($cart['tax'] ?? null);
        $viewHT = $this->boolean($cart['view_ht'] ?? false);
        $shippingSource = $this->money($cart['shipping'] ?? 0, 'shipping');
        $degressiveSource = $this->money($cart['degressive_value'] ?? 0, 'degressive_value');
        $promoAmountSource = $this->nullableMoney($cart['promo_value_amount'] ?? null, 'promo_value_amount');
        $promoPercent = $this->nullableFloat($cart['promo_value_percent'] ?? null, 'promo_value_percent');
        $normalizedLines = $this->normalizeLines($lines);
        $itemsSource = array_sum(array_column($normalizedLines, 'totalSource'));
        $promoPercentSource = null !== $promoPercent && 0.0 !== $promoPercent
            ? (int) ceil($itemsSource * $promoPercent)
            : 0;
        $promoSource = $promoPercentSource + ($promoAmountSource ?? 0);
        $legacyTotalSource = $itemsSource + $shippingSource - $promoSource - $degressiveSource;
        $legacyTotalTTC = $viewHT
            ? $this->legacyHTToTTC($legacyTotalSource, $taxRate)
            : $legacyTotalSource;

        $sourceDegressiveAllocations = $this->allocate(
            $degressiveSource,
            array_column($normalizedLines, 'totalSource'),
            $normalizedLines,
        );
        $targetDegressiveTTC = $viewHT
            ? $this->legacyHTToTTC($degressiveSource, $taxRate)
            : $degressiveSource;
        $targetDegressiveAllocations = $viewHT
            ? $this->allocate($targetDegressiveTTC, $sourceDegressiveAllocations, $normalizedLines)
            : $sourceDegressiveAllocations;

        $targetGrossTotals = array_fill(0, \count($normalizedLines), 0);

        foreach ($normalizedLines as $index => $line) {
            $targetGrossTotals[$index] = $viewHT
                ? $this->legacyHTToTTC($line['totalSource'], $taxRate)
                : $line['totalSource'];
        }

        $shippingTTC = $viewHT ? $this->legacyHTToTTC($shippingSource, $taxRate) : $shippingSource;
        $promoTTC = $viewHT ? $this->legacyHTToTTC($promoSource, $taxRate) : $promoSource;
        $subtotalTTC = array_sum($targetGrossTotals) - array_sum($targetDegressiveAllocations);
        $roundingResidual = $legacyTotalTTC - ($subtotalTTC + $shippingTTC - $promoTTC);

        if (0 !== $roundingResidual) {
            if ([] !== $normalizedLines) {
                // The legacy code applies tax once to the complete cart. Assign its
                // indivisible cent residue to a stable line so the snapshot remains exact.
                $targetGrossTotals[$this->roundingRecipient($normalizedLines)] += $roundingResidual;
            } else {
                $shippingTTC += $roundingResidual;
            }
        }

        $targetGrossTotals = array_values($targetGrossTotals);

        $pricingLines = $this->buildPricingLines(
            $normalizedLines,
            $sourceDegressiveAllocations,
            $targetDegressiveAllocations,
            $targetGrossTotals,
            $taxRate,
            $viewHT,
        );
        $subtotalTTC = array_sum(array_column($pricingLines, 'netTTC'));
        $subtotalHT = array_sum(array_column($pricingLines, 'netHT'));
        $degressiveTTC = array_sum(array_column($pricingLines, 'degressiveTTC'));
        $degressiveHT = array_sum(array_column($pricingLines, 'degressiveHT'));
        $shippingHT = $viewHT ? $shippingSource : $this->computeHTFromTTC($shippingTTC, $taxRate);
        $promoHT = $viewHT ? $promoSource : $this->computeHTFromTTC($promoTTC, $taxRate);
        $totalHT = $subtotalHT + $shippingHT - $promoHT;
        $promoCode = $this->nullableString($cart['promo'] ?? null);
        $promoAmountTTC = null === $promoAmountSource
            ? null
            : ($viewHT ? $this->legacyHTToTTC($promoAmountSource, $taxRate) : $promoAmountSource);
        $cartPromos = [
            '#type' => CartPromos::class,
            'items' => [],
        ];

        if (null !== $promoCode || null !== $promoAmountSource || null !== $promoPercent) {
            $cartPromos['items'][] = [
                '#type' => CartPromo::class,
                'code' => $promoCode,
                'title' => $promoCode,
                'promoId' => null,
                'auto' => false,
                'amountTTC' => $promoAmountTTC,
                'percent' => $promoPercent,
                // Cart::getTotal() from master always applied its percentage to items,
                // even when this historical display flag was true.
                'onShipping' => false,
                'withShippingFees' => $this->boolean($cart['promo_with_shipping_fees'] ?? false),
                'discountItemsTTC' => $promoTTC,
                'discountShippingTTC' => 0,
                'giftVariantId' => null,
                'giftProductName' => null,
                'giftTypeName' => null,
                'giftUnitLabel' => null,
                'giftImagePath' => null,
                'giftOriginalUnitPriceTTC' => null,
                'giftQuantity' => 0,
            ];
        }

        $cartPricing = [
            '#type' => CartPricing::class,
            'lines' => $pricingLines,
            'taxRate' => $taxRate,
            'subtotalHT' => $subtotalHT,
            'subtotalTax' => $subtotalTTC - $subtotalHT,
            'subtotalTTC' => $subtotalTTC,
            'discountHT' => $promoHT,
            'discountTax' => $promoTTC - $promoHT,
            'discountTTC' => $promoTTC,
            'degressiveHT' => $degressiveHT,
            'degressiveTax' => $degressiveTTC - $degressiveHT,
            'degressiveTTC' => $degressiveTTC,
            'totalHT' => $totalHT,
            'totalTax' => $legacyTotalTTC - $totalHT,
            'totalTTC' => $legacyTotalTTC,
            'degressiveIsBottleOffered' => false,
        ];

        return [
            'cartPricing' => $cartPricing,
            'cartShipping' => [
                '#type' => CartShipping::class,
                'amountTTC' => $shippingTTC,
                'taxRate' => $taxRate,
                'name' => $this->nullableString($cart['shipping_name'] ?? null),
            ],
            'cartPromos' => $cartPromos,
            'legacyTotalTTC' => $legacyTotalTTC,
        ];
    }

    /**
     * @param list<array<string, mixed>> $lines
     *
     * @return list<array{
     *     index: int,
     *     identity: string,
     *     itemId: ?string,
     *     productId: ?string,
     *     productName: ?string,
     *     variantId: ?string,
     *     variantFullName: ?string,
     *     typeId: ?string,
     *     typeName: ?string,
     *     unitLabel: ?string,
     *     imagePath: ?string,
     *     quantity: int,
     *     unitPriceSource: int,
     *     totalSource: int
     * }>
     */
    private function normalizeLines(array $lines): array
    {
        $normalized = [];

        foreach ($lines as $index => $line) {
            $quantity = $this->money($line['quantity'] ?? null, \sprintf('lines.%d.quantity', $index));
            $price = $this->money($line['price'] ?? null, \sprintf('lines.%d.price', $index));
            $itemId = $this->nullableString($line['item_id'] ?? null);

            $normalized[] = [
                'index' => $index,
                'identity' => $itemId ?? \sprintf('~%010d', $index),
                'itemId' => $itemId,
                'productId' => $this->nullableString($line['product_id'] ?? null),
                'productName' => $this->nullableString($line['product_name'] ?? null),
                'variantId' => $this->nullableString($line['variant_id'] ?? null),
                'variantFullName' => $this->nullableString($line['variant_full_name'] ?? null),
                'typeId' => $this->nullableString($line['type_id'] ?? null),
                'typeName' => $this->nullableString($line['type_name'] ?? null),
                'unitLabel' => $this->nullableString($line['unit_label'] ?? null),
                'imagePath' => $this->nullableString($line['image_path'] ?? null),
                'quantity' => $quantity,
                'unitPriceSource' => $price,
                'totalSource' => $quantity * $price,
            ];
        }

        return $normalized;
    }

    /**
     * @param list<array<string, mixed>> $lines
     * @param list<int>                  $sourceDegressiveAllocations
     * @param list<int>                  $targetDegressiveAllocations
     * @param list<int>                  $targetGrossTotals
     *
     * @return list<array<string, mixed>>
     */
    private function buildPricingLines(
        array $lines,
        array $sourceDegressiveAllocations,
        array $targetDegressiveAllocations,
        array $targetGrossTotals,
        float $taxRate,
        bool $viewHT,
    ): array {
        $snapshots = [];

        foreach ($lines as $index => $line) {
            $grossTTC = $targetGrossTotals[$index];
            $grossHT = $viewHT ? $line['totalSource'] : $this->computeHTFromTTC($grossTTC, $taxRate);
            $degressiveTTC = $targetDegressiveAllocations[$index];
            $degressiveHT = $viewHT
                ? $sourceDegressiveAllocations[$index]
                : $this->computeHTFromTTC($degressiveTTC, $taxRate);
            $netTTC = $grossTTC - $degressiveTTC;
            $netHT = $viewHT
                ? $line['totalSource'] - $sourceDegressiveAllocations[$index]
                : $this->computeHTFromTTC($netTTC, $taxRate);
            $unitPriceTTC = $viewHT
                ? $this->legacyHTToTTC($line['unitPriceSource'], $taxRate)
                : $line['unitPriceSource'];
            $unitPriceHT = $viewHT
                ? $line['unitPriceSource']
                : $this->computeHTFromTTC($unitPriceTTC, $taxRate);

            $snapshots[] = [
                '#type' => CartPricingLine::class,
                'itemId' => $line['itemId'],
                'productId' => $line['productId'],
                'productName' => $line['productName'],
                'variantId' => $line['variantId'],
                'variantFullName' => $line['variantFullName'],
                'typeId' => $line['typeId'],
                'typeName' => $line['typeName'],
                'unitLabel' => $line['unitLabel'],
                'imagePath' => $line['imagePath'],
                'quantity' => $line['quantity'],
                'unitPriceHT' => $unitPriceHT,
                'unitPriceTTC' => $unitPriceTTC,
                'unitPriceDiscountHT' => null,
                'unitPriceDiscountTTC' => null,
                'totalHT' => $grossHT,
                'totalTTC' => $grossTTC,
                'totalDiscountTTC' => 0,
                'totalDiscountHT' => 0,
                'unitDegressiveTTC' => null,
                'unitDegressiveHT' => null,
                'degressiveTTC' => $degressiveTTC,
                'degressiveHT' => $degressiveHT,
                'netTTC' => $netTTC,
                'netHT' => $netHT,
                'tax' => $netTTC - $netHT,
                'isPromoGift' => false,
                'optionName' => null,
                'optionFlatFee' => false,
                'optionPriceTTC' => 0,
                'optionPriceHT' => 0,
                'optionTotalTTC' => 0,
                'optionTotalHT' => 0,
                'option' => null,
            ];
        }

        return $snapshots;
    }

    /**
     * @param list<int>                  $weights
     * @param list<array<string, mixed>> $lines
     *
     * @return list<int>
     */
    private function allocate(int $amount, array $weights, array $lines): array
    {
        $allocations = array_fill(0, \count($lines), 0);

        if (0 === $amount) {
            return $allocations;
        }

        if ([] === $lines) {
            throw new \InvalidArgumentException('A degressive amount cannot be allocated without order lines.');
        }

        $totalWeight = array_sum($weights);

        if (0 === $totalWeight) {
            $allocations[$this->stableIndexes($lines)[0]] = $amount;

            return array_values($allocations);
        }

        $remainders = [];
        $allocated = 0;

        foreach ($weights as $index => $weight) {
            $numerator = $amount * $weight;
            $allocations[$index] = intdiv($numerator, $totalWeight);
            $allocated += $allocations[$index];
            $remainders[] = [
                'index' => $index,
                'remainder' => $numerator % $totalWeight,
                'identity' => $lines[$index]['identity'],
            ];
        }

        usort($remainders, static function (array $left, array $right): int {
            return $right['remainder'] <=> $left['remainder']
                ?: $left['identity'] <=> $right['identity']
                ?: $left['index'] <=> $right['index'];
        });

        for ($index = 0, $remaining = $amount - $allocated; $index < $remaining; ++$index) {
            ++$allocations[$remainders[$index]['index']];
        }

        return array_values($allocations);
    }

    /**
     * @param list<array<string, mixed>> $lines
     */
    private function roundingRecipient(array $lines): int
    {
        $indexes = $this->stableIndexes($lines);
        $lastIndex = array_key_last($indexes);

        if (null === $lastIndex) {
            throw new \LogicException('A rounding recipient requires at least one order line.');
        }

        return $indexes[$lastIndex];
    }

    /**
     * @param list<array<string, mixed>> $lines
     *
     * @return list<int>
     */
    private function stableIndexes(array $lines): array
    {
        $indexes = array_keys($lines);

        usort($indexes, static function (int $left, int $right) use ($lines): int {
            return $lines[$left]['identity'] <=> $lines[$right]['identity']
                ?: $left <=> $right;
        });

        return $indexes;
    }

    private function legacyHTToTTC(int $amountHT, float $taxRate): int
    {
        // Cart::getTotal() on master rounded to two decimals and then coerced the
        // result to its declared integer return type. The explicit cast preserves it.
        return (int) round($amountHT * (1 + $taxRate), 2);
    }

    private function computeHTFromTTC(int $amountTTC, float $taxRate): int
    {
        if (0.0 === $taxRate) {
            return $amountTTC;
        }

        return (int) round($amountTTC / (1 + $taxRate), 0, \PHP_ROUND_HALF_UP);
    }

    private function taxRate(mixed $value): float
    {
        $taxRate = $this->nullableFloat($value, 'tax') ?? 0.0;

        if ($taxRate < 0.0) {
            throw new \InvalidArgumentException('tax must be greater than or equal to zero.');
        }

        return $taxRate;
    }

    private function nullableFloat(mixed $value, string $field): ?float
    {
        if (null === $value || '' === $value) {
            return null;
        }

        if (!is_numeric($value)) {
            throw new \InvalidArgumentException(\sprintf('%s must be numeric.', $field));
        }

        $number = (float) $value;

        if (!is_finite($number) || $number < 0.0) {
            throw new \InvalidArgumentException(\sprintf('%s must be a finite positive value.', $field));
        }

        return $number;
    }

    private function nullableMoney(mixed $value, string $field): ?int
    {
        if (null === $value || '' === $value) {
            return null;
        }

        return $this->money($value, $field);
    }

    private function money(mixed $value, string $field): int
    {
        if (!is_numeric($value)) {
            throw new \InvalidArgumentException(\sprintf('%s must be an integer amount in cents.', $field));
        }

        $number = (float) $value;
        $amount = (int) $number;

        if (!is_finite($number) || $number < 0.0 || (float) $amount !== $number) {
            throw new \InvalidArgumentException(\sprintf('%s must be a positive integer amount in cents.', $field));
        }

        return $amount;
    }

    private function boolean(mixed $value): bool
    {
        return match ($value) {
            true, 1, '1', 'true', 'TRUE' => true,
            default => false,
        };
    }

    private function nullableString(mixed $value): ?string
    {
        if (!\is_string($value)) {
            return null;
        }

        $value = mb_trim($value);

        return '' === $value ? null : $value;
    }
}
