<?php

namespace Velis\Bpm\CronJob;

use DateTime;
use DateTimeZone;
use Exception;
use Psr\SimpleCache\InvalidArgumentException;
use ReflectionException;
use Velis\App;
use Velis\Arrays;
use Velis\Bpm\ErrorNotification\ErrorLogLineParser;
use Velis\CronJob;
use Velis\CronLog;
use Velis\Filter;
use Velis\Mail\Mail;
use Velis\Model\DataObject\NoColumnsException;

/**
 * Error reporter notification cronjob
 * @author Michał Nosek <michal.nosek@velis.pl>
 */
class ErrorNotification extends CronJob
{
    /**
     * Log file content
     */
    private string $errorLog = '';

    /**
     * Xss log file content
     */
    private string $xssLog = '';

    private bool $testMode = false;

    private string $output = '';

    /**
     * {@inheritDoc}
     * @throws InvalidArgumentException
     * @throws NoColumnsException
     * @throws ReflectionException
     */
    public function run(): void
    {
        try {
            $this
                ->_initMutex()
                ->checkErrorLog()
                ->checkXss()
                ->checkUnfinishedTasks()
            ;

            $this->_log(self::STATUS_SUCCESS, 'Sprawdzono log błędów php');
        } catch (\Velis\Mutex\Exception $me) {
            $this->_log(self::STATUS_WARNING, (string)$me);
        } catch (Exception $e) {
            $this->_log(self::STATUS_FAILED, (string)$e);
        }
    }

    /**
     * Check if errors in log file exists and send notification
     * @return $this
     * @throws InvalidArgumentException
     * @throws NoColumnsException
     * @throws Exception
     */
    private function checkErrorLog(): self
    {
        if (file_exists(self::getLogPath())) {
            $found = false;
            $file = file(self::getLogPath());
            $subject = null;
            $lineInfo = null;
            $parser = new ErrorLogLineParser();

            foreach ($file as $lineNo => $line) {
                if (!$found) {
                    $lineInfo = $parser->parseLine($line);

                    if ($lineInfo) {
                        $date = $lineInfo['datetime'];
                        $date->setTimeZone(new DateTimeZone(date_default_timezone_get()));

                        $now = new DateTime('NOW');
                        $now->setTimeZone(new DateTimeZone(date_default_timezone_get()));
                        $diff = $now->getTimestamp() - $date->getTimestamp();

                        if ($diff <= App::$config->errorReporting->frequency * 60) {
                            $found = true;
                        }
                    }
                }

                if ($found) {
                    $this->errorLog .= $line;
                    if (!$subject) {
                        $subject = self::getEmailSubject($lineInfo['message'], $file, $lineNo);
                    }
                }
            }

            if ($this->errorLog) {
                $content = $this->getLogContent();
                $attachments = [
                    self::getLogFileName() => str_replace("\n", "\r\n", $this->errorLog),
                ];

                $this->reportResult($subject, $content, $attachments);
            }
        }

        return $this;
    }

    /**
     * Returns daily error log file
     * @return string
     */
    private static function getLogPath(): string
    {
        return LOG_PATH . 'php/' . self::getLogFileName();
    }

    /**
     * Returns log file name
     * @return string
     */
    private static function getLogFileName(): string
    {
        return 'php_errors_' . date('Y-m-d') . '.log';
    }

    /**
     * Returns email subject
     * @param string $message
     * @param array $file
     * @param int $lineNo
     * @return string
     */
    private static function getEmailSubject(string $message, $file, $lineNo): string
    {
        $subject = '';
        preg_match('/^\[(.*)\] PHP.* (parse error|fatal error|deprecated):(.+)/i', $file[$lineNo], $typeMatches);
        $message = str_replace(' in ' . ROOT_PATH, ' - ', $message);

        if ($typeMatches[2]) {
            $subject .= $typeMatches[2];
        }

        if (strpos($subject, 'Deprecated') !== false) {
            $errTypes = ['Duplicated queries', 'Unlimited query'];

            foreach ($errTypes as $errType) {
                if (strpos($message, $errType) !== false) {
                    if ($errType == 'Duplicated queries') {
                        $subject = 'DQ';
                    } else {
                        $subject = 'Unlimited query';
                    }

                    preg_match("/Debug info:(.*?)(\[trace\] \=\>)/s", implode('', array_slice($file, $lineNo)), $debugInfoMatches);

                    if ($debugInfoMatches[1]) {
                        $modulePattern = "/\[module\] \=\> (\w[-\w+]+)/i";
                        $controllerPattern = "/\[controller\] \=\> (\w[-\w+]+)/i";
                        $actionPattern = "/\[action\] \=\> (\w[-\w+]+)/i";
                        $tablePattern = "/(FROM|INTO|UPDATE) (\w+\.\w+)/i";
                        preg_match($modulePattern, $debugInfoMatches[1], $moduleMatches);
                        preg_match($controllerPattern, $debugInfoMatches[1], $controllerMatches);
                        preg_match($actionPattern, $debugInfoMatches[1], $actionMatches);
                        preg_match($tablePattern, $debugInfoMatches[1], $tableMatches);
                        $message = $moduleMatches[1] . '/' . $controllerMatches[1] . '/' . $actionMatches[1] . ' - ' . $tableMatches[2];
                    }
                }
            }
        }

        return $subject . ' - ' . trim($message);
    }

