<?php
/**
 * This file is part of FacturaScripts
 * Copyright (C) 2017-2026 Carlos Garcia Gomez <carlos@facturascripts.com>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */

namespace FacturaScripts\Core\Controller;

use Exception;
use FacturaScripts\Core\Cache;
use FacturaScripts\Core\Contract\ControllerInterface;
use FacturaScripts\Core\Kernel;
use FacturaScripts\Core\Plugins;
use FacturaScripts\Core\Tools;
use FacturaScripts\Core\Where;
use FacturaScripts\Core\WorkQueue;
use FacturaScripts\Dinamic\Lib\Import\CSVImport;
use FacturaScripts\Dinamic\Lib\ReceiptGenerator;
use FacturaScripts\Dinamic\Model\AlbaranCliente;
use FacturaScripts\Dinamic\Model\AlbaranProveedor;
use FacturaScripts\Dinamic\Model\AttachedFileRelation;
use FacturaScripts\Dinamic\Model\CronJob;
use FacturaScripts\Dinamic\Model\EmailNotification;
use FacturaScripts\Dinamic\Model\Fabricante;
use FacturaScripts\Dinamic\Model\FacturaCliente;
use FacturaScripts\Dinamic\Model\FacturaProveedor;
use FacturaScripts\Dinamic\Model\Familia;
use FacturaScripts\Dinamic\Model\LogMessage;
use FacturaScripts\Dinamic\Model\PedidoCliente;
use FacturaScripts\Dinamic\Model\PedidoProveedor;
use FacturaScripts\Dinamic\Model\PresupuestoCliente;
use FacturaScripts\Dinamic\Model\PresupuestoProveedor;
use FacturaScripts\Dinamic\Model\Producto;
use FacturaScripts\Dinamic\Model\ReciboCliente;
use FacturaScripts\Dinamic\Model\ReciboProveedor;
use FacturaScripts\Dinamic\Model\WorkEvent;
use ParseCsv\Csv;

class Cron implements ControllerInterface
{
    public function __construct(string $className, string $url = '')
    {
    }

    public function getPageData(): array
    {
        return [];
    }

    public function run(): void
    {
        header('Content-Type: text/plain');
        $this->echoLogo();

        Tools::log('cron')->notice('starting-cron');
        echo PHP_EOL . PHP_EOL . Tools::trans('starting-cron');
        ob_flush();

        // ejecutamos el cron de cada plugin
        $this->runPlugins();

        // ejecutamos los trabajos del core
        $this->runCoreJobs();

        // ejecutamos la cola de trabajos
        $this->runWorkQueue();

        // mostramos los mensajes del log
        $levels = ['critical', 'error', 'info', 'notice', 'warning'];
        foreach (Tools::log()::read('', $levels) as $message) {
            // si el canal no es master o database, no lo mostramos
            if (!in_array($message['channel'], ['master', 'database'])) {
                continue;
            }

            echo PHP_EOL . $message['message'];
            ob_flush();
        }

        // limpiamos los archivos expirados de la caché
        Cache::expire();

        // mensaje de finalización
        $context = [
            '%timeNeeded%' => Kernel::getExecutionTime(3),
            '%memoryUsed%' => $this->getMemorySize(memory_get_peak_usage())
        ];
        echo PHP_EOL . PHP_EOL . Tools::trans('finished-cron', $context) . PHP_EOL . PHP_EOL;
        Tools::log()->notice('finished-cron', $context);
    }

