<?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\CartPromo;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Types\Types;
use League\ISO3166\ISO3166;
use Ramsey\Uuid\Uuid;

final class LegacyOrderImporter
{
    private const string RUN_TABLE = 'cart_legacy_order_import_run';
    private const string MAP_TABLE = 'cart_legacy_order_import_map';

    /**
     * @var array<string, list<string>>
     */
    private const array REQUIRED_TARGET_SCHEMA = [
        self::RUN_TABLE => [
            'id', 'source_key', 'source_label', 'scope', 'status', 'attempt_count', 'source_count', 'imported_count',
            'skipped_count', 'total_ttc', 'report', 'started_at', 'completed_at',
        ],
        self::MAP_TABLE => [
            'id', 'run_id', 'source_key', 'source_checkout_id', 'source_cart_id', 'target_cart_id', 'source_checksum', 'imported_at',
        ],
        'cart_cart' => [
            'id', 'delivery_method_id', 'invoice_address_id', 'delivery_address_id', 'user_id', 'invoice_id', 'token', 'tax', 'group_code', 'view_ht',
            'comment', 'delivery', 'cgv', 'locale', 'code_carrier', 'url_de_suivi_carrier', 'ip_client', 'document_type', 'expires_at', 'cart_pricing',
            'cart_shipping', 'cart_promos', 'promo_code_input', 'promo_code_status', 'promo_code_message_key', 'deleted_at',
            'created_at', 'updated_at', 'marketing_attribution', 'last_activity_at', 'abandoned_at', 'fulfilled_at', 'tracking_carrier',
        ],
        'cart_cart_item' => [
            'id', 'cart_cart_id', 'product_variant_id', 'quantity', 'selected_option_name', 'selected_option_price',
            'selected_option_flat_fee', 'sku_snapshot', 'created_at', 'updated_at',
        ],
        'cart_cart_checkout' => [
            'id', 'payment_method_id', 'cart_cart_id', 'payed_at', 'initiated_at', 'ordered_at', 'payed_token', 'response',
            'marketing_attribution', 'disabled', 'deleted_at', 'created_at', 'updated_at',
        ],
        'cart_delivery_address' => ['id', 'email', 'phone', 'lastname', 'firstname', 'company', 'siret', 'vat_id', 'street', 'postal', 'city', 'country'],
        'cart_invoice_address' => ['id', 'email', 'different_address', 'phone', 'lastname', 'firstname', 'company', 'siret', 'vat_id', 'street', 'postal', 'city', 'country'],
        'cart_payment_method' => ['id', 'method', 'disabled', 'deleted_at'],
        'shop_delivery_method' => ['id'],
        'product_variant' => ['id', 'product_id', 'product_type_id', 'sku', 'stock_at'],
        'product_product' => ['id', 'sku'],
    ];

    private ISO3166 $countries;
    private readonly LegacyAddressNormalizer $addressNormalizer;

    public function __construct(
        private readonly Connection $target,
        private readonly LegacyOrderSourceReader $sourceReader,
        private readonly LegacyPricingSnapshotBuilder $pricingSnapshotBuilder,
        ?LegacyAddressNormalizer $addressNormalizer = null,
    ) {
        $this->countries = new ISO3166();
        $this->addressNormalizer = $addressNormalizer ?? new LegacyAddressNormalizer();
    }

    public function sourceKey(string $sourceLabel): string
    {
        return hash('sha256', 'viti-core-master-orders:'.mb_strtolower(mb_trim($sourceLabel)));
    }

    /**
     * @return list<string>
     */
    public function validateTarget(): array
    {
        $errors = [];

        if (!$this->target->getDatabasePlatform() instanceof PostgreSQLPlatform) {
            $errors[] = 'La base cible courante doit utiliser PostgreSQL.';

            return $errors;
        }

        foreach (self::REQUIRED_TARGET_SCHEMA as $table => $requiredColumns) {
            $columnNames = $this->target->fetchFirstColumn(<<<'SQL'
                SELECT LOWER(column_name)
                FROM information_schema.columns
                WHERE table_schema = CURRENT_SCHEMA() AND table_name = :table_name
                SQL, ['table_name' => $table]);

            if ([] === $columnNames) {
                $errors[] = \sprintf(
                    'Table cible requise absente : %s. Lancez les migrations Doctrine avant l\'import.',
                    $table,
                );

                continue;
            }

            $columns = array_fill_keys(array_map('strval', $columnNames), true);

            foreach ($requiredColumns as $column) {
                if (!isset($columns[$column])) {
                    $errors[] = \sprintf('Colonne cible requise absente : %s.%s.', $table, $column);
                }
            }
        }

        return $errors;
    }

    /**
     * @return array<string, mixed>|null
     */
    public function findRun(string $sourceKey): ?array
    {
        $runTableExists = (int) $this->target->fetchOne(<<<'SQL'
            SELECT COUNT(*)
            FROM information_schema.tables
            WHERE table_schema = CURRENT_SCHEMA() AND table_name = :table_name
            SQL, ['table_name' => self::RUN_TABLE]) > 0;

        if (!$runTableExists) {
            return null;
        }

        $run = $this->target->fetchAssociative(
            \sprintf('SELECT * FROM %s WHERE source_key = :source_key', self::RUN_TABLE),
            ['source_key' => $sourceKey],
        );

        return false === $run ? null : $run;
    }

