<?php

namespace TbiBank\FusionPay\Test\Unit\Etc;

use PHPUnit\Framework\TestCase;

/**
 * Behaviour under test: the module stays compliant with the seven issues raised in the partner
 * review of plugin v1.3 (CKT-3844).
 *
 * Given the shipped module (etc/*.xml, composer.json and the PHP sources)
 * When  each partner-reported issue is re-checked
 * Then  none of them is present any more.
 *
 * These are structural assertions on purpose: every item in that review was a wiring or a
 * packaging defect, so the regression guard has to inspect the wiring, not runtime behaviour.
 */
class PartnerReviewComplianceTest extends TestCase
{
    private static function moduleDir(): string
    {
        return str_replace('\\', '/', dirname(__DIR__, 3));
    }

    private static function read(string $relative): string
    {
        $path = self::moduleDir() . '/' . $relative;
        self::assertFileExists($path, $relative . ' is expected to ship with the module');

        return (string) file_get_contents($path);
    }

    /** @return string[] absolute paths of every shipped PHP file outside Test/ */
    private static function sourceFiles(): array
    {
        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator(self::moduleDir(), \FilesystemIterator::SKIP_DOTS)
        );

        $files = [];
        foreach ($iterator as $file) {
            $path = str_replace('\\', '/', (string) $file);
            if (substr($path, -4) === '.php' && strpos($path, '/Test/') === false) {
                $files[] = $path;
            }
        }
        self::assertNotEmpty($files, 'expected to find PHP sources to scan');

