<?php

namespace Velis;

use ArrayObject;
use Psr\SimpleCache\InvalidArgumentException;
use ReflectionException;
use Velis\Mail\Mail;
use Velis\Model\BaseModel;
use Velis\Model\DataObject\NoColumnsException;
use Velis\Mutex\Semaphore;

/**
 * Base class for cron jobs
 * @author Olek Procki <olo@velis.pl>
 */
abstract class CronJob extends BaseModel
{
    public const STATUS_STARTED = 'Started';
    public const STATUS_SUCCESS = 'Success';
    public const STATUS_FAILED  = 'Failed';
    public const STATUS_WARNING = 'Warning';
    public const STATUS_NOTICE  = 'Notice';

    public const TYPE_INT = 'integer';
    public const TYPE_DATE = 'date';
    public const TYPE_ENUM = 'enum';
    public const TYPE_BOOL = 'bool';

    public const DEFAULT_DESCRIPTION = 'No info available';

    /**
     * Number of days log entries are kept in cron log
     */
    private const MAX_LOG_LIFETIME = 30;
    private const MAX_SANITY_CHECK_LOG_LIFETIME = 3;

    /**
     * Error count trigger (when to stop sending email for same error)
     */
    private const ERROR_COUNT_TRIGGER = 1;

    /**
     * Error email frequency
     */
    private const ERROR_NOTIFICATION_FREQ = 3;

    private array $additionalParams = [];

    /**
     * @deprecated use getParameterValue() instead of CronJon::$_params
     */
    protected $_params;
    protected $_jobStartTime;
    protected $_logSaved;

    /**
     * @var Semaphore
     */
    protected $_mutex;

    /**
     * Mutex namespace prefix
     * @var string
     */
    protected static $_mutexNamespace;

    private CronLog $logEntry;

    /**
     * Constructor
     * @param array|ArrayObject $params
     * @throws InvalidArgumentException
     * @throws NoColumnsException
     * @throws ReflectionException
     */
    public function __construct($params = null)
    {
        $this->_params = $params;
        $this->_jobStartTime = microtime(true);
        $this->_mutex = new Semaphore();

        // call configure method for adding additional parameters
        $this->configure();
        $this->logStarted();
    }

    /**
     * @return void
     * @throws InvalidArgumentException
     * @throws NoColumnsException
     * @throws ReflectionException
     */
    private function logStarted(): void
    {
        $this->logEntry = new CronLog([
            'status' => self::STATUS_STARTED,
            'date_started' => date('Y-m-d H:i:s'),
            'job_name' => get_class($this),
            'exec_time' => 0,
        ]);

        self::$_db->persist($this->logEntry);
    }

    /**
     * Sets mutex namespace prefix
     * @param string $namespace
     */
    public static function setMutexNamespace($namespace)
    {
        self::$_mutexNamespace = $namespace;
    }

    /**
     * Returns mutex id
     * @return string
     */
    public function getMutexId()
    {
        $mutexId = self::$_mutexNamespace . get_class($this);
        if ($this->_params['mode']) {
            $mutexId .= '-' . ucfirst($this->_params['mode']);
        }

        return str_replace('\\', '-', $mutexId);
    }

    /**
     * Acquires mutex
     * @return $this
     */
    protected function _initMutex()
    {
        $this->_mutex->init($this->getMutexId());
        $this->_mutex->acquire();

        return $this;
    }

    /**
     * Releases semaphore just in case
     */
    public function __destruct()
    {
        if (!$this->_logSaved) {
            $this->_log(self::STATUS_NOTICE, 'Execution logged by destructor');
        }

        $this->_mutex->release();
    }

    /**
     * Counts job exec time
     * @return float
     */
    public function countExecTime()
    {
        return round(microtime(true) - $this->_jobStartTime, 4);
    }

    private function cleanupLog(): void
    {
        self::$_db->execDML(
            "DELETE FROM app.cron_log_tab WHERE job_name = 'SanityCheck' AND date_started < CURRENT_TIMESTAMP - INTERVAL '" . self::MAX_SANITY_CHECK_LOG_LIFETIME . " day'"
        );

        self::$_db->execDML(
            "DELETE FROM app.cron_log_tab WHERE job_name != 'SanityCheck' AND date_started < CURRENT_TIMESTAMP - INTERVAL '" . self::MAX_LOG_LIFETIME . " day'"
        );
    }