    public function prepare(
        Connection $source,
        LegacyOrderImportMapping $mapping,
        string $sourceLabel,
        string $scope,
    ): LegacyOrderImportPlan {
        if (!\in_array($scope, ['all', 'paid'], true)) {
            throw new \InvalidArgumentException('Le perimetre doit etre "all" ou "paid".');
        }

        $errors = [
            ...$this->sourceReader->validate($source),
            ...$this->validateTarget(),
        ];

        if (!$source->isTransactionActive()) {
            $errors[] = 'La source doit etre lue dans une transaction coherente en lecture seule.';
        }

        $warnings = [];
        $sourceKey = $this->sourceKey($sourceLabel);
        $errors = [
            ...$errors,
            ...$mapping->contextErrors($sourceLabel, $sourceKey, $scope),
        ];

        if ('' === mb_trim($sourceLabel) || mb_strlen($sourceLabel) > 255) {
            $errors[] = 'L\'identite de source doit contenir entre 1 et 255 caracteres.';
        }

        $existingRun = $this->target->getDatabasePlatform() instanceof PostgreSQLPlatform
            ? $this->findRun($sourceKey)
            : null;
        if (null !== $existingRun && (string) $existingRun['scope'] !== $scope) {
            $errors[] = \sprintf(
                'Cette source est deja tracee avec le perimetre "%s". Conservez exactement le meme --scope.',
                (string) $existingRun['scope'],
            );
        }

        if ([] !== $errors) {
            return new LegacyOrderImportPlan(
                $sourceKey,
                $sourceLabel,
                $scope,
                [],
                $warnings,
                $errors,
                $this->emptySummary(),
            );
        }

        $orders = $this->sourceReader->orders($source, $scope);
        $sourceCartOccurrences = [];

        foreach ($orders as $order) {
            $sourceCartId = (string) ($order['source_cart_id'] ?? '');
            $sourceCartOccurrences[$sourceCartId] = ($sourceCartOccurrences[$sourceCartId] ?? 0) + 1;
        }

        foreach ($sourceCartOccurrences as $sourceCartId => $occurrences) {
            if ($occurrences > 1) {
                $errors[] = \sprintf(
                    'Le panier source %s est rattache a %d checkouts non supprimes. Cette situation ambigue doit etre corrigee avant l\'import.',
                    '' === $sourceCartId ? '(identifiant absent)' : $sourceCartId,
                    $occurrences,
                );
            }
        }

        $linesByCart = $this->sourceReader->linesByCart($source, $scope);
        $giftCount = $this->sourceReader->giftCount($source, $scope);
        $resolvedVariants = $this->resolveVariants($linesByCart, $mapping, $errors, $warnings);
        $records = [];
        $summary = $this->emptySummary();
        $summary['orders'] = \count($orders);
        $summary['gift_cards'] = $giftCount;

        if ($giftCount > 0) {
            $errors[] = \sprintf(
                '%d carte(s) cadeau historique(s) sont rattachee(s) au perimetre. Le VitiCore cible ne permet pas de les restaurer ; l\'import est refuse pour eviter leur perte silencieuse.',
                $giftCount,
            );
        }

        foreach ($orders as $order) {
            $sourceCartId = (string) ($order['source_cart_id'] ?? '');
            $sourceCheckoutId = (string) ($order['source_checkout_id'] ?? '');
            $paid = null !== $order['payed_at'];
            $sourceLines = $linesByCart[$sourceCartId] ?? [];

            ++$summary[$paid ? 'paid' : 'unpaid'];

            if (!Uuid::isValid($sourceCartId) || !Uuid::isValid($sourceCheckoutId)) {
                $errors[] = 'Une ligne source ne contient pas des UUID valides pour le panier et le checkout.';

                continue;
            }

            if ([] === $sourceLines) {
                $errors[] = \sprintf('Le panier source %s ne contient aucune ligne.', $sourceCartId);

                continue;
            }

            try {
                $record = $this->buildRecord(
                    $order,
                    $sourceLines,
                    $resolvedVariants,
                    $mapping,
                    $paid,
                    $warnings,
                );
            } catch (\Throwable $exception) {
                $errors[] = \sprintf('Panier source %s : %s', $sourceCartId, $exception->getMessage());

                continue;
            }

            $this->validateTargetIdentityCollision($record, $sourceKey, $errors);

            $records[] = $record;
            $summary['lines'] += \count($sourceLines);
            $summary['quantity'] += (int) array_sum(array_map(
                static fn (array $line): int => (int) ($line['quantity'] ?? 0),
                $sourceLines,
            ));
            $summary['total_ttc'] += (int) $record['total_ttc'];
            $summary['mapped_lines'] += (int) $record['mapped_lines'];
            $summary['detached_lines'] += (int) $record['detached_lines'];
            $summary['stock_impact_quantity'] += (int) $record['stock_impact_quantity'];
        }

        $summary['warnings'] = \count($warnings);
        $summary['errors'] = \count($errors);

        return new LegacyOrderImportPlan(
            $sourceKey,
            $sourceLabel,
            $scope,
            $records,
            array_values(array_unique($warnings)),
            array_values(array_unique($errors)),
            $summary,
        );
    }

