<?php
defined('ABSPATH') || exit;

use Google\ApiCore\ApiException;
use Google\Shopping\Merchant\Products\V1\Client\ProductInputsServiceClient;
use Google\Shopping\Merchant\Products\V1\Client\ProductsServiceClient;
use Google\Shopping\Merchant\Products\V1\DeleteProductInputRequest;
use Google\Shopping\Merchant\Products\V1\InsertProductInputRequest;
use Google\Shopping\Merchant\Products\V1\ListProductsRequest;
use Google\Shopping\Merchant\Products\V1\ProductInput;

/**
 * Orchestrates full and delta syncs to Google Merchant Center.
 *
 * Products are pushed directly via the Merchant API (products.productInputs.insert).
 * Unlike the old Content API, a primary product data source must already exist —
 * JRA_Merchant_Auth::getOrCreateDataSourceName() finds (or, on genuinely first use,
 * creates) it. Existing Content-API-era listings already live in a data source
 * that's visible here, so this reuses it rather than starting over.
 *
 * The Merchant API has no batch endpoint (Content API's products.custombatch has
 * no equivalent) — every upsert/delete is one API call per product per market.
 *
 * Full sync:
 *   1. Map all eligible products.
 *   2. Upsert to Merchant Center.
 *   3. List all remote products in our data source; delete any whose offer IDs
 *      are not in our set.
 *
 * Delta sync:
 *   1. Map products modified since last sync timestamp.
 *   2. Upsert those products.
 *   3. Delete products trashed since last sync.
 */
class JRA_Merchant_Sync_Service {

    /** Log a chunk-progress line every N items (upsert/delete calls are now one-at-a-time) */
    private const PROGRESS_CHUNK = 250;

    /** Retries for rate-limited (RESOURCE_EXHAUSTED) calls */
    private const RATE_LIMIT_RETRIES = 2;

    private JRA_Merchant_Auth           $auth;
    private JRA_Merchant_Product_Query  $query;
    private JRA_Merchant_Product_Mapper $mapper;
    private JRA_Merchant_Logger         $logger;

    public function __construct() {
        $this->auth   = new JRA_Merchant_Auth();
        $this->query  = new JRA_Merchant_Product_Query();
        $this->mapper = new JRA_Merchant_Product_Mapper();
        $this->logger = new JRA_Merchant_Logger();
    }

    // -------------------------------------------------------------------------
    // Public API
    // -------------------------------------------------------------------------

    /**
     * Push specific products immediately (no deletions, no lastSyncedAt update).
     * Variable product IDs are automatically expanded to their variations.
     *
     * @param  int[] $productIds
     * @return array{upserted: int, errors: string[]}
     */
    public function syncProducts(array $productIds): array {
        $expandedIds = [];
        foreach ($productIds as $id) {
            $product = wc_get_product($id);
            if (!$product) continue;
            if ($product->is_type('variable')) {
                foreach ($product->get_children() as $varId) {
                    $expandedIds[] = (int) $varId;
                }
            } elseif ($product->is_type('grouped')) {
                // Grouped parent is not purchasable — sync its children instead
                foreach ($product->get_children() as $childId) {
                    $expandedIds[] = (int) $childId;
                }
            } else {
                $expandedIds[] = (int) $id;
            }
        }

        if (empty($expandedIds)) {
            return ['upserted' => 0, 'errors' => ['No eligible products found.']];
        }

        $merchantId   = $this->requireMerchantId();
        $dataSource   = $this->auth->getOrCreateDataSourceName();
        $inputsClient = $this->buildProductInputsClient();

        ['mapped' => $mapped, 'errors' => $mapErrors] = $this->mapper->mapBatch($expandedIds);

        foreach ($mapErrors as $id => $msg) {
            $this->logger->warning($msg, 'manual', (int)$id);
        }

        $upserted = $this->upsertAll($inputsClient, $merchantId, $dataSource, $mapped, 'manual');

        $result = [
            'upserted' => $upserted['success'],
            'errors'   => array_merge(array_values($mapErrors), $upserted['errors']),
        ];

        $this->logger->info(
            sprintf('Manual sync complete — upserted: %d, errors: %d', $result['upserted'], count($result['errors'])),
            'manual'
        );

        return $result;
    }