    /**
     * Saves cron log entry
     *
     * @param string $status
     * @param string|null $output
     * @param string|null $subjectId
     * @return $this
     * @throws NoColumnsException
     * @throws ReflectionException
     */
    protected function _log($status, $output = null, $subjectId = null)
    {
        $this->logEntry->append([
            'status' => $status,
            'info' => $output,
            'exec_time' => $this->countExecTime(),
            'memory_peak' => Output::formatSize(memory_get_peak_usage(true)),
        ]);

        $jobName = $this->logEntry->getName();
        if (str_contains($jobName, '\\')) {
            $confNode = substr($jobName, strrpos($jobName, '\\') + 1);
        } else {
            $confNode = $jobName;
        }
        $confNode = lcfirst($confNode);

        if (is_scalar($subjectId) && $subjectId) {
            $this->logEntry['subject_id'] = $subjectId;
        }

        if (App::$config->cron->{$confNode}->monitoring || App::$config->cron->all->monitoring) {
            $counter = DATA_PATH . 'semaphore' . DIRECTORY_SEPARATOR . $this->getMutexId() . '.err';

            if (in_array($status, [self::STATUS_FAILED, self::STATUS_WARNING])) {
                $errorCountTrigger = App::$config->cron->{$confNode}->errorCountTrigger
                                         ?: App::$config->cron->all->errorCountTrigger
                                         ?: self::ERROR_COUNT_TRIGGER;

                $errorNotificationFreq = App::$config->cron->{$confNode}->errorNotificationFreq
                                         ?: App::$config->cron->all->errorNotificationFreq
                                         ?: self::ERROR_NOTIFICATION_FREQ;

                $errorCount = 1;

                if (file_exists($counter) || $errorCountTrigger === 1) {
                    if (file_exists($counter)) {
                        $errorCount = file_get_contents($counter) + 1;
                    }
                    $msgRecipients = App::$config->cron->{$confNode}->errorAlertEmail ?: App::$config->cron->all->errorAlertEmail;

                    if (
                        $errorCount >= $errorCountTrigger
                        && ($errorCount < $errorNotificationFreq
                            || !($errorCount % $errorNotificationFreq))
                    ) {
                        $logInfo = array_merge($this->logEntry->getArrayCopy(), [
                            'error_count' => $errorCount,
                            'error_count_trigger' => $errorCountTrigger,
                            'error_notification_freq' => $errorNotificationFreq,
                        ]);

                        if ($msgRecipients) {
                            $this->_reportFailure(
                                Arrays::toArray($msgRecipients),
                                $logInfo
                            );
                        }
                    }
                }
                file_put_contents($counter, $errorCount);
            } elseif (file_exists($counter)) {
                unlink($counter);
            }
        }

        if (App::isConsole() && $status === self::STATUS_WARNING) {
            $str = strtok($output, "\n");
            echo "\n[error]\n$str\n\n";
        }

        self::$_db->merge($this->logEntry);

        // erase old log entries
        $this->cleanupLog();

        $this->_logSaved = true;

        return $this;
    }

    /**
     * Reports cron failure via email
     *
     * @param string|array $email
     * @param array $info
     * @throws NoColumnsException
     * @throws \Exception
     */
    protected function _reportFailure($email, $info)
    {
        if (App::$config->notifications->error) {
            $account = Mail::getErrorNotificationAccount();
            $from = App::$config->notifications->error->email_address;
        } else {
            $account = null;
            $from = App::$config->notifications->emailFrom;
        }

        $mail = new Mail($account);
        $mail->setEncoding('utf-8');

        $textPart = print_r($info, true);
        $htmlPart = Debug::dump($info, Debug::MODE_RETURN_HTML);

        $mail->setContents([
            'body_text' => $textPart,
            'body_html' => $htmlPart,
        ]);

        $mail
            ->setSubject('Mutex\Exception - ' . $this->logEntry->getName())
            ->setFrom($from, 'Cron ' . App::$config->settings->instanceAcro)
            ->addTo($email);

        try {
            $mail->send();
            if (App::$config->notifications->log) {
                $mail->log();
            }
        } catch (\Exception $e) {
            $mail->queue(null, Notification::QUEUE_CRON);
        }
    }

    /**
     * @return void
     */
    abstract public function run();

    /**
     * @return string
     */
    abstract public function test();

    /**
     * @return void
     */
    public function info()
    {
        $parameters = $this->getCronParameters();

        printf('DESCRIPTION: ' . "\n");
        printf($this->getDescription() . "\n");

        if ($parameters) {
            printf("\n" . 'PARAMETERS (' .  sizeof($parameters) . '):');
        }

        foreach ($parameters as $parameter) {
            printf("\n" . '* ' . $parameter->getName() . ($parameter->isRequired() ? ' (required)' : ''));
            printf("\n" . 'Type of parameter: %s ', $parameter->getType());

            if ($parameter->getOptions()) {
                print_r('["' . implode('", "', $parameter->getOptions()) . '"]');
            }

            echo "\n";
        }

        echo "\n";
    }

    /**
     * Get additional cron params (for UI)
     * @return array
     */
    public function getCronParameters(): array
    {
        return $this->additionalParams;
    }

    /**
     * Method for register additional parameter
     */
    public function registerParameter($key, $value)
    {
        $this->additionalParams[$key] = $value;
    }

    /**
     * Base configure method
     * @return void
     */
    public function configure()
    {
    }

    /**
     * Get parameter value
     * @param $name
     * @return mixed
     */
    public function getParameterValue($name)
    {
        return $this->_params[$name] ?? null;
    }

    /**
     * @return string
     */
    public function getDescription(): string
    {
        return self::DEFAULT_DESCRIPTION;
    }
}