    /**
     * @return array{imported: int, skipped: int, total_ttc: int, run_id: string}
     */
    public function apply(LegacyOrderImportPlan $plan, bool $force, bool $allowStockImpact = false): array
    {
        if (!$plan->isReady()) {
            throw new \RuntimeException('Le preflight contient des erreurs ; aucune ecriture n\'est autorisee.');
        }

        return $this->target->transactional(function (Connection $target) use ($plan, $force, $allowStockImpact): array {
            $lockClause = $target->getDatabasePlatform() instanceof PostgreSQLPlatform ? ' FOR UPDATE' : '';
            $existingRun = $target->fetchAssociative(
                \sprintf('SELECT * FROM %s WHERE source_key = :source_key%s', self::RUN_TABLE, $lockClause),
                ['source_key' => $plan->sourceKey],
            );

            if (false !== $existingRun && (string) $existingRun['scope'] !== $plan->scope) {
                throw new \RuntimeException(\sprintf('Cette source a ete importee avec le perimetre "%s". Une reprise --force doit conserver exactement le meme --scope.', (string) $existingRun['scope']));
            }

            if (false !== $existingRun && !$force) {
                throw new \RuntimeException(\sprintf('Cette source a deja ete importee le %s. Utilisez --force pour une reprise idempotente.', (string) ($existingRun['completed_at'] ?? $existingRun['started_at'] ?? 'date inconnue')));
            }

            if (!$allowStockImpact && $this->pendingStockImpactQuantity($target, $plan) > 0) {
                throw new \RuntimeException('L\'import modifierait le calcul du stock courant. Verifiez le rapport puis relancez avec --allow-stock-impact.');
            }

            $runId = false === $existingRun ? Uuid::uuid4()->toString() : (string) $existingRun['id'];
            $startedAt = new \DateTimeImmutable();

            if (false === $existingRun) {
                $target->insert(self::RUN_TABLE, [
                    'id' => $runId,
                    'source_key' => $plan->sourceKey,
                    'source_label' => $plan->sourceLabel,
                    'scope' => $plan->scope,
                    'status' => 'running',
                    'attempt_count' => 1,
                    'source_count' => (int) $plan->summary['orders'],
                    'imported_count' => 0,
                    'skipped_count' => 0,
                    'total_ttc' => (int) $plan->summary['total_ttc'],
                    'report' => $plan->summary,
                    'started_at' => $startedAt,
                    'completed_at' => null,
                ], [
                    'report' => Types::JSON,
                    'started_at' => Types::DATETIME_IMMUTABLE,
                    'completed_at' => Types::DATETIME_IMMUTABLE,
                ]);
            } else {
                $target->update(self::RUN_TABLE, [
                    'source_label' => $plan->sourceLabel,
                    'scope' => $plan->scope,
                    'status' => 'running',
                    'attempt_count' => ((int) $existingRun['attempt_count']) + 1,
                    'source_count' => (int) $plan->summary['orders'],
                    'total_ttc' => (int) $plan->summary['total_ttc'],
                    'report' => $plan->summary,
                    'started_at' => $startedAt,
                    'completed_at' => null,
                ], ['id' => $runId], [
                    'report' => Types::JSON,
                    'started_at' => Types::DATETIME_IMMUTABLE,
                    'completed_at' => Types::DATETIME_IMMUTABLE,
                ]);
            }

            $imported = 0;
            $skipped = 0;

            foreach ($plan->records as $record) {
                $existingMap = $target->fetchAssociative(
                    \sprintf(
                        'SELECT * FROM %s WHERE source_key = :source_key AND source_cart_id = :source_cart_id',
                        self::MAP_TABLE,
                    ),
                    [
                        'source_key' => $plan->sourceKey,
                        'source_cart_id' => $record['source_cart_id'],
                    ],
                );

                if (false !== $existingMap) {
                    if (!hash_equals((string) $existingMap['source_checksum'], (string) $record['source_checksum'])) {
                        throw new \RuntimeException(\sprintf('Le panier source %s a change depuis son import. Il ne sera pas ecrase automatiquement.', $record['source_cart_id']));
                    }

                    $targetExists = (int) $target->fetchOne(
                        'SELECT COUNT(*) FROM cart_cart WHERE id = :id',
                        ['id' => $existingMap['target_cart_id']],
                    ) > 0;

                    if (!$targetExists) {
                        throw new \RuntimeException(\sprintf('La commande cible tracee pour le panier source %s est absente.', $record['source_cart_id']));
                    }

                    ++$skipped;

                    continue;
                }

                if ((int) $target->fetchOne(
                    'SELECT COUNT(*) FROM cart_cart WHERE id = :id',
                    ['id' => $record['cart']['id']],
                ) > 0) {
                    throw new \RuntimeException(\sprintf('Collision avec une commande cible non tracee pour le panier source %s.', $record['source_cart_id']));
                }

                $this->insertRecord($target, $record);
                $target->insert(self::MAP_TABLE, [
                    'id' => Uuid::uuid4()->toString(),
                    'run_id' => $runId,
                    'source_key' => $plan->sourceKey,
                    'source_checkout_id' => $record['source_checkout_id'],
                    'source_cart_id' => $record['source_cart_id'],
                    'target_cart_id' => $record['cart']['id'],
                    'source_checksum' => $record['source_checksum'],
                    'imported_at' => $startedAt,
                ], [
                    'imported_at' => Types::DATETIME_IMMUTABLE,
                ]);
                ++$imported;
            }

            $completedAt = new \DateTimeImmutable();
            $totalImported = (int) $target->fetchOne(
                \sprintf('SELECT COUNT(*) FROM %s WHERE source_key = :source_key', self::MAP_TABLE),
                ['source_key' => $plan->sourceKey],
            );
            $finalReport = [
                ...$plan->summary,
                'imported_total' => $totalImported,
                'imported_this_attempt' => $imported,
                'skipped_this_attempt' => $skipped,
            ];
            $target->update(self::RUN_TABLE, [
                'status' => 'completed',
                'imported_count' => $totalImported,
                'skipped_count' => $skipped,
                'report' => $finalReport,
                'completed_at' => $completedAt,
            ], ['id' => $runId], [
                'report' => Types::JSON,
                'completed_at' => Types::DATETIME_IMMUTABLE,
            ]);

            return [
                'imported' => $imported,
                'skipped' => $skipped,
                'total_ttc' => (int) $plan->summary['total_ttc'],
                'run_id' => $runId,
            ];
        });
    }

    /**
     * @param array<string, list<array<string, mixed>>> $linesByCart
     * @param list<string>                              $errors
     * @param list<string>                              $warnings
     *
     * @return array<string, array<string, mixed>|null>
     */
    private function resolveVariants(
        array $linesByCart,
        LegacyOrderImportMapping $mapping,
        array &$errors,
        array &$warnings,
    ): array {
        $sourceVariantIds = [];

        foreach ($linesByCart as $lines) {
            foreach ($lines as $line) {
                $sourceVariantId = mb_strtolower(mb_trim((string) ($line['source_variant_id'] ?? '')));
                if ('' !== $sourceVariantId) {
                    $sourceVariantIds[$sourceVariantId] = true;
                }
            }
        }

        $resolved = [];

        foreach (array_keys($sourceVariantIds) as $sourceVariantId) {
            if (1 === $mapping->schemaVersion && !$mapping->hasVariant($sourceVariantId)) {
                $errors[] = \sprintf(
                    'L entree de la variante source %s a ete retiree du fichier genere. Regenerez ou completez le fichier sans supprimer de cle.',
                    $sourceVariantId,
                );
                $resolved[$sourceVariantId] = null;

                continue;
            }

            if ($mapping->hasVariant($sourceVariantId)) {
                $targetReference = $mapping->variant($sourceVariantId);

                if (null === $targetReference) {
                    $resolved[$sourceVariantId] = null;
                    $warnings[] = \sprintf(
                        'Variante source %s explicitement conservee comme ligne historique detachee.',
                        $sourceVariantId,
                    );

                    continue;
                }

                if (LegacyOrderImportMapping::UNRESOLVED === $targetReference) {
                    $errors[] = \sprintf(
                        'La correspondance de la variante source %s reste a completer dans le fichier JSON.',
                        $sourceVariantId,
                    );
                    $resolved[$sourceVariantId] = null;

                    continue;
                }

                $candidate = $this->findTargetVariant($targetReference);
                if (null === $candidate) {
                    $errors[] = \sprintf(
                        'Variante cible "%s" introuvable pour la variante source %s.',
                        $targetReference,
                        $sourceVariantId,
                    );
                    $resolved[$sourceVariantId] = null;

                    continue;
                }

                $resolved[$sourceVariantId] = $candidate;

                continue;
            }

            $sameIdCandidate = $this->findTargetVariant($sourceVariantId);
            if (null !== $sameIdCandidate) {
                $resolved[$sourceVariantId] = $sameIdCandidate;

                continue;
            }

            $errors[] = \sprintf(
                'Aucune correspondance declaree pour la variante source %s. Ajoutez sa cible UUID/sku ou null dans "variants".',
                $sourceVariantId,
            );
            $resolved[$sourceVariantId] = null;
        }

        return $resolved;
    }

