<?php

declare(strict_types=1);

namespace Autogedal\FeedMonitor\Model;

use Psr\Log\LoggerInterface;

/**
 * Core of the feed monitor: execution history, alert state (deduplication, reminders, recovery), runs that never
 * finished and jobs that stopped running. The sources (custom crons, sub-feeds, Magento cron, later Firebear) report
 * to it through their adapters (Model\Adapter).
 *
 * The monitor never stops a job: its own errors (e.g. database) are only logged.
 */
class FeedMonitor
{
    public function __construct(
        private readonly RunRepository $runs,
        private readonly StateRepository $states,
        private readonly AlertManager $alerts,
        private readonly Config $config,
        private readonly Clock $clock,
        private readonly LoggerInterface $logger
    ) {
    }

    public function start(string $jobCode, string $feedCode = ''): FeedRun
    {
        try {
            return new FeedRun($this->runs->start($jobCode, $feedCode), $jobCode, $feedCode);
        } catch (\Throwable $e) {
            $this->logger->error('Feed monitor: cannot record the start of ' . $jobCode . ': ' . $e->getMessage());

            return new FeedRun(0, $jobCode, $feedCode);
        }
    }

    public function success(FeedRun $run, int $itemsRead, int $itemsUpdated, string $message = ''): void
    {
        if (!$this->close($run)) {
            return;
        }
        $this->safely($run->getKey(), function () use ($run, $itemsRead, $itemsUpdated, $message): void {
            $this->runs->finish($run->getId(), RunRepository::STATUS_OK, null, $itemsRead, $itemsUpdated, $message);
            $this->alerts->resolve(
                $run->getKey(),
                $message !== '' ? $message : sprintf('%d produse citite, %d actualizate.', $itemsRead, $itemsUpdated)
            );
        });
    }

    /**
     * @param int $itemsUpdated products saved before / despite the problem (partial update)
     */
    public function fail(FeedRun $run, string $problem, string $message, ?int $itemsRead = null, int $itemsUpdated = 0): void
    {
        if (!$this->close($run)) {
            return;
        }
        $this->safely($run->getKey(), function () use ($run, $problem, $message, $itemsRead, $itemsUpdated): void {
            $this->runs->finish($run->getId(), RunRepository::STATUS_ERROR, $problem, $itemsRead, $itemsUpdated, $message);
            $this->alerts->reportProblem($run->getKey(), $problem, $message);
        });
    }

    public function failException(FeedRun $run, \Throwable $e): void
    {
        $this->fail($run, Problem::EXCEPTION, get_class($e) . ': ' . $e->getMessage());
    }

    /**
     * True when the update may go on. A feed with no products, or with fewer than the configured share of the last
     * successful run, ends the run as failed (unless a one-time allowance was given from the CLI). When the check
     * itself cannot be done (monitor tables / config not readable) the update does not start either: fail closed.
     */
    public function acceptItemCount(FeedRun $run, int $itemsRead): bool
    {
        if ($itemsRead <= 0) {
            $this->fail($run, Problem::EMPTY, 'Feed-ul nu contine niciun produs.', 0);

            return false;
        }

        try {
            $reason = $this->incompleteReason($run->getJobCode(), $run->getFeedCode(), $itemsRead);
            if ($reason === null || $this->isIncompleteAllowed($run)) {
                return true;
            }
        } catch (\Throwable $e) {
            $this->logger->error('Feed monitor: completeness check failed for ' . $run->getKey() . ': ' . $e->getMessage());
            $this->fail($run, Problem::EXCEPTION, 'Verificarea completitudinii feed-ului nu a putut fi facuta ('
                . $e->getMessage() . '). Stocul / preturile nu au fost actualizate.', $itemsRead);

            return false;
        }

        $this->fail($run, Problem::INCOMPLETE, $reason . ' Stocul / preturile nu au fost actualizate.', $itemsRead);

        return false;
    }

    /**
     * Why $itemsRead is too few: below the configured share of the last successful run of the job / sub-feed. Null
     * when enough (or no previous successful run, or threshold 0).
     *
     * @throws \Throwable when the run history or the configuration cannot be read
     */
    public function incompleteReason(string $jobCode, string $feedCode, int $itemsRead): ?string
    {
        $threshold = $this->config->getIncompleteThreshold($jobCode, $feedCode);
        $last = $threshold > 0 ? $this->runs->getLastSuccessful($jobCode, $feedCode) : null;
        $minimum = $last ? (int)ceil((int)$last['items_read'] * $threshold / 100) : 0;
        if ($itemsRead >= $minimum) {
            return null;
        }

        return sprintf(
            'Feed-ul are %d produse, fata de %d la ultima rulare reusita (minim %d, prag %s%%).',
            $itemsRead,
            (int)$last['items_read'],
            $minimum,
            rtrim(rtrim(number_format($threshold, 2, '.', ''), '0'), '.')
        );
    }

    /**
     * One-time CLI allowance for a source that has no FeedRun (e.g. a Firebear import): true once, then consumed.
     */
    public function consumeAllowance(string $key): bool
    {
        try {
            return $this->states->consumeIncompleteAllowance($key);
        } catch (\Throwable $e) {
            $this->logger->error('Feed monitor: allowance check failed for ' . $key . ': ' . $e->getMessage());

            return false;
        }
    }

