<?php

declare(strict_types=1);

/*
 * 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\Tests\Commerce\Catalog;

use CpCreation\VitiCore\Commerce\Catalog\CatalogIdentity;
use CpCreation\VitiCore\Commerce\Catalog\CatalogSnapshotHasher;
use CpCreation\VitiCore\Commerce\Catalog\CatalogText;
use CpCreation\VitiCore\Commerce\Catalog\Api\Resource\CatalogRichText;
use CpCreation\VitiCore\Commerce\Catalog\Configuration\CatalogIntegrationConfiguration;
use CpCreation\VitiCore\Product\Model\CommerceProductType;
use CpCreation\VitiCore\Product\Model\ProductCategory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface;

final class CatalogContractTest extends TestCase
{
    private const string INSTANCE_ID = '7ad9e145-910f-4de4-9cec-5f832494adef';
    private const string PRODUCT_ID = 'de239cbd-a2bd-4431-a1d6-32b73f9c35d1';

    public function testApiConfigurationUsesAHashedOpaqueToken(): void
    {
        $configuration = $this->configuration(apiTokenHash: hash('sha256', 'central-secret-token'));

        self::assertTrue($configuration->isApiReady());
        self::assertTrue($configuration->matchesApiToken('central-secret-token'));
        self::assertFalse($configuration->matchesApiToken('another-token'));
        self::assertSame(self::INSTANCE_ID, $configuration->instanceId());
        self::assertSame('EUR', $configuration->currency());
    }

    public function testCurrencyMustExistInIso4217(): void
    {
        $euros = $this->configuration(currency: 'eur');
        self::assertSame('EUR', $euros->currency());
        self::assertSame(2, $euros->minorUnitExponent());
        self::assertSame(0, $this->configuration(currency: 'JPY')->minorUnitExponent());

        $this->expectException(\LogicException::class);
        $this->configuration(currency: 'ZZZ')->currency();
    }

    public function testCatalogIdsAreStableAndProductIdsCanBeParsed(): void
    {
        $identity = new CatalogIdentity($this->configuration());

        $expected = 'vc_7ad9e145910f4de49cec5f832494adef_product_de239cbda2bd4431a1d632b73f9c35d1';
        self::assertSame($expected, $identity->productId(self::PRODUCT_ID));
        self::assertSame(self::PRODUCT_ID, $identity->parseProductId($expected)?->toString());
        self::assertNull($identity->parseProductId('product_de239cbda2bd4431a1d632b73f9c35d1'));
    }

    public function testCategoryCommerceComplianceDefaultsToFailClosed(): void
    {
        $category = new ProductCategory();

        self::assertFalse($category->isCommerceExportEnabled());
        self::assertSame(CommerceProductType::UNKNOWN, $category->getCommerceProductType());
        self::assertNull($category->getMinimumPurchaseAge());

        $category->setCommerceExportEnabled(true);
        $category->setCommerceProductType(CommerceProductType::ALCOHOL);
        $category->setMinimumPurchaseAge(18);

        self::assertTrue($category->isCommerceExportEnabled());
        self::assertSame(CommerceProductType::ALCOHOL, $category->getCommerceProductType());
        self::assertSame(18, $category->getMinimumPurchaseAge());
    }

    public function testCategoryCannotEnableAnUnclassifiedOrAgelessAlcoholExport(): void
    {
        $category = new ProductCategory();
        $category->setCommerceExportEnabled(true);

        $this->expectCommerceViolation(
            $category,
            'product_category.commerce_product_type_required',
            'commerceProductType',
        );

        $category->setCommerceProductType(CommerceProductType::ALCOHOL);
        $this->expectCommerceViolation(
            $category,
            'product_category.minimum_purchase_age_required',
            'minimumPurchaseAge',
        );

        $category->setMinimumPurchaseAge(18);
        $context = $this->createMock(ExecutionContextInterface::class);
        $context->expects(self::never())->method('buildViolation');
        $category->validateCommerceCompliance($context);
    }

    private function expectCommerceViolation(ProductCategory $category, string $message, string $path): void
    {
        $builder = $this->createMock(ConstraintViolationBuilderInterface::class);
        $builder->expects(self::once())->method('atPath')->with($path)->willReturnSelf();
        $builder->expects(self::once())->method('addViolation');
        $context = $this->createMock(ExecutionContextInterface::class);
        $context->expects(self::once())->method('buildViolation')->with($message)->willReturn($builder);

        $category->validateCommerceCompliance($context);
    }

    public function testSnapshotHashIgnoresOnlyVolatileContractFields(): void
    {
        $hasher = new CatalogSnapshotHasher();
        $first = [
            'snapshot_at' => '2026-07-22T10:00:00+00:00',
            'source' => ['snapshot_hash' => '', 'product_id' => self::PRODUCT_ID],
            'availability' => ['as_of' => '2026-07-22T10:00:00+00:00', 'quantity' => 6],
        ];
        $second = [
            'snapshot_at' => '2026-07-22T10:05:00+00:00',
            'source' => ['snapshot_hash' => 'previous-value', 'product_id' => self::PRODUCT_ID],
            'availability' => ['as_of' => '2026-07-22T10:05:00+00:00', 'quantity' => 6],
        ];

        self::assertSame($hasher->hash($first), $hasher->hash($second));
        $second['availability']['quantity'] = 5;
        self::assertNotSame($hasher->hash($first), $hasher->hash($second));
    }

    public function testSnapshotHashCanonicalizesMapsButPreservesListOrder(): void
    {
        $hasher = new CatalogSnapshotHasher();

        self::assertSame(
            $hasher->hash(['status' => ['published' => true, 'orderable' => false]]),
            $hasher->hash(['status' => ['orderable' => false, 'published' => true]]),
        );
        self::assertNotSame(
            $hasher->hash(['variants' => [['id' => 'one'], ['id' => 'two']]]),
            $hasher->hash(['variants' => [['id' => 'two'], ['id' => 'one']]]),
        );
    }

    public function testRichTextExposesOriginalHtmlAndNormalizedPlainText(): void
    {
        self::assertEquals(
            new CatalogRichText('<p>Brut&nbsp; <strong>nature</strong></p>', 'Brut nature'),
            CatalogText::rich('  <p>Brut&nbsp; <strong>nature</strong></p>  '),
        );
        self::assertSame('Premier paragraphe Deuxième paragraphe', CatalogText::plain(
            '<p>Premier paragraphe</p><p>Deuxième paragraphe</p>',
        ));
        self::assertEquals(new CatalogRichText(null, null), CatalogText::rich('  '));
        self::assertSame(
            ['html' => '<p>Brut</p>', 'plain' => 'Brut'],
            json_decode(json_encode(CatalogText::rich('<p>Brut</p>'), \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR),
        );
    }

    private function configuration(
        string $apiTokenHash = 'dd3d153a3ac15c3020b3993bdbd2db9c5d42a8f9e5f5b4e21a11ea3d68f24691',
        string $instanceId = self::INSTANCE_ID,
        string $currency = 'eur',
    ): CatalogIntegrationConfiguration {
        return new CatalogIntegrationConfiguration(
            enabled: true,
            instanceId: $instanceId,
            apiTokenHash: $apiTokenHash,
            currency: $currency,
        );
    }
}