    /**
     * @return array<string, mixed>|null
     */
    private function findTargetVariant(string $reference): ?array
    {
        if (str_starts_with($reference, 'sku:')) {
            $sku = mb_trim(mb_substr($reference, 4));
            if ('' === $sku) {
                return null;
            }

            $candidates = $this->target->fetchAllAssociative(<<<'SQL'
                SELECT variant.id, variant.product_id, variant.product_type_id, variant.sku AS variant_sku,
                       product.sku AS product_sku, variant.stock_at
                FROM product_variant variant
                LEFT JOIN product_product product ON product.id = variant.product_id
                WHERE variant.sku = :sku OR (variant.sku IS NULL AND product.sku = :sku)
                SQL, ['sku' => $sku]);

            return 1 === \count($candidates) ? $candidates[0] : null;
        }

        if (!Uuid::isValid($reference)) {
            return null;
        }

        $candidate = $this->target->fetchAssociative(<<<'SQL'
            SELECT variant.id, variant.product_id, variant.product_type_id, variant.sku AS variant_sku,
                   product.sku AS product_sku, variant.stock_at
            FROM product_variant variant
            LEFT JOIN product_product product ON product.id = variant.product_id
            WHERE variant.id = :id
            SQL, ['id' => $reference]);

        return false === $candidate ? null : $candidate;
    }

