<?php

declare(strict_types=1);

namespace Autogedal\FeedMonitor\Model;

use Magento\Framework\Lock\LockManagerInterface;

/**
 * Alert state machine per monitored key: one email when a problem starts (or changes), at most one reminder every
 * N hours while it lasts, one email when it is gone. The alert is closed only once that recovery email has left;
 * until then it stays open as "recovery pending" and the email is retried by the periodic check. Each change of a
 * key's state is done under a lock, so the checks from cron:run, from the server crontab and the jobs themselves
 * never send the same email twice.
 */
class AlertManager
{
    public const TYPE_ALERT = 'alert';
    public const TYPE_REMINDER = 'reminder';
    public const TYPE_RECOVERY = 'recovery';

    private const LOCK_TIMEOUT = 30;

    public function __construct(
        private readonly StateRepository $states,
        private readonly AlertNotifier $notifier,
        private readonly Config $config,
        private readonly Clock $clock,
        private readonly LockManagerInterface $locks
    ) {
    }

    public function reportProblem(string $key, string $problem, string $message): void
    {
        $this->locked($key, fn () => $this->doReportProblem($key, $problem, $message));
    }

    public function resolve(string $key, string $message): void
    {
        $this->locked($key, fn () => $this->doResolve($key, $message));
    }

    private function doReportProblem(string $key, string $problem, string $message): void
    {
        $state = $this->states->get($key);
        $now = $this->clock->format();

        if (!$state || !(int)$state['alert_open']) {
            $data = ['alert_open' => 1, 'problem' => $problem, 'message' => $message, 'first_detected_at' => $now];
            if ($this->notifier->send(self::TYPE_ALERT, $key, $problem, $message, $now)) {
                $data['last_notified_at'] = $now;
            }
            $this->states->save($key, $data);
            return;
        }

        // a problem back before its recovery email left: the alert simply goes on
        $data = ['problem' => $problem, 'message' => $message, 'recovery_pending' => 0];
        $type = $state['problem'] !== $problem || !$state['last_notified_at']
            ? self::TYPE_ALERT
            : ($this->isReminderDue($state) ? self::TYPE_REMINDER : null);
        if ($type && $this->notifier->send($type, $key, $problem, $message, (string)$state['first_detected_at'])) {
            $data['last_notified_at'] = $now;
        }
        $this->states->save($key, $data);
    }

    private function doResolve(string $key, string $message): void
    {
        $state = $this->states->get($key);
        if (!$state || !(int)$state['alert_open']) {
            return;
        }

        $this->sendRecovery($key, (string)$state['problem'], $message, (string)$state['first_detected_at']);
    }

    /**
     * Closes the alert when the recovery email left, else keeps it open as "recovery pending" (retried later).
     */
    private function sendRecovery(string $key, string $problem, string $message, string $since): void
    {
        if (!$this->notifier->send(self::TYPE_RECOVERY, $key, $problem, $message, $since)) {
            $this->states->save($key, ['recovery_pending' => 1, 'message' => $message]);
            return;
        }
        $this->states->save($key, [
            'alert_open' => 0,
            'problem' => null,
            'message' => null,
            'first_detected_at' => null,
            'last_notified_at' => null,
            'recovery_pending' => 0,
        ]);
    }

    public function isOpen(string $key, ?string $problem = null): bool
    {
        $state = $this->states->get($key);

        return $state && (int)$state['alert_open'] && ($problem === null || $state['problem'] === $problem);
    }

    /**
     * Reminders for alerts no run reported again (e.g. a daily job that keeps failing) and recovery emails not sent
     * yet. A key locked by another process does not stop the others; the error is thrown at the end.
     */
    public function remindOpenAlerts(): void
    {
        $error = null;
        foreach ($this->states->getOpenAlerts() as $open) {
            $key = (string)$open['monitor_key'];
            try {
                $this->remind($key);
            } catch (\RuntimeException $e) {
                $error = $e;
            }
        }
        if ($error) {
            throw $error;
        }
    }

    private function remind(string $key): void
    {
        $this->locked($key, function () use ($key): void {
            $state = $this->states->get($key); // again under the lock: another process may have sent it
            if (!$state || !(int)$state['alert_open']) {
                return;
            }
            if ((int)($state['recovery_pending'] ?? 0)) {
                $this->sendRecovery($key, (string)$state['problem'], (string)$state['message'], (string)$state['first_detected_at']);
                return;
            }
            if (!$this->isReminderDue($state)) {
                return;
            }
            if ($this->notifier->send(
                $state['last_notified_at'] ? self::TYPE_REMINDER : self::TYPE_ALERT,
                $key,
                (string)$state['problem'],
                (string)$state['message'],
                (string)$state['first_detected_at']
            )) {
                $this->states->save($key, ['last_notified_at' => $this->clock->format()]);
            }
        });
    }

    private function locked(string $key, callable $action): void
    {
        $name = 'autogedal_feed_monitor_' . md5($key);
        if (!$this->locks->lock($name, self::LOCK_TIMEOUT)) {
            throw new \RuntimeException('Feed monitor: alert state of ' . $key . ' is locked by another process.');
        }
        try {
            $action();
        } finally {
            $this->locks->unlock($name);
        }
    }

    private function isReminderDue(array $state): bool
    {
        $last = $state['last_notified_at'] ? (int)strtotime($state['last_notified_at'] . ' UTC') : 0;

        return $this->clock->now() - $last >= $this->config->getReminderHours() * 3600;
    }
}