    private function echoLogo(): void
    {
        if (PHP_SAPI === 'cli') {
            echo <<<END

  ______         _                    _____           _       _       
 |  ____|       | |                  / ____|         (_)     | |      
 | |__ __ _  ___| |_ _   _ _ __ __ _| (___   ___ _ __ _ _ __ | |_ ___ 
 |  __/ _` |/ __| __| | | | '__/ _` |\___ \ / __| '__| | '_ \| __/ __|
 | | | (_| | (__| |_| |_| | | | (_| |____) | (__| |  | | |_) | |_\__ \
 |_|  \__,_|\___|\__|\__,_|_|  \__,_|_____/ \___|_|  |_| .__/ \__|___/
                                                       | |            
                                                       |_|
END;
        }
    }

    private function getMemorySize(int $size): string
    {
        $unit = ['b', 'kb', 'mb', 'gb', 'tb', 'pb'];
        return round($size / pow(1024, ($i = floor(log($size, 1024)))), 2) . $unit[$i];
    }

    private function job(string $name): CronJob
    {
        $job = new CronJob();
        $where = [
            Where::eq('jobname', $name),
            // algunas filas antiguas tienen cadena vacía en lugar de null
            Where::sub([
                Where::isNull('pluginname'),
                Where::orEq('pluginname', '')
            ])
        ];
        // en caso de duplicados, cargamos la fila más antigua
        if (false === $job->loadWhere($where, ['id' => 'ASC'])) {
            // no se había ejecutado nunca, lo creamos
            $job->jobname = $name;
        }

        // si es un proceso zombie, lo liberamos
        $job->releaseIfStale();

        return $job;
    }

    protected function removeOldLogs(): void
    {
        $maxDays = Tools::settings('default', 'days_log_retention', 90);
        if ($maxDays <= 0) {
            return;
        }

        $minDate = Tools::dateTime('-' . $maxDays . ' days');
        echo PHP_EOL . PHP_EOL . Tools::trans('removing-logs-until', ['%date%' => $minDate]) . ' ... ';
        ob_flush();

        $query = LogMessage::table()
            ->whereNotIn('channel', ['audit', 'docs'])
            ->whereLt('time', $minDate);

        if (false === $query->delete()) {
            Tools::log('cron')->warning('old-logs-delete-error');
            return;
        }

        Tools::log('cron')->notice('old-logs-delete-ok');
    }

    protected function removeOldWorkEvents(): void
    {
        $maxDays = Tools::settings('default', 'days_log_retention', 90);
        if ($maxDays <= 0) {
            return;
        }

        $minDate = Tools::dateTime('-' . $maxDays . ' days');

        $query = WorkEvent::table()
            ->whereEq('done', true)
            ->whereLt('creation_date', $minDate);

        if (false === $query->delete()) {
            Tools::log('cron')->warning('old-work-events-delete-error');
            return;
        }

        Tools::log('cron')->notice('old-work-events-delete-ok');
    }

    protected function restoreNotifications(): void
    {
        echo PHP_EOL . PHP_EOL . Tools::trans('restoring-notifications') . ' ... ';
        ob_flush();

        // obtenemos las notificaciones existentes en la base de datos
        $existing = [];
        $emailNotification = new EmailNotification();
        foreach ($emailNotification->all() as $notification) {
            $existing[] = $notification->name;
        }

        // obtenemos las notificaciones que deberían existir del CSV
        $filePath = CSVImport::getTableFilePath(EmailNotification::tableName());
        if (empty($filePath)) {
            return;
        }

        // leemos el CSV y restauramos solo las que faltan
        $csv = new Csv();
        $csv->auto($filePath);

        $restored = 0;
        foreach ($csv->data as $row) {
            // si ya existe, la saltamos
            if (in_array($row['name'], $existing)) {
                continue;
            }

            // creamos la notificación que falta
            $notification = new EmailNotification();
            $notification->name = $row['name'];
            $notification->enabled = $row['enabled'] === 'true';
            $notification->subject = $row['subject'];
            $notification->body = $row['body'];
            if ($notification->save()) {
                $restored++;
            }
        }

        if ($restored > 0) {
            Tools::log('cron')->notice('restored-notifications', ['%count%' => $restored]);
        }
    }

    protected function runCoreJobs(): void
    {
        $this->job('update-attached-relations')
            ->everyDayAt(0)
            ->run(function () {
                $this->updateAttachedRelations();
            });

        $this->job('update-families')
            ->everyDayAt(1)
            ->run(function () {
                $this->updateFamilies();
            });

        $this->job('update-manufacturers')
            ->everyDayAt(2)
            ->run(function () {
                $this->updateManufacturers();
            });

        $this->job('remove-old-logs')
            ->everyDayAt(3)
            ->run(function () {
                $this->removeOldLogs();
                $this->removeOldWorkEvents();
            });

        $this->job('update-receipts')
            ->everyDayAt(4)
            ->run(function () {
                $this->updateReceipts();
            });

        $this->job('restore-notifications')
            ->everyDayAt(5)
            ->run(function () {
                $this->restoreNotifications();
            });

        $this->job('update-unpaid-invoices')
            ->everyDayAt(6)
            ->run(function () {
                $this->updateUnpaidInvoices();
            });
    }

    protected function runPlugins(): void
    {
        foreach (Plugins::enabled() as $pluginName) {
            $cronClass = '\\FacturaScripts\\Plugins\\' . $pluginName . '\\Cron';
            if (false === class_exists($cronClass)) {
                continue;
            }

            echo PHP_EOL . Tools::trans('running-plugin-cron', ['%pluginName%' => $pluginName]) . ' ... ';
            Tools::log('cron')->notice('running-plugin-cron', ['%pluginName%' => $pluginName]);

            try {
                $cron = new $cronClass($pluginName);
                $cron->run();
            } catch (Exception $ex) {
                echo $ex->getMessage() . PHP_EOL;
                Tools::log('cron')->error($ex->getMessage());
            }

            ob_flush();

            // si se ha superado el tiempo máximo de ejecución definido, se detiene
            if (CronJob::isMaxExecutionTimeReached()) {
                echo PHP_EOL . PHP_EOL . Tools::trans('cron-max-execution-time-reached', [
                    '%seconds%' => CronJob::getMaxExecutionTime(),
                ]);
                break;
            }

            // si no se está ejecutando en modo cli y lleva más de 20 segundos, se detiene
            if (PHP_SAPI != 'cli' && Kernel::getExecutionTime() > 20) {
                echo PHP_EOL . PHP_EOL . Tools::trans('cron-timeout');
                break;
            }
        }
    }

    protected function runWorkQueue(): void
    {
        $max = rand(25, 1000);

        echo PHP_EOL . PHP_EOL . Tools::trans('running-work-queue') . ' ... (' . $max . ') ';
        ob_flush();

        while ($max > 0) {
            if (false === WorkQueue::run()) {
                break;
            }

            echo '.';
            ob_flush();

            --$max;

            // si se ha superado el tiempo máximo de ejecución definido, terminamos
            if (CronJob::isMaxExecutionTimeReached()) {
                echo PHP_EOL . PHP_EOL . Tools::trans('cron-max-execution-time-reached', [
                    '%seconds%' => CronJob::getMaxExecutionTime(),
                ]);
                return;
            }

            // si no se está ejecutando en modo cli y lleva más de 25 segundos, terminamos
            if (PHP_SAPI != 'cli' && Kernel::getExecutionTime() > 25) {
                echo PHP_EOL . PHP_EOL . Tools::trans('cron-timeout');
                return;
            }
        }
    }

    protected function updateAttachedRelations(): void
    {
        echo PHP_EOL . PHP_EOL . Tools::trans('updating-attached-relations') . ' ... ';
        ob_flush();

        // si no hay relaciones con archivos adjuntos, terminamos
        $relationModel = new AttachedFileRelation();
        if (0 === $relationModel->count()) {
            return;
        }

        // elegimos un modelo al azar
        $models = [
            new AlbaranCliente(), new FacturaCliente(), new PedidoCliente(), new PresupuestoCliente(),
            new AlbaranProveedor(), new FacturaProveedor(), new PedidoProveedor(), new PresupuestoProveedor()
        ];
        shuffle($models);
        echo $models[0]->modelClassName();
        ob_flush();

        // recorremos todos los documentos
        $limit = 100;
        $offset = 0;
        $orderBy = ['codigo' => 'ASC'];
        $documents = $models[0]->all([], $orderBy, 0, $limit);
        while (!empty($documents)) {
            foreach ($documents as $doc) {
                $where = [Where::eq('model', $doc->modelClassName())];
                $where[] = is_numeric($doc->id()) ?
                    Where::eq('modelid|modelcode', $doc->id()) :
                    Where::eq('modelcode', $doc->id());

                $num = $relationModel->count($where);
                if ($num == $doc->numdocs) {
                    continue;
                }

                $doc->numdocs = $num;
                if (false === $doc->save()) {
                    Tools::log('cron')->error('record-save-error', [
                        '%model%' => $doc->modelClassName(),
                        '%id%' => $doc->id()
                    ]);
                    break;
                }
            }

            $offset += $limit;
            $documents = $models[0]->all([], $orderBy, $offset, $limit);
        }
    }

    protected function updateFamilies(): void
    {
        echo PHP_EOL . PHP_EOL . Tools::trans('updating-families') . ' ... ';
        ob_flush();

        // recorremos todas las familias para actualizar su contador de productos
        foreach (Familia::all() as $familia) {
            $count = Producto::count([Where::eq('codfamilia', $familia->codfamilia)]);
            if ($familia->numproductos == $count) {
                continue;
            }

            $familia->numproductos = $count;
            $familia->save();
        }
    }

    protected function updateManufacturers(): void
    {
        echo PHP_EOL . PHP_EOL . Tools::trans('updating-manufacturers') . ' ... ';
        ob_flush();

        // recorremos todos los fabricantes para actualizar su contador de productos
        foreach (Fabricante::all() as $fabricante) {
            $count = Producto::count([Where::eq('codfabricante', $fabricante->codfabricante)]);
            if ($fabricante->numproductos == $count) {
                continue;
            }

            $fabricante->numproductos = $count;
            $fabricante->save();
        }
    }

    protected function updateReceipts(): void
    {
        echo PHP_EOL . PHP_EOL . Tools::trans('updating-receipts') . ' ... ';
        ob_flush();

        // recorremos todos los recibos de compra impagados con fecha anterior a hoy
        $where = [
            Where::eq('pagado', false),
            Where::lt('vencimiento', Tools::date())
        ];
        $orderBy = ['vencimiento' => 'DESC'];
        foreach (ReciboProveedor::all($where, $orderBy, 0, 500) as $recibo) {
            // si el código de factura ha cambiado, lo guardamos
            $factura = $recibo->getInvoice();
            if ($recibo->codigofactura != $factura->codigo) {
                $recibo->codigofactura = $factura->codigo;
            }

            // guardamos para que se actualice
            $recibo->save();
        }

        // recorremos todos los recibos de venta impagados con fecha anterior a hoy
        foreach (ReciboCliente::all($where, $orderBy, 0, 500) as $recibo) {
            // si el código de factura ha cambiado, lo guardamos
            $factura = $recibo->getInvoice();
            if ($recibo->codigofactura != $factura->codigo) {
                $recibo->codigofactura = $factura->codigo;
            }

            // guardamos para que se actualice
            $recibo->save();
        }
    }

    protected function updateUnpaidInvoices(): void
    {
        echo PHP_EOL . PHP_EOL . Tools::trans('updating-unpaid-invoices') . ' ... ';
        ob_flush();

        $generator = new ReceiptGenerator();
        $where = [Where::eq('pagada', false)];
        $orderBy = ['fecha' => 'DESC'];

        // recorremos las facturas de cliente impagadas
        foreach (FacturaCliente::all($where, $orderBy, 0, 500) as $factura) {
            $generator->update($factura);
        }

        // recorremos las facturas de proveedor impagadas
        foreach (FacturaProveedor::all($where, $orderBy, 0, 500) as $factura) {
            $generator->update($factura);
        }
    }
}