    /**
     * @param array<string, mixed>                     $order
     * @param list<array<string, mixed>>               $sourceLines
     * @param array<string, array<string, mixed>|null> $resolvedVariants
     * @param list<string>                             $warnings
     *
     * @return array<string, mixed>
     */
    private function buildRecord(
        array $order,
        array $sourceLines,
        array $resolvedVariants,
        LegacyOrderImportMapping $mapping,
        bool $paid,
        array &$warnings,
    ): array {
        $sourceCartId = (string) $order['source_cart_id'];
        $sourceCheckoutId = (string) $order['source_checkout_id'];
        $targetCartId = $this->targetId('cart', $sourceCartId);
        $targetCheckoutId = $this->targetId('checkout', $sourceCheckoutId);
        $preparedLines = [];
        $targetItems = [];
        $mappedLines = 0;
        $detachedLines = 0;
        $stockImpactQuantity = 0;

        foreach ($sourceLines as $sourceLine) {
            $sourceItemId = (string) ($sourceLine['source_item_id'] ?? '');
            $sourceVariantId = mb_strtolower((string) ($sourceLine['source_variant_id'] ?? ''));

            if (!Uuid::isValid($sourceItemId) || !Uuid::isValid($sourceVariantId)) {
                throw new \RuntimeException('Une ligne ne contient pas un UUID valide pour elle-meme ou sa variante.');
            }

            $targetVariant = $resolvedVariants[$sourceVariantId] ?? null;
            $targetItemId = $this->targetId('item', $sourceItemId);
            $quantity = $this->positiveInteger($sourceLine['quantity'] ?? null, 'quantite');
            $targetVariantId = null === $targetVariant ? null : (string) $targetVariant['id'];
            $targetProductId = null === $targetVariant
                ? $this->nullableString($sourceLine['source_product_id'] ?? null)
                : $this->nullableString($targetVariant['product_id'] ?? null);
            $targetTypeId = null === $targetVariant
                ? $this->nullableString($sourceLine['source_type_id'] ?? null)
                : $this->nullableString($targetVariant['product_type_id'] ?? null);
            $productName = $this->nullableString($sourceLine['product_name'] ?? null) ?? 'Produit historique';
            $typeName = $this->nullableString($sourceLine['type_name'] ?? null);
            $quality = $this->nullableString($sourceLine['product_quality'] ?? null);
            $variantFullName = mb_trim(implode(' ', array_filter([$productName, $quality])));

            if (null !== $typeName) {
                $variantFullName .= ' - '.$typeName;
            }

            if (null === $targetVariant) {
                ++$detachedLines;
            } else {
                ++$mappedLines;
                $stockAt = $this->dateValue($targetVariant['stock_at'] ?? null, nullable: true);
                $createdAt = $this->requiredDate($order['cart_created_at'] ?? null);

                if ($paid && null !== $stockAt && $createdAt > $stockAt) {
                    $stockImpactQuantity += $quantity;
                }
            }

            $sku = $this->nullableString($targetVariant['variant_sku'] ?? null)
                ?? $this->nullableString($targetVariant['product_sku'] ?? null)
                ?? 'LEGACY-'.mb_strtoupper(mb_substr(str_replace('-', '', $sourceVariantId), 0, 16));

            $preparedLines[] = [
                'item_id' => $targetItemId,
                'product_id' => $targetProductId,
                'product_name' => $productName,
                'variant_id' => $targetVariantId ?? $sourceVariantId,
                'variant_full_name' => $variantFullName,
                'type_id' => $targetTypeId,
                'type_name' => $typeName,
                'unit_label' => $this->unitLabel($sourceLine['volume'] ?? null, $sourceLine['unity'] ?? null),
                'image_path' => null,
                'quantity' => $quantity,
                'price' => $this->nonNegativeInteger($sourceLine['price'] ?? null, 'prix'),
            ];
            $targetItems[] = [
                'id' => $targetItemId,
                'cart_cart_id' => $targetCartId,
                'product_variant_id' => $targetVariantId,
                'quantity' => $quantity,
                'selected_option_name' => null,
                'selected_option_price' => null,
                'selected_option_flat_fee' => false,
                'sku_snapshot' => $this->limited($sku, 255, 'sku_snapshot'),
                'created_at' => $this->requiredDate($sourceLine['item_created_at'] ?? null),
                'updated_at' => $this->requiredDate($sourceLine['item_updated_at'] ?? null),
            ];
        }

        $snapshot = $this->pricingSnapshotBuilder->build($order, $preparedLines);
        $paymentMethodId = $this->resolvePaymentMethod($order, $mapping, $paid, $warnings);
        $deliveryMethodId = $this->resolveDeliveryMethod($order, $mapping, $warnings);
        $addresses = $this->buildAddresses($order, $sourceCartId, $paid, $warnings);
        $cartCreatedAt = $this->requiredDate($order['cart_created_at'] ?? null);
        $cartUpdatedAt = $this->requiredDate($order['cart_updated_at'] ?? null);
        $checkoutCreatedAt = $this->requiredDate($order['checkout_created_at'] ?? null);
        $checkoutUpdatedAt = $this->requiredDate($order['checkout_updated_at'] ?? null);
        $response = $this->jsonObject($order['checkout_response'] ?? null, 'response du paiement');
        $response['legacy_order_import'] = [
            'source' => 'viti-core-master',
            'source_cart_id' => $sourceCartId,
            'source_checkout_id' => $sourceCheckoutId,
            'payment_method' => $this->nullableString($order['source_payment_method'] ?? null),
            'delivery_method_id' => $this->nullableString($order['source_delivery_method_id'] ?? null),
            'delivery_method' => $this->jsonValue($order['delivery_method_json'] ?? null),
            'colissimo_carrier' => $this->boolean($order['colissimo_carrier'] ?? false),
            'private_carrier' => $this->boolean($order['private_carrier'] ?? false),
        ];

        $promo = $this->limitedNullable($order['promo'] ?? null, 255, 'code promotionnel');
        $cart = [
            'id' => $targetCartId,
            'delivery_method_id' => $deliveryMethodId,
            'invoice_address_id' => $addresses['invoice']['id'] ?? null,
            'delivery_address_id' => $addresses['delivery']['id'] ?? null,
            'user_id' => null,
            'invoice_id' => null,
            'token' => $this->nullableString($order['token'] ?? null),
            'tax' => (string) ((float) ($order['tax'] ?? 0)),
            'group_code' => $this->limitedNullable($order['group_code'] ?? null, 255, 'group_code'),
            'view_ht' => $this->boolean($order['view_ht'] ?? false),
            'comment' => $this->nullableString($order['comment'] ?? null),
            'delivery' => $this->boolean($order['delivery'] ?? false),
            'cgv' => $this->boolean($order['cgv'] ?? false),
            'locale' => $this->limited($this->nullableString($order['locale'] ?? null) ?? 'fr', 10, 'locale'),
            'code_carrier' => $this->limitedNullable($order['colissimo_code'] ?? null, 255, 'code transporteur'),
            'url_de_suivi_carrier' => null,
            'tracking_carrier' => $this->trackingCarrier($order),
            'ip_client' => $this->limitedNullable($order['ip_client'] ?? null, 255, 'adresse IP'),
            'document_type' => 'order',
            'expires_at' => $cartCreatedAt->modify('+3 days'),
            'cart_pricing' => $snapshot['cartPricing'],
            'cart_shipping' => $snapshot['cartShipping'],
            'cart_promos' => $snapshot['cartPromos'],
            'promo_code_input' => $promo,
            'promo_code_status' => null === $promo ? null : CartPromo::STATUS_APPLIED,
            'promo_code_message_key' => null,
            'deleted_at' => null,
            'created_at' => $cartCreatedAt,
            'updated_at' => $cartUpdatedAt,
            'marketing_attribution' => null,
            'last_activity_at' => $cartUpdatedAt,
            'abandoned_at' => null,
            // Master stored only a boolean. Do not invent a shipping date.
            'fulfilled_at' => null,
        ];
        $checkout = [
            'id' => $targetCheckoutId,
            'payment_method_id' => $paymentMethodId,
            'cart_cart_id' => $targetCartId,
            'payed_at' => $this->dateValue($order['payed_at'] ?? null, nullable: true),
            'initiated_at' => $checkoutCreatedAt,
            'ordered_at' => $checkoutCreatedAt,
            'payed_token' => $this->limitedNullable($order['payed_token'] ?? null, 255, 'token de paiement'),
            'response' => $response,
            'marketing_attribution' => null,
            'disabled' => $this->boolean($order['disabled'] ?? false),
            'deleted_at' => null,
            'created_at' => $checkoutCreatedAt,
            'updated_at' => $checkoutUpdatedAt,
        ];
        $checksumPayload = [
            'source_order' => $order,
            'source_lines' => $sourceLines,
            'target_variant_ids' => array_column($targetItems, 'product_variant_id', 'id'),
            'target_payment_method_id' => $paymentMethodId,
            'target_delivery_method_id' => $deliveryMethodId,
        ];

        return [
            'source_cart_id' => $sourceCartId,
            'source_checkout_id' => $sourceCheckoutId,
            'source_checksum' => hash('sha256', $this->canonicalJson($checksumPayload)),
            'cart' => $cart,
            'items' => $targetItems,
            'delivery_address' => $addresses['delivery'],
            'invoice_address' => $addresses['invoice'],
            'checkout' => $checkout,
            'total_ttc' => $snapshot['legacyTotalTTC'],
            'mapped_lines' => $mappedLines,
            'detached_lines' => $detachedLines,
            'stock_impact_quantity' => $stockImpactQuantity,
        ];
    }

