<?php

declare(strict_types=1);

namespace Autogedal\FeedMonitor\Test\Unit\Model\Firebear;

use Autogedal\FeedMonitor\Model\Adapter\FirebearJobMonitor;
use Autogedal\FeedMonitor\Model\Config;
use Autogedal\FeedMonitor\Model\FeedMonitor;
use Autogedal\FeedMonitor\Model\Firebear\CronInterval;
use Autogedal\FeedMonitor\Model\Firebear\FirebearJobs;
use Autogedal\FeedMonitor\Model\Firebear\ImportHistory;
use Autogedal\FeedMonitor\Model\Firebear\ImportLog;
use Autogedal\FeedMonitor\Model\JobProviderInterface;
use Autogedal\FeedMonitor\Model\JobRegistry;
use Autogedal\FeedMonitor\Model\Problem;
use Autogedal\FeedMonitor\Test\Unit\Model\FixedClock;
use Magento\Framework\App\Filesystem\DirectoryList;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
 * Firebear import jobs: schedule, log, result of each import, imports never finished, jobs not running.
 */
class FirebearTest extends TestCase
{
    private FixedClock $clock;
    private FeedMonitor&MockObject $monitor;
    private ImportHistory&MockObject $history;
    private ImportLog&MockObject $log;
    private JobRegistry $jobs;
    private FirebearJobMonitor $adapter;

    protected function setUp(): void
    {
        $this->clock = new FixedClock();
        $this->monitor = $this->createMock(FeedMonitor::class);
        $this->monitor->method('duration')->willReturnCallback(static fn (int $seconds): string => intdiv($seconds, 60) . ' min');
        $this->history = $this->createMock(ImportHistory::class);
        $this->log = $this->createMock(ImportLog::class);
        $provider = $this->createMock(JobProviderInterface::class);
        $provider->method('getJobs')->willReturn([
            'firebear_job_22' => ['label' => 'Firebear: RRP Portbagaje auto', 'max_age_minutes' => 180, 'source' => FirebearJobs::SOURCE],
            'firebear_job_28' => ['label' => 'Firebear: Import Westfalia', 'max_age_minutes' => null, 'source' => FirebearJobs::SOURCE],
        ]);
        $this->jobs = new JobRegistry(['ptc_update_stock' => ['max_age_minutes' => 180]], [$provider]);
        $config = $this->createMock(Config::class);
        $config->method('getStuckAfterMinutes')->willReturn(120);
        $config->method('getMaxAgeMinutes')->willReturnCallback(static fn (string $job, ?int $default): ?int => $default);

        $this->adapter = new FirebearJobMonitor($this->monitor, $this->jobs, $config, $this->history, $this->log, $this->clock);
    }

    public function testTheIntervalComesFromTheCronExpression(): void
    {
        $interval = new CronInterval();

        self::assertSame(60, $interval->getMinutes('41 * * * *'));
        self::assertSame(240, $interval->getMinutes('3 */4 * * *'));
        self::assertSame(1440, $interval->getMinutes('31 3 * * *'));
        self::assertSame(30, $interval->getMinutes('0,30 * * * *'));
        self::assertSame(10080, $interval->getMinutes('0 5 * * 0'));
        self::assertSame(1440, $interval->getMinutes('0 5 * * 0-6'));
        self::assertNull($interval->getMinutes('13 10 1 1 1'));   // yearly-like
        self::assertNull($interval->getMinutes('* * 30 2 *'));    // 30 February: never
        self::assertNull($interval->getMinutes('not a cron'));
    }

