<?php

declare(strict_types=1);

namespace Autogedal\FeedMonitor\Test\Unit\Model;

use Autogedal\FeedMonitor\Model\AlertManager;
use Autogedal\FeedMonitor\Model\Config;
use Autogedal\FeedMonitor\Model\FeedMonitor;
use Autogedal\FeedMonitor\Model\FeedRun;
use Autogedal\FeedMonitor\Model\Problem;
use Autogedal\FeedMonitor\Model\RunRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

/**
 * Core: how a run ends, the guard before updating, jobs that stopped running, runs that never finished.
 */
class FeedMonitorTest extends TestCase
{
    private FixedClock $clock;
    private RunRepository&MockObject $runs;
    private AlertManager&MockObject $alerts;
    private InMemoryStateRepository $states;
    private FeedMonitor $monitor;
    /** @var array<int, array<int, mixed>> finish() calls */
    private array $finished = [];
    /** @var array<string, array<string, mixed>|null> */
    private array $lastRuns = [];

    protected function setUp(): void
    {
        $this->clock = new FixedClock();
        $this->runs = $this->createMock(RunRepository::class);
        $this->runs->method('finish')->willReturnCallback(function (...$args): void {
            $this->finished[] = $args;
        });
        $this->runs->method('getLastRun')->willReturnCallback(
            fn (string $job, string $feed): ?array => $this->lastRuns[$job . ':' . $feed] ?? null
        );
        $this->alerts = $this->createMock(AlertManager::class);
        $this->states = new InMemoryStateRepository($this->clock);
        $config = $this->createMock(Config::class);
        $config->method('getIncompleteThreshold')->willReturnCallback(
            static fn (string $job): float => $job === 'unguarded_job' ? 0.0 : 50.0
        );
        $config->method('getStuckAfterMinutes')->willReturnCallback(
            static fn (string $job = ''): int => $job === 'smartbill_stock_update' ? 240 : 120
        );
        $config->method('getShortestStuckAfterMinutes')->willReturn(120);

        $this->monitor = new FeedMonitor(
            $this->runs,
            $this->states,
            $this->alerts,
            $config,
            $this->clock,
            $this->createMock(LoggerInterface::class)
        );
    }

    public function testAFeedWithoutProductsIsRefused(): void
    {
        $this->alerts->expects(self::once())->method('reportProblem')->with('ptc_update_stock', Problem::EMPTY);

        self::assertFalse($this->monitor->acceptItemCount($this->ptcRun(), 0));
        self::assertSame(RunRepository::STATUS_ERROR, $this->finished[0][1]);
    }

    public function testTheFirstRunIsAcceptedWhateverItsSize(): void
    {
        $this->runs->method('getLastSuccessful')->willReturn(null);

        self::assertTrue($this->monitor->acceptItemCount($this->ptcRun(), 3));
    }

    public function testAFeedBelowTheThresholdOfTheLastSuccessfulRunIsRefused(): void
    {
        $this->runs->method('getLastSuccessful')->willReturn(['items_read' => 4200]);
        $this->alerts->expects(self::once())->method('reportProblem')
            ->with('ptc_update_stock', Problem::INCOMPLETE, self::stringContains('1200 produse, fata de 4200'));

        self::assertFalse($this->monitor->acceptItemCount($this->ptcRun(), 1200));
        self::assertSame([Problem::INCOMPLETE, 1200], [$this->finished[0][2], $this->finished[0][3]]);
    }

    public function testAFeedAtTheThresholdIsAccepted(): void
    {
        $this->runs->method('getLastSuccessful')->willReturn(['items_read' => 4200]);

        self::assertTrue($this->monitor->acceptItemCount($this->ptcRun(), 2100));
    }

    public function testAnAllowanceFromTheCliLetsOneSmallFeedThrough(): void
    {
        $this->runs->method('getLastSuccessful')->willReturn(['items_read' => 4200]);
        $this->states->allowIncomplete('ptc_update_stock');

        self::assertTrue($this->monitor->acceptItemCount($this->ptcRun(), 1200));
        self::assertFalse($this->monitor->acceptItemCount($this->ptcRun(), 1200));
    }

    public function testAnAllowanceExpiresAfterADay(): void
    {
        $this->runs->method('getLastSuccessful')->willReturn(['items_read' => 4200]);
        $this->states->allowIncomplete('ptc_update_stock');
        $this->clock->advanceMinutes(25 * 60);

        self::assertFalse($this->monitor->acceptItemCount($this->ptcRun(), 1200));
    }

    public function testOneAllowanceCoversEveryCheckOfTheSameRun(): void
    {
        $this->runs->method('getLastSuccessful')->willReturn(['items_read' => 4200]);
        $this->states->allowIncomplete('autogedal_feed_oos_products:schiuri');
        $run = new FeedRun(9, 'autogedal_feed_oos_products', 'schiuri');

        self::assertTrue($this->monitor->acceptItemCount($run, 1200));   // smaller feed
        self::assertTrue($this->monitor->isIncompleteAllowed($run));      // and too many products missing
        self::assertFalse($this->monitor->isIncompleteAllowed(new FeedRun(10, 'autogedal_feed_oos_products', 'schiuri')));
    }

    public function testWhenTheCheckItselfFailsTheUpdateDoesNotStart(): void
    {
        $this->runs->method('getLastSuccessful')->willThrowException(new \RuntimeException('Table does not exist'));
        $this->alerts->expects(self::once())->method('reportProblem')
            ->with('ptc_update_stock', Problem::EXCEPTION, self::stringContains('Verificarea completitudinii feed-ului nu a putut fi facuta'));

        self::assertFalse($this->monitor->acceptItemCount($this->ptcRun(), 4200));
    }