    /**
     * @param array<string, mixed> $order
     * @param list<string>         $warnings
     */
    private function resolvePaymentMethod(
        array $order,
        LegacyOrderImportMapping $mapping,
        bool $paid,
        array &$warnings,
    ): ?string {
        $sourceMethod = mb_strtolower($this->nullableString($order['source_payment_method'] ?? null) ?? '');
        $targetReference = $sourceMethod;

        if ('' !== $sourceMethod && 1 === $mapping->schemaVersion && !$mapping->hasPaymentMethod($sourceMethod)) {
            throw new \RuntimeException(\sprintf('L entree du moyen de paiement source "%s" a ete retiree du fichier genere.', $sourceMethod));
        }

        if ('' !== $sourceMethod && $mapping->hasPaymentMethod($sourceMethod)) {
            $targetReference = $mapping->paymentMethod($sourceMethod) ?? '';
        }

        if (LegacyOrderImportMapping::UNRESOLVED === $targetReference) {
            throw new \RuntimeException(\sprintf('La correspondance du moyen de paiement source "%s" reste a completer dans le fichier JSON.', $sourceMethod));
        }

        if ('' === $targetReference) {
            if ($paid) {
                throw new \RuntimeException('Aucun moyen de paiement cible n\'est defini pour une commande payee.');
            }

            return null;
        }

        if (Uuid::isValid($targetReference)) {
            $targetId = $this->target->fetchOne(<<<'SQL'
                SELECT id
                FROM cart_payment_method
                WHERE id = :id AND disabled = FALSE AND deleted_at IS NULL
                SQL, ['id' => $targetReference]);

            if (false !== $targetId) {
                return (string) $targetId;
            }

            if ($paid) {
                throw new \RuntimeException(\sprintf('Le moyen de paiement cible %s est introuvable ou inactif.', $targetReference));
            }

            $warnings[] = \sprintf('Moyen de paiement cible %s introuvable ou inactif sur au moins un checkout non paye.', $targetReference);

            return null;
        }

        $matches = $this->target->fetchFirstColumn(<<<'SQL'
            SELECT id
            FROM cart_payment_method
            WHERE LOWER(method) = :method AND disabled = FALSE AND deleted_at IS NULL
            SQL, ['method' => mb_strtolower($targetReference)]);

        if (1 === \count($matches)) {
            return (string) $matches[0];
        }

        if ($paid) {
            throw new \RuntimeException(\sprintf('Le moyen de paiement cible "%s" doit correspondre a une unique methode active.', $targetReference));
        }

        $warnings[] = \sprintf(
            'Moyen de paiement source "%s" non rattache sur au moins un checkout non paye.',
            '' === $sourceMethod ? 'inconnu' : $sourceMethod,
        );

        return null;
    }

    /**
     * @param array<string, mixed> $order
     * @param list<string>         $warnings
     */
    private function resolveDeliveryMethod(
        array $order,
        LegacyOrderImportMapping $mapping,
        array &$warnings,
    ): ?string {
        $sourceId = mb_strtolower($this->nullableString($order['source_delivery_method_id'] ?? null) ?? '');

        if ('' === $sourceId) {
            return null;
        }

        if (1 === $mapping->schemaVersion && !$mapping->hasDeliveryMethod($sourceId)) {
            throw new \RuntimeException(\sprintf('L entree du mode de livraison source %s a ete retiree du fichier genere.', $sourceId));
        }

        $explicit = $mapping->hasDeliveryMethod($sourceId);
        $targetId = $explicit ? $mapping->deliveryMethod($sourceId) : $sourceId;

        if (null === $targetId) {
            return null;
        }

        if (LegacyOrderImportMapping::UNRESOLVED === $targetId) {
            throw new \RuntimeException(\sprintf('La correspondance du mode de livraison source %s reste a completer dans le fichier JSON.', $sourceId));
        }

        if (Uuid::isValid($targetId) && (int) $this->target->fetchOne(
            'SELECT COUNT(*) FROM shop_delivery_method WHERE id = :id',
            ['id' => $targetId],
        ) > 0) {
            return $targetId;
        }

        if ($explicit) {
            throw new \RuntimeException(\sprintf('Le mode de livraison cible %s est introuvable.', $targetId));
        }

        $warnings[] = \sprintf(
            'Mode de livraison source %s conserve uniquement dans le snapshot historique.',
            $sourceId,
        );

        return null;
    }

    /**
     * @param array<string, mixed> $order
     * @param list<string>         $warnings
     *
     * @return array{delivery: array<string, mixed>|null, invoice: array<string, mixed>|null}
     */
    private function buildAddresses(
        array $order,
        string $sourceCartId,
        bool $paid,
        array &$warnings,
    ): array {
        $differentAddress = $this->boolean($order['different_address'] ?? false);
        $invoiceExists = null !== $order['source_invoice_address_id'];
        $deliveryExists = null !== $order['source_delivery_address_id'];

        if (!$invoiceExists && !$deliveryExists) {
            if ($paid) {
                throw new \RuntimeException('Aucune adresse source n\'est rattachee a cette commande payee.');
            }

            $warnings[] = 'Au moins un checkout non paye ne contient aucune adresse.';

            return ['delivery' => null, 'invoice' => null];
        }

        if ($differentAddress && (!$invoiceExists || !$deliveryExists)) {
            if ($paid) {
                throw new \RuntimeException('La livraison est declaree differente mais une des deux adresses est absente.');
            }

            $warnings[] = 'Au moins un checkout non paye contient une adresse differente incomplete.';
        }

        if ($differentAddress) {
            $delivery = $deliveryExists ? $this->normalizeAddress($order, 'delivery') : null;
            $invoice = $invoiceExists ? $this->normalizeAddress($order, 'invoice') : null;
        } else {
            $delivery = $invoiceExists
                ? $this->normalizeAddress($order, 'invoice')
                : $this->normalizeAddress($order, 'delivery');
            $invoice = null;
        }

        if (null !== $delivery) {
            $delivery['id'] = $this->targetId('delivery-address', $sourceCartId);
            unset($delivery['different_address']);
        }

        if (null !== $invoice) {
            $invoice['id'] = $this->targetId('invoice-address', $sourceCartId);
            $invoice['different_address'] = true;
        }

        return ['delivery' => $delivery, 'invoice' => $invoice];
    }