    public function testTheLogGivesTheCountsAndTheLastErrors(): void
    {
        $log = new ImportLog($this->createMock(DirectoryList::class));
        $result = ['found' => true, 'checked' => null, 'invalid' => null, 'imported' => null, 'updated' => null, 'errors' => []];
        foreach ([
            '<span class="console-info">2026-11-04 10:42:49 : Entity catalog_product</span>',
            '<span class="console-debug">2026-11-04 10:42:52 : Checked rows: 11595, checked entities: 11584, invalid rows: 5, total errors: 5</span>',
            '<span class="console-error">2026-11-04 10:42:52 : Please make sure attribute "sku" is not empty in rows: 5588</span>',
            '<span class="console-info">2026-11-04 10:54:06 : Imported: 0 rows</span>',
            '<span class="console-info">2026-11-04 10:54:06 : Updated: 49 rows</span>',
        ] as $line) {
            $log->parseLine($line, $result);
        }

        self::assertSame([11595, 5, 0, 49], [$result['checked'], $result['invalid'], $result['imported'], $result['updated']]);
        self::assertSame(['Please make sure attribute "sku" is not empty in rows: 5588'], $result['errors']);
    }

    public function testTheResultIsReadFromTheRunThatEndedNotFromTheLatestOne(): void
    {
        // a newer run of the same job may already have started: only the file of this run is used
        $this->history->expects(self::once())->method('getByFile')->with(22, '22-1')
            ->willReturn(['history_id' => 9, 'file' => '22-1', 'started' => 1000, 'finished' => 1600]);
        $this->history->expects(self::never())->method('getLatest');
        $this->log->expects(self::once())->method('read')->with('22-1')->willReturn($this->log(1897, 0, 0, 50));
        $this->monitor->method('incompleteReason')->willReturn(null);
        $this->monitor->expects(self::once())->method('recordCompleted')
            ->with('firebear_job_22', null, self::stringContains('1897 randuri verificate, 50 importate'), 1000, 1600, 1897, 50);

        $this->adapter->recordImport(22, '22-1', true);
    }

    public function testAFailedImportIsAnAlertWithTheErrorsOfTheLog(): void
    {
        $this->log->method('read')->willReturn(['errors' => ['File not found']] + $this->log(null));
        $this->monitor->expects(self::once())->method('recordCompleted')
            ->with('firebear_job_22', Problem::IMPORT, self::stringContains('Ultimele erori: File not found. Log: var/log/firebear/22-1.log'));

        $this->adapter->recordImport(22, '22-1', false);
    }

    public function testASuccessfulImportThatCheckedNoRowIsAnEmptyFeed(): void
    {
        $this->log->method('read')->willReturn($this->log(0, 0));
        $this->monitor->expects(self::once())->method('recordCompleted')
            ->with('firebear_job_22', Problem::EMPTY, self::stringContains('niciun rand'));

        $this->adapter->recordImport(22, '22-1', true);
    }

    public function testASuccessfulImportWithTooManyInvalidRowsIsAnInvalidFeed(): void
    {
        $this->log->method('read')->willReturn(['errors' => ['Value for material is incorrect']] + $this->log(399, 60));
        $this->monitor->expects(self::once())->method('recordCompleted')
            ->with('firebear_job_22', Problem::INVALID, self::stringContains('60 din 399 randuri (15%) sunt invalide'));

        $this->adapter->recordImport(22, '22-1', true);
    }

    public function testAFewInvalidRowsAreReportedWithTheSuccess(): void
    {
        $this->log->method('read')->willReturn($this->log(12552, 1, 0, 0));
        $this->monitor->method('incompleteReason')->willReturn(null);
        $this->monitor->expects(self::once())->method('recordCompleted')
            ->with('firebear_job_22', null, self::stringContains('1 randuri invalide ignorate'));

        $this->adapter->recordImport(22, '22-1', true);
    }