    public function testAThresholdOfZeroDisablesTheCheck(): void
    {
        $this->runs->expects(self::never())->method('getLastSuccessful');

        self::assertTrue($this->monitor->acceptItemCount(new FeedRun(5, 'unguarded_job', ''), 1));
    }

    public function testSuccessRecordsTheCountsAndClosesTheAlertOnlyOnce(): void
    {
        $this->alerts->expects(self::once())->method('resolve')->with('ptc_update_stock');

        $run = $this->ptcRun();
        $this->monitor->success($run, 4200, 3100);
        $this->monitor->fail($run, Problem::EXCEPTION, 'ignored, the run already ended');

        self::assertCount(1, $this->finished);
        self::assertSame([RunRepository::STATUS_OK, null, 4200, 3100], array_slice($this->finished[0], 1, 4));
    }

    public function testAMonitorDatabaseErrorNeverStopsTheJob(): void
    {
        $runs = $this->createMock(RunRepository::class);
        $runs->method('start')->willThrowException(new \RuntimeException('table does not exist'));
        $monitor = new FeedMonitor(
            $runs,
            $this->states,
            $this->alerts,
            $this->createMock(Config::class),
            $this->clock,
            $this->createMock(LoggerInterface::class)
        );

        $run = $monitor->start('ptc_update_stock');
        $monitor->success($run, 1, 1);

        self::assertSame(0, $run->getId());
    }

    public function testAJobThatHasNotStartedForLongerThanItsIntervalIsReported(): void
    {
        $this->lastRuns['smartbill_stock_update:'] = $this->startedMinutesAgo(75, RunRepository::STATUS_OK);
        $this->alerts->expects(self::once())->method('reportProblem')
            ->with('smartbill_stock_update', Problem::STALE, self::stringContains('Interval asteptat: maxim 60 min'));

        self::assertNotNull($this->monitor->checkNotRunning('smartbill_stock_update', '', 60));
    }

    public function testAJobNeverSeenRunningIsReportedOnlyAfterItsIntervalSinceWatchingStarted(): void
    {
        self::assertNull($this->monitor->checkNotRunning('alfaromtrans_paravanturi_stock', '', 60));

        $this->clock->advanceMinutes(61);
        $this->alerts->expects(self::once())->method('reportProblem')->with('alfaromtrans_paravanturi_stock', Problem::STALE);
        self::assertNotNull($this->monitor->checkNotRunning('alfaromtrans_paravanturi_stock', '', 60));
    }

    public function testAStaleAlertIsClosedWhenTheJobRunsWellAgain(): void
    {
        $this->lastRuns['autogedal_feed_oos_products:schiuri'] = $this->startedMinutesAgo(5, RunRepository::STATUS_OK);
        $this->alerts->method('isOpen')->willReturn(true);
        $this->alerts->expects(self::once())->method('resolve')->with('autogedal_feed_oos_products:schiuri');

        self::assertNull($this->monitor->checkNotRunning('autogedal_feed_oos_products', 'schiuri', 180));
    }

    public function testARunThatNeverFinishedIsClosedAsStuckAndReported(): void
    {
        $runs = $this->createMock(RunRepository::class);
        $runs->method('getStuckRuns')->with(120)->willReturn([
            ['run_id' => 41, 'job_code' => 'autogedal_feed_oos_products', 'feed_code' => 'schiuri', 'started_at' => $this->clock->format($this->clock->now() - 200 * 60)],
        ]);
        $runs->expects(self::once())->method('finish')
            ->with(41, RunRepository::STATUS_ERROR, Problem::STUCK, null, null, self::stringContains('nu s-a terminat'));
        $this->alerts->expects(self::once())->method('reportProblem')
            ->with('autogedal_feed_oos_products:schiuri', Problem::STUCK);
        $config = $this->createMock(Config::class);
        $config->method('getStuckAfterMinutes')->willReturn(120);
        $config->method('getShortestStuckAfterMinutes')->willReturn(120);
        $monitor = new FeedMonitor($runs, $this->states, $this->alerts, $config, $this->clock, $this->createMock(LoggerInterface::class));

        self::assertCount(1, $monitor->closeStuckRuns());
    }

    public function testAJobAllowedToRunLongerIsNotClosedBeforeItsOwnLimit(): void
    {
        $runs = $this->createMock(RunRepository::class);
        $runs->method('getStuckRuns')->with(120)->willReturn([
            ['run_id' => 51, 'job_code' => 'smartbill_stock_update', 'feed_code' => '', 'started_at' => $this->clock->format($this->clock->now() - 150 * 60)],
        ]);
        $runs->expects(self::never())->method('finish');
        $this->alerts->expects(self::never())->method('reportProblem');
        $config = $this->createMock(Config::class);
        $config->method('getShortestStuckAfterMinutes')->willReturn(120);
        $config->method('getStuckAfterMinutes')->willReturn(240);
        $monitor = new FeedMonitor($runs, $this->states, $this->alerts, $config, $this->clock, $this->createMock(LoggerInterface::class));

        self::assertSame([], $monitor->closeStuckRuns());
    }

    public function testClearDoesNothingWithoutAnOpenAlert(): void
    {
        $this->alerts->method('isOpen')->willReturn(false);
        $this->alerts->expects(self::never())->method('resolve');

        $this->monitor->clear('magento_cron', 'ok');
    }

    private function ptcRun(): FeedRun
    {
        return new FeedRun(7, 'ptc_update_stock', '');
    }

    private function startedMinutesAgo(int $minutes, string $status): array
    {
        return ['started_at' => $this->clock->format($this->clock->now() - $minutes * 60), 'status' => $status];
    }
}