    /**
     * Returns log message
     * @return string
     */
    private function getLogContent(): string
    {
        return 'Fatal error w aplikacji: ' . App::$config->notifications->nameFrom;
    }

    /**
     * @param string $subject
     * @param string $content
     * @param array $attachments
     * @return void
     * @throws InvalidArgumentException
     * @throws NoColumnsException
     */
    private function reportResult(string $subject, string $content, array $attachments = []): void
    {
        if ($this->testMode) {
            $this->output .= $content . PHP_EOL . PHP_EOL;

            foreach ($attachments as $name => $content) {
                $this->output .= $name . ' => ' . $content . PHP_EOL;
            }
        } else {
            $this->sendMail($subject, $content, $attachments);
        }
    }

    /**
     * @param string $subject
     * @param string $content
     * @param array $attachments
     * @return void
     * @throws InvalidArgumentException
     * @throws NoColumnsException
     */
    private function sendMail(string $subject, string $content, array $attachments = []): void
    {
        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');

        $params['body_text'] = $content;
        $params['body_html'] = Filter::filterTextile($content);

        if (!empty($attachments)) {
            $params['attachments'] = $attachments;
        }

        $mail
            ->setSubject($subject)
            ->setContents($params)
            ->setFrom($from, 'Error ' . App::$config->settings->instanceAcro)
            ->addTo(App::$config->errorReporting->recipient)
        ;

        $mail->send();
    }

    /**
     * Checks if XSS detected and send notification
     * @return $this
     * @throws InvalidArgumentException
     * @throws NoColumnsException
     * @throws Exception
     */
    private function checkXss(): self
    {
        if (file_exists(self::getXssLogPath())) {
            $found = false;
            foreach (file(self::getXssLogPath()) as $line) {
                if (!$found) {
                    preg_match_all('/^(.{25}) ERR \(3\):  !XSS DETECTED!/i', $line, $matches);

                    if ($matches[1][0]) {
                        $date = new DateTime($matches[1][0]);
                        $date->setTimeZone(new DateTimeZone(date_default_timezone_get()));

                        $now = new DateTime('NOW');
                        $now->setTimeZone(new DateTimeZone(date_default_timezone_get()));

                        $diff = $now->getTimestamp() - $date->getTimestamp();

                        if ($diff <= App::$config->errorReporting->frequency * 60) {
                            $found = true;
                        }
                    }
                }

                if ($found) {
                    $this->xssLog .= $line;
                }
            }

            if ($this->xssLog) {
                $subject = 'Wykryto próbę ataku XSS [' . App::$config->notifications->nameFrom . ']';
                $content = $this->getXssLogContent();
                $attachments = [
                    'xss.log' => str_replace("\n", "\r\n", $this->xssLog),
                ];

                $this->reportResult($subject, $content, $attachments);
            }
        }

        return $this;
    }

    /**
     * Returns xss log file name
     * @return string
     */
    private static function getXssLogPath(): string
    {
        return LOG_PATH . 'php/xss.log';
    }

    /**
     * Returns xss log message
     * @return string
     */
    private function getXssLogContent(): string
    {
        return 'W aplikacji: ' . App::$config->notifications->nameFrom . ' wykryto próbę ataku XSS';
    }

    /**
     * @return $this
     * @throws InvalidArgumentException
     * @throws NoColumnsException
     * @throws ReflectionException
     */
    private function checkUnfinishedTasks(): self
    {
        $tasks = self::$_db->getRepository(CronLog::class)
            ->findUnfinishedTasks();

        if (count($tasks)) {
            $jobNamesArray = array_unique(Arrays::getColumn($tasks, 'job_name'));
            sort($jobNamesArray);
            $unfinishedJobNames = implode(', ', $jobNamesArray);
            $subject = 'Zadania cronjob ' . $unfinishedJobNames . ' nie zakończyły się w ciągu 4 godzin [' . App::$config->notifications->nameFrom . ']';
            $content = $this->getUnfinishedTasksMessage($tasks);

            $this->reportResult($subject, $content);

            if (!$this->testMode) {
                $this->markTasksReported($tasks);
            }
        }

        return $this;
    }

    /**
     * @param CronLog[] $tasks
     * @return string
     */
    private function getUnfinishedTasksMessage(array $tasks): string
    {
        $messageLines = [];

        foreach ($tasks as $task) {
            $messageLines[] = $task->getName() . ' - ' . $task['date_started'];
        }
        sort($messageLines);
        $message = 'Zadania cronjob nie zakończyły się w ciągu 4 godzin: ' . PHP_EOL . PHP_EOL . implode(PHP_EOL, $messageLines);

        return $message;
    }

    /**
     * @param CronLog[] $tasks
     * @return void
     * @throws InvalidArgumentException
     * @throws ReflectionException
     */
    private function markTasksReported(array $tasks): void
    {
        foreach ($tasks as $task) {
            $task['status'] = CronJob::STATUS_FAILED;
            $task['info'] = 'Zadanie nie zakończyło się w wyznaczonym czasie';

            self::$_db->merge($task);
        }
    }

    /**
     * {@inheritDoc}
     * @throws InvalidArgumentException
     * @throws NoColumnsException
     * @throws ReflectionException
     */
    public function test(): string
    {
        $this->testMode = true;

        $this
            ->checkErrorLog()
            ->checkXss()
            ->checkUnfinishedTasks()
        ;

        if (empty($this->output)) {
            return 'Nie znaleziono problemów';
        }

        return $this->output;
    }
}
