<?php

declare(strict_types=1);

namespace Ferienpass\AdminBundle\Tests\Export;

use Doctrine\ORM\QueryBuilder;
use Ferienpass\AdminBundle\Export\SerializerContextBuilder;
use Ferienpass\AdminBundle\Export\XlsxExportQueryBuilder;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
 * Locks in the contract that column-header translation lookups try the
 * entity-namespaced key first, falling back to the global key.
 *
 *     export.xlsx.offer.fee  →  export.xlsx.fee  →  raw key
 *
 * Without that scoping, columns whose name overlaps across exports (the
 * canonical example being `createdAt` — "Buchungsdatum" on the payment
 * export, "Erstellungsdatum" on the offer export) would either collide on
 * the wrong label or fail to ship a translation at all.
 */
final class XlsxExportQueryBuilderTest extends TestCase
{
    public function testResolveColumnLabelPrefersEntityScopedKey(): void
    {
        $translator = $this->makeTranslator([
            'export.xlsx.offer.fee' => 'Teilnahmegebühr',
            'export.xlsx.fee' => 'WRONG — generic fallback should not win',
        ]);

        $builder = new XlsxExportQueryBuilder(
            $this->createMock(NormalizerInterface::class),
            $translator,
            $this->createMock(SerializerContextBuilder::class),
        );

        self::assertSame(
            'Teilnahmegebühr',
            $this->callPrivate($builder, 'resolveColumnLabel', ['offer', 'fee']),
        );
    }

    public function testResolveColumnLabelFallsBackToGlobalWhenScopedMissing(): void
    {
        $translator = $this->makeTranslator([
            'export.xlsx.firstname' => 'Vorname',
            // Note: no `export.xlsx.participant.firstname` — global must win.
        ]);

        $builder = new XlsxExportQueryBuilder(
            $this->createMock(NormalizerInterface::class),
            $translator,
            $this->createMock(SerializerContextBuilder::class),
        );

        self::assertSame(
            'Vorname',
            $this->callPrivate($builder, 'resolveColumnLabel', ['participant', 'firstname']),
        );
    }

    public function testResolveColumnLabelReturnsRawKeyWhenNothingMatches(): void
    {
        // A missing translation surfaces as the raw key — same symptom the
        // user reported on the offer export. Intentional: any future missing
        // key should be visible at a glance, not silently rendered blank.
        $translator = $this->makeTranslator([]);

        $builder = new XlsxExportQueryBuilder(
            $this->createMock(NormalizerInterface::class),
            $translator,
            $this->createMock(SerializerContextBuilder::class),
        );

        self::assertSame(
            'export.xlsx.humanEdition',
            $this->callPrivate($builder, 'resolveColumnLabel', ['offer', 'humanEdition']),
        );
    }

    public function testResolveColumnLabelSkipsEntityScopingWhenTypeKeyNull(): void
    {
        // A QueryBuilder whose root entity can't be resolved (mock objects,
        // exotic QBs) collapses straight to the global key.
        $translator = $this->makeTranslator([
            'export.xlsx.id' => 'ID',
        ]);

        $builder = new XlsxExportQueryBuilder(
            $this->createMock(NormalizerInterface::class),
            $translator,
            $this->createMock(SerializerContextBuilder::class),
        );

        self::assertSame(
            'ID',
            $this->callPrivate($builder, 'resolveColumnLabel', [null, 'id']),
        );
    }

    public function testResolveTypeKeyDerivesLowerCamelShortName(): void
    {
        $builder = new XlsxExportQueryBuilder(
            $this->createMock(NormalizerInterface::class),
            $this->createMock(TranslatorInterface::class),
            $this->createMock(SerializerContextBuilder::class),
        );

        $qb = $this->createMock(QueryBuilder::class);
        $qb->method('getRootEntities')->willReturn(['Alfeld\\Entity\\Offer']);

        // Class doesn't need to be loadable for the test — `ReflectionClass`
        // throws on unknown classes, and `resolveTypeKey()` catches that and
        // returns null. So we use a real class to exercise the happy path:
        $qbReal = $this->createMock(QueryBuilder::class);
        $qbReal->method('getRootEntities')->willReturn([\stdClass::class]);

        self::assertSame(
            'stdClass',
            $this->callPrivate($builder, 'resolveTypeKey', [$qbReal]),
        );
    }

    public function testResolveTypeKeyReturnsNullForUnknownClass(): void
    {
        $builder = new XlsxExportQueryBuilder(
            $this->createMock(NormalizerInterface::class),
            $this->createMock(TranslatorInterface::class),
            $this->createMock(SerializerContextBuilder::class),
        );

        $qb = $this->createMock(QueryBuilder::class);
        $qb->method('getRootEntities')->willReturn(['Totally\\Made\\Up\\Class']);

        self::assertNull($this->callPrivate($builder, 'resolveTypeKey', [$qb]));
    }

    public function testResolveTypeKeyReturnsNullForRootlessQueryBuilder(): void
    {
        $builder = new XlsxExportQueryBuilder(
            $this->createMock(NormalizerInterface::class),
            $this->createMock(TranslatorInterface::class),
            $this->createMock(SerializerContextBuilder::class),
        );

        $qb = $this->createMock(QueryBuilder::class);
        $qb->method('getRootEntities')->willReturn([]);

        self::assertNull($this->callPrivate($builder, 'resolveTypeKey', [$qb]));
    }

    /**
     * @param array<string, string> $catalog
     */
    private function makeTranslator(array $catalog): TranslatorInterface
    {
        $translator = $this->createMock(TranslatorInterface::class);
        $translator->method('trans')->willReturnCallback(
            // Mirror Symfony Translator's "return the key on miss" behaviour
            // so the SUT's fallback chain runs through the realistic shape.
            static fn (string $key) => $catalog[$key] ?? $key,
        );

        return $translator;
    }

    /**
     * @param array<int, mixed> $args
     */
    private function callPrivate(object $obj, string $method, array $args): mixed
    {
        $ref = new \ReflectionMethod($obj, $method);
        $ref->setAccessible(true);

        return $ref->invokeArgs($obj, $args);
    }
}