        return $files;
    }

    /** Item 1: invalid di.xml preference blocking compilation. */
    public function testNoInvalidPreferenceInDiConfig(): void
    {
        $di = self::read('etc/di.xml');

        $this->assertStringNotContainsString('InterfaceOrClassToOverride', $di);
        // The real class is TbiBank\FusionPay\Model\Cryptor; the bogus non-Model one must not return.
        $this->assertDoesNotMatchRegularExpression('/type="TbiBank\\\\FusionPay\\\\Cryptor"/', $di);
    }

    /** Item 2: no observer may run on every request. */
    public function testNoObserverBoundToEveryRequest(): void
    {
        foreach (['etc/events.xml', 'etc/adminhtml/events.xml'] as $eventsFile) {
            $xml = new \DOMDocument();
            $xml->loadXML(self::read($eventsFile));

            foreach ($xml->getElementsByTagName('event') as $event) {
                $this->assertNotSame(
                    'controller_action_predispatch',
                    $event->getAttribute('name'),
                    $eventsFile . ' binds an observer to every request - move it to cron instead'
                );
            }
        }
    }

    /** Item 2: the version check still runs, on the schedule. */
    public function testUpdateCheckRunsOnCron(): void
    {
        $crontab = self::read('etc/crontab.xml');

        $this->assertStringContainsString('TbiBank\FusionPay\Cron\CheckForUpdate', $crontab);
        $this->assertFileExists(self::moduleDir() . '/Cron/CheckForUpdate.php');
        $this->assertFileDoesNotExist(
            self::moduleDir() . '/Observer/CheckForUpdate.php',
            'the per-request observer must not come back alongside the cron job'
        );
    }

    /** Item 3: no runtime JOIN onto the order grid; the column is fed by the native grid sync. */
    public function testOrderGridStatusIsDenormalizedNotJoined(): void
    {
        $this->assertFileDoesNotExist(
            self::moduleDir() . '/Plugin/AddTbiStatusToOrderGridCollection.php',
            'the grid collection JOIN plugin must stay removed'
        );

        $di = self::read('etc/di.xml');
        $this->assertStringContainsString('Magento\Sales\Model\ResourceModel\Order\Grid', $di);
        $this->assertStringContainsString('sales_order.tbi_status', $di);
    }

    /** Item 4: the payment-method plugin must not force checkout session construction. */
    public function testPaymentMethodVisibilityUsesProxyAndNoRegistry(): void
    {
        $plugin = self::read('Plugin/PaymentMethodVisibility.php');

        $this->assertStringContainsString('Magento\Checkout\Model\Session\Proxy', $plugin);
        $this->assertStringNotContainsString('Magento\Framework\Registry', $plugin);
    }

    /**
     * Executable code of a PHP file, with comments and docblocks stripped.
     *
     * The scans below must not fire on prose: ProgrammaticOrderContext documents that it
     * replaces Registry, which is a description of the fix, not a use of the class.
     */
    private static function codeWithoutComments(string $file): string
    {
        $code = '';
        foreach (token_get_all((string) file_get_contents($file)) as $token) {
            if (is_array($token)) {
                if ($token[0] === T_COMMENT || $token[0] === T_DOC_COMMENT) {
                    continue;
                }
                $code .= $token[1];
                continue;
            }
            $code .= $token;
        }

        return $code;
    }

    /** Items 4 and 5: deprecated Registry and the service locator are gone module-wide. */
    public function testNoRegistryOrObjectManagerAnywhereInSources(): void
    {
        foreach (self::sourceFiles() as $file) {
            $contents = self::codeWithoutComments($file);
            $relative = str_replace(self::moduleDir() . '/', '', $file);

            $this->assertStringNotContainsString(
                'ObjectManager::getInstance',
                $contents,
                $relative . ' uses the ObjectManager service locator - inject the dependency instead'
            );
            $this->assertStringNotContainsString(
                'Magento\Framework\Registry',
                $contents,
                $relative . ' uses Registry, deprecated in Magento 2.4'
            );
        }
    }

    /** Item 6: Zend_Db_Expr is legacy and is removed in Magento 2.4.9. */
    public function testNoLegacyZendDbExpression(): void
    {
        foreach (self::sourceFiles() as $file) {
            $this->assertStringNotContainsString(
                'Zend_Db_Expr',
                self::codeWithoutComments($file),
                'use Magento\Framework\DB\Sql\Expression instead of the legacy Zend_Db_Expr'
            );
        }
    }

    /** Item 7: the composer version and the module setup_version must agree and move forward. */
    public function testModuleVersionsAreAlignedAndNotBehindTheLastRelease(): void
    {
        $composer = json_decode(self::read('composer.json'), true);
        $this->assertIsArray($composer);
        $composerVersion = $composer['version'] ?? null;
        $this->assertNotNull($composerVersion, 'composer.json must declare a version');

        $module = new \DOMDocument();
        $module->loadXML(self::read('etc/module.xml'));
        $setupVersion = $module->getElementsByTagName('module')->item(0)->getAttribute('setup_version');

        $this->assertSame(
            0,
            version_compare($composerVersion, $setupVersion),
            "composer version {$composerVersion} and setup_version {$setupVersion} must be the same release"
        );
        // 1.0.1 was the previously delivered build; going backwards from it was the partner complaint.
        $this->assertTrue(
            version_compare($composerVersion, '1.0.1', '>'),
            "composer version {$composerVersion} must be newer than the delivered 1.0.1"
        );
    }

    /** Item 7: no empty shipped directories, and no layout files where Magento never looks. */
    public function testNoEmptyOrUnreadableShippedDirectories(): void
    {
        $this->assertNotEmpty(
            glob(self::moduleDir() . '/Setup/Patch/Data/*.php'),
            'Setup/Patch/Data must ship real patches, not be empty'
        );

        // Only view/<area>/layout/*.xml is loaded - a nested directory is dead weight.
        $this->assertEmpty(
            glob(self::moduleDir() . '/view/frontend/layout/*/*.xml'),
            'layout XML in a nested directory is never loaded by Magento'
        );
    }

    /** Item 7: Hyva assets must not be wired into the Luma (non-Hyva) layout handles. */
    public function testHyvaAssetsAreNotLoadedByNonHyvaLayouts(): void
    {
        $lumaLayouts = array_filter(
            glob(self::moduleDir() . '/view/frontend/layout/*.xml') ?: [],
            static function ($path) {
                return strpos(basename($path), 'hyva') !== 0;
            }
        );
        $this->assertNotEmpty($lumaLayouts, 'expected non-Hyva layout files to check');

        foreach ($lumaLayouts as $layout) {
            $this->assertStringNotContainsString(
                'hyva',
                (string) file_get_contents($layout),
                basename($layout) . ' pulls in Hyva assets on a non-Hyva layout handle'
            );
        }
    }
}