    /**
     * @param array<string, mixed> $order
     *
     * @return array<string, mixed>
     */
    private function normalizeAddress(array $order, string $prefix): array
    {
        return [
            'email' => $this->limitedNullable($order[$prefix.'_email'] ?? null, 255, 'email'),
            'phone' => $this->addressNormalizer->phone($order[$prefix.'_phone'] ?? null),
            'lastname' => $this->limitedNullable($order[$prefix.'_lastname'] ?? null, 255, 'nom'),
            'firstname' => $this->limitedNullable($order[$prefix.'_firstname'] ?? null, 255, 'prenom'),
            'company' => $this->limitedNullable($order[$prefix.'_company'] ?? null, 255, 'societe'),
            'siret' => null,
            'vat_id' => $this->addressNormalizer->vatId($order[$prefix.'_vat_id'] ?? null),
            'street' => $this->limitedNullable($order[$prefix.'_street'] ?? null, 255, 'rue'),
            'postal' => $this->postal($order[$prefix.'_postal'] ?? null),
            'city' => $this->limitedNullable($order[$prefix.'_city'] ?? null, 255, 'ville'),
            'country' => $this->country($order[$prefix.'_country'] ?? null),
        ];
    }

    /**
     * @param array<string, mixed> $record
     */
    private function insertRecord(Connection $target, array $record): void
    {
        if (null !== $record['delivery_address']) {
            $target->insert('cart_delivery_address', $record['delivery_address']);
        }

        if (null !== $record['invoice_address']) {
            $target->insert('cart_invoice_address', $record['invoice_address'], [
                'different_address' => Types::BOOLEAN,
            ]);
        }

        $target->insert('cart_cart', $record['cart'], [
            'view_ht' => Types::BOOLEAN,
            'delivery' => Types::BOOLEAN,
            'cgv' => Types::BOOLEAN,
            'expires_at' => Types::DATETIME_IMMUTABLE,
            'cart_pricing' => Types::JSON,
            'cart_shipping' => Types::JSON,
            'cart_promos' => Types::JSON,
            'deleted_at' => Types::DATETIME_IMMUTABLE,
            'created_at' => Types::DATETIME_IMMUTABLE,
            'updated_at' => Types::DATETIME_IMMUTABLE,
            'marketing_attribution' => Types::JSON,
            'last_activity_at' => Types::DATETIME_IMMUTABLE,
            'abandoned_at' => Types::DATETIME_IMMUTABLE,
            'fulfilled_at' => Types::DATETIME_IMMUTABLE,
        ]);

        foreach ($record['items'] as $item) {
            $target->insert('cart_cart_item', $item, [
                'selected_option_flat_fee' => Types::BOOLEAN,
                'created_at' => Types::DATETIME_IMMUTABLE,
                'updated_at' => Types::DATETIME_IMMUTABLE,
            ]);
        }

        $target->insert('cart_cart_checkout', $record['checkout'], [
            'payed_at' => Types::DATETIME_IMMUTABLE,
            'initiated_at' => Types::DATETIME_IMMUTABLE,
            'ordered_at' => Types::DATETIME_IMMUTABLE,
            'response' => Types::JSON,
            'marketing_attribution' => Types::JSON,
            'disabled' => Types::BOOLEAN,
            'deleted_at' => Types::DATETIME_IMMUTABLE,
            'created_at' => Types::DATETIME_IMMUTABLE,
            'updated_at' => Types::DATETIME_IMMUTABLE,
        ]);
    }

    /**
     * @param array<string, mixed> $record
     * @param list<string>         $errors
     */
    private function validateTargetIdentityCollision(array $record, string $sourceKey, array &$errors): void
    {
        $existingMap = $this->target->fetchAssociative(
            \sprintf('SELECT source_key, source_cart_id, source_checksum FROM %s WHERE target_cart_id = :target_cart_id', self::MAP_TABLE),
            ['target_cart_id' => $record['cart']['id']],
        );

        if (false !== $existingMap) {
            if ((string) $existingMap['source_key'] !== $sourceKey || (string) $existingMap['source_cart_id'] !== $record['source_cart_id']) {
                $errors[] = \sprintf(
                    'Le panier source %s est deja trace sous une autre identite de source. Reutilisez exactement le meme --source-id et --force.',
                    $record['source_cart_id'],
                );

                return;
            }

            if (!hash_equals((string) $existingMap['source_checksum'], (string) $record['source_checksum'])) {
                $errors[] = \sprintf(
                    'Le panier source %s a change depuis son import et ne sera pas ecrase automatiquement.',
                    $record['source_cart_id'],
                );

                return;
            }

            if ((int) $this->target->fetchOne(
                'SELECT COUNT(*) FROM cart_cart WHERE id = :target_cart_id',
                ['target_cart_id' => $record['cart']['id']],
            ) < 1) {
                $errors[] = \sprintf(
                    'La commande cible tracee pour le panier source %s est absente.',
                    $record['source_cart_id'],
                );
            }

            return;
        }

        if ((int) $this->target->fetchOne(
            'SELECT COUNT(*) FROM cart_cart WHERE id = :target_cart_id',
            ['target_cart_id' => $record['cart']['id']],
        ) > 0) {
            $errors[] = \sprintf(
                'Collision avec un panier cible non trace pour le panier source %s.',
                $record['source_cart_id'],
            );
        }
    }

    private function targetId(string $entity, string $sourceId): string
    {
        return Uuid::uuid5(
            Uuid::NAMESPACE_URL,
            \sprintf('https://viti-core.cpcreation.fr/legacy-order/viti-core-master/%s/%s', $entity, mb_strtolower($sourceId)),
        )->toString();
    }

    private function pendingStockImpactQuantity(Connection $target, LegacyOrderImportPlan $plan): int
    {
        $quantity = 0;

        foreach ($plan->records as $record) {
            $alreadyMapped = (int) $target->fetchOne(
                \sprintf('SELECT COUNT(*) FROM %s WHERE source_key = :source_key AND source_cart_id = :source_cart_id', self::MAP_TABLE),
                [
                    'source_key' => $plan->sourceKey,
                    'source_cart_id' => $record['source_cart_id'],
                ],
            ) > 0;

            if (!$alreadyMapped) {
                $quantity += (int) $record['stock_impact_quantity'];
            }
        }

        return $quantity;
    }

