<?php
/**
 * Copyright © Simplyin. All rights reserved.
 */
declare(strict_types=1);

namespace Simplyin\Payment\Setup\Patch\Data;

use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Psr\Log\LoggerInterface;

/**
 * Data patch removing duplicate simplyin_order_sync rows for the same order_id.
 *
 * order_id used to carry only a plain (non-unique) index: the cron sync, the async
 * post-placement queue consumer and the order-placement observer all do the same
 * get-or-create lookup by order_id without a lock, so a race between them could
 * leave more than one row for the same order. This patch shipped as a prerequisite
 * for the UNIQUE constraint that a later release then added on order_id —
 * declarative schema fails hard on install if duplicates already exist, so they
 * had to be removed first, in their own release, before the constraint shipped.
 *
 * For each order_id with more than one row, keeps exactly one:
 *  - if any row has a filled simplyin_order_id (it actually finished syncing),
 *    the newest (highest entity_id) among those filled rows — never an unfilled
 *    row, even if an unfilled row is newer. Discarding a completed sync in favor
 *    of an incomplete/failed one would make OrderSyncService pick the order back
 *    up and either re-create it on the backend or, on the backend's "already
 *    exists" response, permanently lose the simplyin_order_id link (markOrderSync
 *    only sets that column when the sync call returns one);
 *  - otherwise (no row is filled) the row with the highest entity_id, i.e. the
 *    newest one.
 * Every other row in the group is deleted.
 */
class DeduplicateOrderSync implements DataPatchInterface
{
    private const TABLE_ORDER_SYNC = 'simplyin_order_sync';

    /**
     * Duplicate order_ids (and their rows) are processed in batches so a shop
     * with a large order history doesn't load everything into memory at once.
     */
    private const CHUNK_SIZE = 500;

    /**
     * @var ModuleDataSetupInterface
     */
    private $moduleDataSetup;

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @param ModuleDataSetupInterface $moduleDataSetup
     * @param LoggerInterface $logger
     */
    public function __construct(
        ModuleDataSetupInterface $moduleDataSetup,
        LoggerInterface $logger
    ) {
        $this->moduleDataSetup = $moduleDataSetup;
        $this->logger = $logger;
    }

    /**
     * @inheritdoc
     */
    public function apply()
    {
        $this->moduleDataSetup->startSetup();

        try {
            // Deliberately no catch here: this cleanup is a prerequisite for a
            // later UNIQUE-constraint migration, so a failure must abort
            // setup:upgrade and surface loudly rather than let the deploy report
            // success while duplicates remain (unlike FixPendingSyncStatus in
            // this same directory, whose failure mode is just a stale status
            // field). The finally block below only guarantees endSetup() still
            // runs so the setup connection isn't left in an inconsistent state.
            $connection = $this->moduleDataSetup->getConnection();
            $table = $this->moduleDataSetup->getTable(self::TABLE_ORDER_SYNC);

            $duplicateOrderIds = $this->findDuplicateOrderIds($connection, $table);

            if ($duplicateOrderIds !== []) {
                $deletedRows = $this->deduplicate($connection, $table, $duplicateOrderIds);

                $this->logger->info('SimplyIN: Deduplicated simplyin_order_sync records', [
                    'duplicate_groups_found' => count($duplicateOrderIds),
                    'rows_deleted' => $deletedRows,
                ]);
            }
        } finally {
            $this->moduleDataSetup->endSetup();
        }

        return $this;
    }

    /**
     * Finds every order_id that has more than one row in the table.
     *
     * @param AdapterInterface $connection
     * @param string $table
     * @return int[]
     */
    private function findDuplicateOrderIds(AdapterInterface $connection, string $table): array
    {
        $select = $connection->select()
            ->from($table, ['order_id'])
            ->group('order_id')
            ->having('COUNT(*) > ?', 1);

        return array_map('intval', $connection->fetchCol($select));
    }

    /**
     * Resolves the winning row and deletes the rest for every duplicate order_id group, in chunks.
     *
     * @param AdapterInterface $connection
     * @param string $table
     * @param int[] $duplicateOrderIds
     * @return int total number of deleted rows
     */
    private function deduplicate(AdapterInterface $connection, string $table, array $duplicateOrderIds): int
    {
        $deletedRows = 0;

        foreach (array_chunk($duplicateOrderIds, self::CHUNK_SIZE) as $orderIdChunk) {
            $rows = $connection->fetchAll(
                $connection->select()
                    ->from($table, ['entity_id', 'order_id', 'simplyin_order_id'])
                    ->where('order_id IN (?)', $orderIdChunk)
            );

            $groups = [];
            foreach ($rows as $row) {
                $groups[(int)$row['order_id']][] = $row;
            }

            $idsToDelete = [];
            foreach ($groups as $groupRows) {
                $winnerEntityId = $this->pickWinnerEntityId($groupRows);
                foreach ($groupRows as $row) {
                    if ((int)$row['entity_id'] !== $winnerEntityId) {
                        $idsToDelete[] = (int)$row['entity_id'];
                    }
                }
            }

            if ($idsToDelete !== []) {
                $deletedRows += $connection->delete($table, ['entity_id IN (?)' => $idsToDelete]);
            }
        }

        return $deletedRows;
    }

    /**
     * Picks the entity_id to keep for a group of rows sharing the same order_id.
     *
     * Candidates are the filled rows if any exist, otherwise every row in the
     * group — an unfilled row is only eligible when NO row in the group is
     * filled. The winner is the newest (highest entity_id) among the candidates.
     *
     * @param array $rows rows with entity_id, order_id and simplyin_order_id keys, as returned by fetchAll()
     * @return int
     */
    private function pickWinnerEntityId(array $rows): int
    {
        $filledRows = array_filter($rows, $this->hasFilledSimplyinOrderId(...));
        $candidates = $filledRows !== [] ? $filledRows : $rows;

        $winnerEntityId = null;
        foreach ($candidates as $row) {
            $entityId = (int)$row['entity_id'];
            if ($winnerEntityId === null || $entityId > $winnerEntityId) {
                $winnerEntityId = $entityId;
            }
        }

        return $winnerEntityId;
    }

    /**
     * Checks whether a row's simplyin_order_id column is set to a non-blank value.
     *
     * @param array $row row with a simplyin_order_id key, as returned by fetchAll()
     * @return bool
     */
    private function hasFilledSimplyinOrderId(array $row): bool
    {
        $value = $row['simplyin_order_id'];

        return $value !== null && trim($value) !== '';
    }

    /**
     * @inheritdoc
     */
    public static function getDependencies()
    {
        return [];
    }

    /**
     * @inheritdoc
     */
    public function getAliases()
    {
        return [];
    }
}