    public function testFarFewerRowsThanUsualIsAnIncompleteFeedUnlessAllowed(): void
    {
        $this->log->method('read')->willReturn($this->log(300, 0, 0, 10));
        $this->monitor->method('incompleteReason')->with('firebear_job_22', '', 300)->willReturn('Feed-ul are 300 produse, fata de 1897.');
        $this->monitor->method('consumeAllowance')->willReturnOnConsecutiveCalls(false, true);
        $problems = [];
        $this->monitor->method('recordCompleted')->willReturnCallback(function (string $job, ?string $problem) use (&$problems): void {
            $problems[] = $problem;
        });

        $this->adapter->recordImport(22, '22-1', true);
        $this->adapter->recordImport(22, '22-2', true);

        self::assertSame([Problem::INCOMPLETE, null], $problems);
    }

    public function testAResultThatCannotBeVerifiedIsNotASuccess(): void
    {
        $this->log->method('read')->willReturn(['found' => false] + $this->log(null));
        $this->monitor->expects(self::once())->method('recordCompleted')
            ->with('firebear_job_22', Problem::INVALID, self::stringContains('nu poate fi verificat'));

        $this->adapter->recordImport(22, '22-1', true);
    }

    public function testImportsOfJobsNotScheduledAreNotMonitored(): void
    {
        $this->monitor->expects(self::never())->method('recordCompleted');

        $this->adapter->recordImport(99, '99-1', false);
    }

    public function testAnImportStartedAndNeverFinishedIsReportedAsStuck(): void
    {
        $this->history->method('getLatest')->willReturnCallback(fn (int $id): ?array => $id === 22
            ? ['history_id' => 9, 'file' => '22-1', 'started' => $this->clock->now() - 150 * 60, 'finished' => null]
            : null);
        $this->monitor->expects(self::once())->method('raise')
            ->with('firebear_job_22', Problem::STUCK, self::stringContains('nu s-a terminat'));
        $this->monitor->expects(self::never())->method('checkNotStartedSince');

        self::assertCount(1, $this->adapter->check(false));
    }

    public function testAnImportStillRunningIsLeftAlone(): void
    {
        $this->history->method('getLatest')->willReturn(
            ['history_id' => 9, 'file' => '22-1', 'started' => $this->clock->now() - 30 * 60, 'finished' => null]
        );
        $this->monitor->expects(self::never())->method('raise');
        $this->monitor->expects(self::never())->method('checkNotStartedSince');

        self::assertSame([], $this->adapter->check(false));
    }

    public function testScheduledJobsAreCheckedForNotRunningFromTheHistory(): void
    {
        $started = $this->clock->now() - 200 * 60;
        $this->history->method('getLatest')->willReturn(['history_id' => 9, 'file' => '22-1', 'started' => $started, 'finished' => $started + 60]);
        // only job 22: job 28 has no regular schedule
        $this->monitor->expects(self::once())->method('checkNotStartedSince')
            ->with('firebear_job_22', $started, true, 180)->willReturn('firebear_job_22: stale');

        self::assertSame(['firebear_job_22: stale'], $this->adapter->check(false));
    }

    public function testTheRegistryKeepsCustomAndFirebearJobsApart(): void
    {
        self::assertSame(['ptc_update_stock'], $this->jobs->getJobCodes(JobRegistry::SOURCE_CUSTOM));
        self::assertSame(['firebear_job_22', 'firebear_job_28'], $this->jobs->getJobCodes(FirebearJobs::SOURCE));
        self::assertSame('Firebear: RRP Portbagaje auto', $this->jobs->getLabel('firebear_job_22'));
        self::assertNull($this->jobs->getDefaultMaxAgeMinutes('firebear_job_28'));
        self::assertSame(22, FirebearJobs::jobId('firebear_job_22'));
        self::assertNull(FirebearJobs::jobId('ptc_update_stock'));
    }

    /**
     * @return array{found: bool, checked: int|null, invalid: int|null, imported: int|null, updated: int|null, errors: string[]}
     */
    private function log(?int $checked, ?int $invalid = 0, ?int $imported = null, ?int $updated = null): array
    {
        return ['found' => true, 'checked' => $checked, 'invalid' => $invalid, 'imported' => $imported, 'updated' => $updated, 'errors' => []];
    }
}