    /**
     * @return array{upserted: int, deleted: int, errors: string[]}
     */
    public function fullSync(): array {
        $this->logger->info('Starting full sync', 'full');

        $merchantId   = $this->requireMerchantId();
        $dataSource   = $this->auth->getOrCreateDataSourceName();
        $inputsClient = $this->buildProductInputsClient();

        $ids = $this->query->getAllProductIds();
        $this->logger->info(count($ids) . ' products found for full sync', 'full');

        ['mapped' => $mapped, 'errors' => $mapErrors] = $this->mapper->mapBatch($ids);

        foreach ($mapErrors as $id => $msg) {
            $this->logger->warning($msg, 'full', (int)$id);
        }

        // Safety net: never delete everything from Google due to a local query failure
        if (empty($mapped)) {
            $this->logger->error('mapBatch returned no products — aborting to prevent mass deletion', 'full');
            return ['upserted' => 0, 'deleted' => 0, 'errors' => array_values($mapErrors)];
        }

        $upserted = $this->upsertAll($inputsClient, $merchantId, $dataSource, $mapped, 'full');
        $deleted  = $this->pruneOrphans($merchantId, $dataSource, $inputsClient, $mapped);

        $this->auth->updateLastSyncedAt();

        $result = [
            'upserted' => $upserted['success'],
            'deleted'  => $deleted['success'],
            'errors'   => array_merge(array_values($mapErrors), $upserted['errors'], $deleted['errors']),
        ];

        $this->logger->info(
            sprintf('Full sync complete — upserted: %d, deleted: %d, errors: %d',
                $result['upserted'], $result['deleted'], count($result['errors'])),
            'full'
        );

        return $result;
    }

    /**
     * @return array{upserted: int, deleted: int, errors: string[]}
     */
    public function deltaSync(): array {
        $this->logger->info('Starting delta sync', 'delta');

        $lastSync     = $this->auth->getLastSyncedAt();
        $merchantId   = $this->requireMerchantId();
        $dataSource   = $this->auth->getOrCreateDataSourceName();
        $inputsClient = $this->buildProductInputsClient();

        $ids = $lastSync
            ? $this->query->getModifiedSince($lastSync)
            : $this->query->getAllProductIds(); // no previous sync — do a full set

        $this->logger->info(
            count($ids) . ' products modified' .
            ($lastSync ? ' since ' . $lastSync->format('Y-m-d H:i:s') . ' UTC' : ' (first run)'),
            'delta'
        );

        ['mapped' => $mapped, 'errors' => $mapErrors] = $this->mapper->mapBatch($ids);

        foreach ($mapErrors as $id => $msg) {
            $this->logger->warning($msg, 'delta', (int)$id);
        }

        $upserted = $this->upsertAll($inputsClient, $merchantId, $dataSource, $mapped, 'delta');
        $deleted  = $this->syncDeletions($inputsClient, $merchantId, $dataSource, $lastSync);

        $this->auth->updateLastSyncedAt();

        $result = [
            'upserted' => $upserted['success'],
            'deleted'  => $deleted['success'],
            'errors'   => array_merge(array_values($mapErrors), $upserted['errors'], $deleted['errors']),
        ];

        $this->logger->info(
            sprintf('Delta sync complete — upserted: %d, deleted: %d, errors: %d',
                $result['upserted'], $result['deleted'], count($result['errors'])),
            'delta'
        );

        return $result;
    }