    /**
     * @param array<string, mixed> $order
     */
    private function trackingCarrier(array $order): ?string
    {
        if ($this->boolean($order['colissimo_carrier'] ?? false)) {
            return 'laposte';
        }

        if ($this->boolean($order['private_carrier'] ?? false)) {
            return 'private';
        }

        return null;
    }

    private function unitLabel(mixed $volume, mixed $unity): ?string
    {
        $unity = $this->nullableString($unity);
        if (null === $unity) {
            return null;
        }

        $volume = $this->nullableString($volume);
        if (null === $volume) {
            return $unity;
        }

        $volume = mb_rtrim(mb_rtrim($volume, '0'), '.');

        return mb_trim($volume.' '.$unity);
    }

    private function country(mixed $value): ?string
    {
        $country = $this->nullableString($value);
        if (null === $country) {
            return null;
        }

        $country = mb_strtoupper($country);
        $aliases = [
            'FRANCE' => 'FR',
            'BELGIQUE' => 'BE',
            'SUISSE' => 'CH',
            'ALLEMAGNE' => 'DE',
            'LUXEMBOURG' => 'LU',
            'PAYS-BAS' => 'NL',
            'ROYAUME-UNI' => 'GB',
        ];

        if (isset($aliases[$country])) {
            return $aliases[$country];
        }

        try {
            if (2 === mb_strlen($country)) {
                return $this->countries->alpha2($country)['alpha2'];
            }

            if (3 === mb_strlen($country)) {
                return $this->countries->alpha3($country)['alpha2'];
            }

            return $this->countries->name($country)['alpha2'];
        } catch (\Throwable) {
            throw new \RuntimeException('Un pays d\'adresse ne peut pas etre converti en code ISO alpha-2.');
        }
    }

    private function postal(mixed $value): ?string
    {
        $postal = $this->nullableString($value);
        if (null === $postal) {
            return null;
        }

        $postal = mb_strtoupper(preg_replace('/\s+/', '', $postal) ?? '');

        return $this->limited($postal, 8, 'code postal');
    }

    private function limitedNullable(mixed $value, int $maxLength, string $field): ?string
    {
        $value = $this->nullableString($value);

        return null === $value ? null : $this->limited($value, $maxLength, $field);
    }

    private function limited(string $value, int $maxLength, string $field): string
    {
        if (mb_strlen($value) > $maxLength) {
            throw new \RuntimeException(\sprintf('Le champ %s depasse %d caracteres.', $field, $maxLength));
        }

        return $value;
    }

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

        $value = mb_trim((string) $value);

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

    private function boolean(mixed $value): bool
    {
        return \in_array($value, [true, 1, '1', 'true', 'TRUE'], true);
    }

    private function positiveInteger(mixed $value, string $field): int
    {
        $integer = filter_var($value, \FILTER_VALIDATE_INT);
        if (false === $integer || $integer <= 0) {
            throw new \RuntimeException(\sprintf('Le champ %s doit etre un entier strictement positif.', $field));
        }

        return $integer;
    }

    private function nonNegativeInteger(mixed $value, string $field): int
    {
        $integer = filter_var($value, \FILTER_VALIDATE_INT);
        if (false === $integer || $integer < 0) {
            throw new \RuntimeException(\sprintf('Le champ %s doit etre un entier positif ou nul.', $field));
        }

        return $integer;
    }

    private function dateValue(mixed $value, bool $nullable = false): ?\DateTimeImmutable
    {
        if (null === $value || '' === (string) $value) {
            if ($nullable) {
                return null;
            }

            throw new \RuntimeException('Une date historique obligatoire est absente.');
        }

        try {
            return new \DateTimeImmutable((string) $value);
        } catch (\Throwable $exception) {
            throw new \RuntimeException('Une date historique est invalide.', previous: $exception);
        }
    }

    private function requiredDate(mixed $value): \DateTimeImmutable
    {
        $date = $this->dateValue($value);

        if (!$date instanceof \DateTimeImmutable) {
            throw new \RuntimeException('Une date historique obligatoire est absente.');
        }

        return $date;
    }

    /**
     * @return array<string, mixed>
     */
    private function jsonObject(mixed $value, string $field): array
    {
        $decoded = $this->jsonValue($value);
        if (null === $decoded) {
            return [];
        }

        if (!\is_array($decoded)) {
            throw new \RuntimeException(\sprintf('Le champ JSON %s ne contient pas un objet ou un tableau.', $field));
        }

        return $decoded;
    }

    private function jsonValue(mixed $value): mixed
    {
        if (null === $value || '' === $value || \is_array($value)) {
            return '' === $value ? null : $value;
        }

        if (!\is_string($value)) {
            return $value;
        }

        try {
            return json_decode($value, true, 512, \JSON_THROW_ON_ERROR);
        } catch (\JsonException $exception) {
            throw new \RuntimeException('Une valeur JSON historique est invalide.', previous: $exception);
        }
    }

    /**
     * @param array<string, mixed> $value
     */
    private function canonicalJson(array $value): string
    {
        $normalize = static function (mixed $item) use (&$normalize): mixed {
            if (!\is_array($item)) {
                return $item;
            }

            if (!array_is_list($item)) {
                ksort($item);
            }

            foreach ($item as $key => $nested) {
                $item[$key] = $normalize($nested);
            }

            return $item;
        };

        return json_encode($normalize($value), \JSON_THROW_ON_ERROR | \JSON_PRESERVE_ZERO_FRACTION);
    }

    /**
     * @return array<string, int>
     */
    private function emptySummary(): array
    {
        return [
            'orders' => 0,
            'paid' => 0,
            'unpaid' => 0,
            'lines' => 0,
            'quantity' => 0,
            'total_ttc' => 0,
            'mapped_lines' => 0,
            'detached_lines' => 0,
            'stock_impact_quantity' => 0,
            'gift_cards' => 0,
            'warnings' => 0,
            'errors' => 0,
        ];
    }
}