    /**
     * One-time allowance from the CLI (allow-incomplete): taken by the first run that needs it, then valid for every
     * "smaller than usual" check of that run.
     */
    public function isIncompleteAllowed(FeedRun $run): bool
    {
        if ($run->isIncompleteAllowed()) {
            return true;
        }
        try {
            if ($this->states->consumeIncompleteAllowance($run->getKey())) {
                $run->markIncompleteAllowed();
                return true;
            }
            return false;
        } catch (\Throwable $e) {
            $this->logger->error('Feed monitor: allowance check failed for ' . $run->getKey() . ': ' . $e->getMessage());

            return false;
        }
    }

    /**
     * Records a run that already ended outside the monitored code (e.g. a Firebear import, from its events) and
     * updates the alert like success() / fail().
     */
    public function recordCompleted(
        string $jobCode,
        ?string $problem,
        string $message,
        int $startedAt,
        int $finishedAt,
        ?int $itemsRead = null,
        ?int $itemsUpdated = null
    ): void {
        $this->safely($jobCode, function () use ($jobCode, $problem, $message, $startedAt, $finishedAt, $itemsRead, $itemsUpdated): void {
            $status = $problem === null ? RunRepository::STATUS_OK : RunRepository::STATUS_ERROR;
            $this->runs->record($jobCode, '', $status, $problem, $startedAt, $finishedAt, $itemsRead, $itemsUpdated, $message);
            if ($problem === null) {
                $this->alerts->resolve($jobCode, $message);
            } else {
                $this->alerts->reportProblem($jobCode, $problem, $message);
            }
        });
    }

    /**
     * Reports a job / sub-feed that has not started for longer than its interval, closes that alert once it runs
     * well again.
     *
     * @return string|null what was found
     */
    public function checkNotRunning(string $jobCode, string $feedCode, int $maxAgeMinutes): ?string
    {
        $last = $this->runs->getLastRun($jobCode, $feedCode);

        return $this->checkNotStartedSince(
            MonitorKey::build($jobCode, $feedCode),
            $last ? (int)strtotime($last['started_at'] . ' UTC') : null,
            $last && $last['status'] === RunRepository::STATUS_OK,
            $maxAgeMinutes
        );
    }

    /**
     * Same, with the last start known by the source itself (e.g. the Firebear import history).
     *
     * @param int|null $lastStart UTC timestamp of the last start, null when it never ran
     * @param bool $lastOk whether that run ended well (closes a "not running" alert)
     */
    public function checkNotStartedSince(string $key, ?int $lastStart, bool $lastOk, int $maxAgeMinutes): ?string
    {
        $age = $this->clock->now() - ($lastStart ?? $this->states->watchedSince($key));

        if ($age > $maxAgeMinutes * 60) {
            $message = sprintf(
                '%s Interval asteptat: maxim %s.',
                $lastStart !== null
                    ? sprintf('Ultima pornire: %s (UTC), acum %s.', $this->clock->format($lastStart), $this->duration($age))
                    : sprintf('Nicio rulare inregistrata de cand este urmarit (%s).', $this->duration($age)),
                $this->duration($maxAgeMinutes * 60)
            );
            $this->alerts->reportProblem($key, Problem::STALE, $message);

            return $key . ': ' . $message;
        }
        if ($lastStart !== null && $lastOk && $this->alerts->isOpen($key, Problem::STALE)) {
            $this->alerts->resolve($key, sprintf('Jobul a rulat din nou la %s (UTC).', $this->clock->format($lastStart)));
        }

        return null;
    }

    /**
     * Runs started and never finished (fatal error, exit, killed process) are closed as failed and reported.
     *
     * @return string[]
     */
    public function closeStuckRuns(): array
    {
        $found = [];
        foreach ($this->runs->getStuckRuns($this->config->getShortestStuckAfterMinutes()) as $row) {
            $minutes = $this->config->getStuckAfterMinutes((string)$row['job_code']);
            if ($this->clock->now() - (int)strtotime($row['started_at'] . ' UTC') < $minutes * 60) {
                continue; // this job may legitimately run longer
            }
            $key = MonitorKey::build((string)$row['job_code'], (string)$row['feed_code']);
            $message = sprintf('Rularea pornita la %s (UTC) nu s-a terminat nici dupa %d minute.', $row['started_at'], $minutes);
            $this->runs->finish((int)$row['run_id'], RunRepository::STATUS_ERROR, Problem::STUCK, null, null, $message);
            $this->alerts->reportProblem($key, Problem::STUCK, $message);
            $found[] = $key . ': ' . $message;
        }

        return $found;
    }

    /**
     * Problem of a source that has no runs (e.g. the Magento cron).
     */
    public function raise(string $key, string $problem, string $message): void
    {
        $this->alerts->reportProblem($key, $problem, $message);
    }

    /**
     * End of a problem raised with raise(); nothing happens when no alert is open.
     */
    public function clear(string $key, string $message): void
    {
        if ($this->alerts->isOpen($key)) {
            $this->alerts->resolve($key, $message);
        }
    }

    public function remindOpenAlerts(): void
    {
        $this->alerts->remindOpenAlerts();
    }

    public function duration(int $seconds): string
    {
        $minutes = intdiv($seconds, 60);

        return $minutes < 120 ? $minutes . ' min' : round($minutes / 60, 1) . ' ore';
    }

    private function close(FeedRun $run): bool
    {
        if ($run->isFinished()) {
            return false;
        }
        $run->markFinished();

        return $run->getId() > 0;
    }

    private function safely(string $key, callable $action): void
    {
        try {
            $action();
        } catch (\Throwable $e) {
            $this->logger->error('Feed monitor: cannot record the end of ' . $key . ': ' . $e->getMessage());
        }
    }
}