    /**
     * Delete a single product from Merchant Center by offer ID across all configured markets.
     * Useful for manual cleanup from the admin UI.
     */
    public function deleteProduct(string $offerId): void {
        $merchantId   = $this->requireMerchantId();
        $dataSource   = $this->auth->getOrCreateDataSourceName();
        $inputsClient = $this->buildProductInputsClient();
        $markets      = $this->auth->getMarkets();

        $productInputNames = [];
        foreach ($markets as $market) {
            $productInputNames[] = ProductInputsServiceClient::productInputName(
                $merchantId,
                "{$market['language']}~{$market['country']}~{$offerId}"
            );
        }

        $result = $this->deleteAll($inputsClient, $dataSource, $productInputNames, 'manual');

        if (!empty($result['errors'])) {
            throw new RuntimeException( 'Delete failed: ' . implode( '; ', $result['errors'] ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }

        $this->logger->info("Manually deleted {$offerId} from Merchant Center (" . count($markets) . " market(s))", 'manual');
    }

    // -------------------------------------------------------------------------
    // Upsert / delete loops
    // -------------------------------------------------------------------------

    /**
     * @param ProductInput[] $products
     * @return array{success: int, errors: string[]}
     */
    private function upsertAll(ProductInputsServiceClient $client, string $merchantId, string $dataSource, array $products, string $syncType): array {
        $success = 0;
        $errors  = [];

        if (empty($products)) {
            return compact('success', 'errors');
        }

        $parent = ProductInputsServiceClient::accountName($merchantId);
        $processed = 0;

        foreach ($products as $productInput) {
            $offerId = $productInput->getOfferId();
            $request = new InsertProductInputRequest([
                'parent'        => $parent,
                'product_input' => $productInput,
                'data_source'   => $dataSource,
            ]);

            try {
                $this->callWithRetry(fn() => $client->insertProductInput($request));
                $success++;
            } catch (ApiException $e) {
                $msg              = $e->getStatus() . ': ' . $e->getBasicMessage();
                $errors[$offerId] = $msg;
                $this->logger->warning("Upsert error [{$offerId}]: {$msg}", $syncType);
            }

            $processed++;
            if ($processed % self::PROGRESS_CHUNK === 0) {
                $this->logger->info("Upsert progress ({$syncType}): {$processed}/" . count($products), $syncType);
            }
        }

        return compact('success', 'errors');
    }

    /**
     * @param string[] $productInputNames Full resource names: accounts/{id}/productInputs/{lang}~{feedLabel}~{offerId}
     * @return array{success: int, errors: string[]}
     */
    private function deleteAll(ProductInputsServiceClient $client, string $dataSource, array $productInputNames, string $syncType): array {
        $success = 0;
        $errors  = [];

        if (empty($productInputNames)) {
            return compact('success', 'errors');
        }

        $processed = 0;

        foreach ($productInputNames as $name) {
            $request = new DeleteProductInputRequest([
                'name'        => $name,
                'data_source' => $dataSource,
            ]);

            try {
                $this->callWithRetry(fn() => $client->deleteProductInput($request));
                $success++;
            } catch (ApiException $e) {
                if ($e->getStatus() === 'NOT_FOUND') {
                    // Already gone from Google — treat as success
                    $success++;
                } else {
                    $msg           = $e->getStatus() . ': ' . $e->getBasicMessage();
                    $errors[$name] = $msg;
                    $this->logger->warning("Delete error [{$name}]: {$msg}", $syncType);
                }
            }

            $processed++;
            if ($processed % self::PROGRESS_CHUNK === 0) {
                $this->logger->info("Delete progress ({$syncType}): {$processed}/" . count($productInputNames), $syncType);
            }
        }

        return compact('success', 'errors');
    }

    /**
     * Retry a single GAPIC call on rate-limit (RESOURCE_EXHAUSTED) errors with a short
     * backoff. The Merchant API has no batch endpoint, so multi-market syncs make far
     * more individual calls than the old Content API custombatch did — quota errors
     * are correspondingly more likely.
     *
     * @throws ApiException on final failure or any non-rate-limit error.
     */
    private function callWithRetry(callable $call): void {
        $attempt = 0;
        while (true) {
            try {
                $call();
                return;
            } catch (ApiException $e) {
                if ($e->getStatus() !== 'RESOURCE_EXHAUSTED' || $attempt >= self::RATE_LIMIT_RETRIES) {
                    throw $e;
                }
                $attempt++;
                usleep((int) (250000 * 2 ** $attempt)); // 0.5s, 1s, ...
            }
        }
    }

    // -------------------------------------------------------------------------
    // Orphan / deletion helpers
    // -------------------------------------------------------------------------

    /**
     * List all products in our data source and delete any whose offer IDs
     * are not in our local set.
     *
     * @param ProductInput[] $localMapped
     * @return array{success: int, errors: string[]}
     */
    private function pruneOrphans(string $merchantId, string $dataSource, ProductInputsServiceClient $inputsClient, array $localMapped): array {
        $localOfferIds = array_flip(array_map(fn($p) => $p->getOfferId(), $localMapped));
        $toDelete      = [];

        try {
            foreach ($this->listOurProducts($merchantId, $dataSource) as $remoteProduct) {
                $offerId = $remoteProduct->getOfferId();
                if (!isset($localOfferIds[$offerId])) {
                    $toDelete[] = ProductInputsServiceClient::productInputName(
                        $merchantId,
                        "{$remoteProduct->getContentLanguage()}~{$remoteProduct->getFeedLabel()}~{$offerId}"
                    );
                }
            }
        } catch (ApiException $e) {
            $this->logger->warning('Could not list Merchant Center products for orphan pruning: ' . $e->getBasicMessage(), 'full');
            return ['success' => 0, 'errors' => []];
        }

        if (empty($toDelete)) {
            return ['success' => 0, 'errors' => []];
        }

        $this->logger->info('Pruning ' . count($toDelete) . ' orphan product(s) from Merchant Center', 'full');
        return $this->deleteAll($inputsClient, $dataSource, $toDelete, 'full');
    }

    /**
     * Delete from Merchant Center any products that were trashed in WooCommerce
     * since the last sync, across all configured markets.
     *
     * @return array{success: int, errors: string[]}
     */
    private function syncDeletions(ProductInputsServiceClient $inputsClient, string $merchantId, string $dataSource, ?DateTime $since): array {
        $markets       = $this->auth->getMarkets();
        $trashedOffers = $this->query->getTrashedOfferIds($since); // [id => 'SKU-xxx']

        if (empty($trashedOffers)) {
            return ['success' => 0, 'errors' => []];
        }

        $productInputNames = [];
        foreach ($trashedOffers as $id => $offerId) {
            foreach ($markets as $market) {
                $productInputNames[] = ProductInputsServiceClient::productInputName(
                    $merchantId,
                    "{$market['language']}~{$market['country']}~{$offerId}"
                );
            }
        }

        $this->logger->info('Deleting ' . count($trashedOffers) . ' trashed product(s) from Merchant Center (' . count($markets) . ' market(s))', 'delta');
        return $this->deleteAll($inputsClient, $dataSource, $productInputNames, 'delta');
    }

    /**
     * Yield processed Product resources belonging to our data source only —
     * listProducts() returns products from every data source on the account,
     * so without this filter a merchant running another feed tool alongside
     * this plugin could have unrelated products treated as orphans.
     *
     * @return iterable<\Google\Shopping\Merchant\Products\V1\Product>
     */
    private function listOurProducts(string $merchantId, string $dataSource): iterable {
        $productsClient = $this->buildProductsClient();
        $request = new ListProductsRequest([
            'parent'    => ProductsServiceClient::accountName($merchantId),
            'page_size' => 250,
        ]);
        foreach ($productsClient->listProducts($request)->iterateAllElements() as $product) {
            if ($product->getDataSource() === $dataSource) {
                yield $product;
            }
        }
    }

    // -------------------------------------------------------------------------
    // Utilities
    // -------------------------------------------------------------------------

    private function buildProductInputsClient(): ProductInputsServiceClient {
        return new ProductInputsServiceClient($this->auth->gapicClientOptions());
    }

    private function buildProductsClient(): ProductsServiceClient {
        return new ProductsServiceClient($this->auth->gapicClientOptions());
    }

    private function requireMerchantId(): string {
        $id = $this->auth->getMerchantId();
        if ($id === '') {
            throw new RuntimeException('No Google Merchant Center merchant ID configured. Set it in Settings.');
        }
        return $id;
    }
}
