<?php

/**
 * Admin Company Automation Settings
 *
 * @package blesta
 * @subpackage app.controllers
 * @copyright Copyright (c) 2010, Phillips Data, Inc.
 * @license http://www.blesta.com/license/ The Blesta License Agreement
 * @link http://www.blesta.com/ Blesta
 */
class AdminCompanyAutomation extends AdminController
{
    /**
     * @var int The number of hours of cron history shown in the execution timeline
     */
    const TIMELINE_HOURS = 1;

    /**
     * @var int The maximum gap (in seconds) between consecutive completed runs that will
     *  be merged into a single timeline span. This keeps high-frequency tasks (e.g. those
     *  running every minute) from producing thousands of bars that lock up the browser,
     *  while still breaking the span apart whenever the task was genuinely idle.
     */
    const TIMELINE_MERGE_GAP_SECONDS = 120;

    /**
     * Pre-action
     */
    public function preAction()
    {
        parent::preAction();

        $this->uses(['Companies', 'CronTasks', 'Logs']);
        $this->components(['SettingsCollection']);
        $this->helpers(['DataStructure']);

        $this->ArrayHelper = $this->DataStructure->create('Array');
    }

    /**
     * Automation settings
     */
    public function index()
    {
        // Set the type of task
        $task_type = 'system';
        $task_types = $this->CronTasks->getTaskTypes();
        if (isset($this->get[0]) && array_key_exists($this->get[0], $task_types)) {
            $task_type = $this->get[0];
        }

        // Get all the cron tasks
        $one_minute = false;
        $task_runs = $this->CronTasks->getAllTaskRun(false, $task_type);
        $vars = $this->groupTasks($this->appendTaskInfo($task_runs));
        foreach ($vars as $group) {
            foreach ($group['tasks'] as &$task) {
                // Cast the time to the proper local time format
                if (!empty($task->time)) {
                    $task->time = $this->Date->cast($task->time, 'H:i:s');
                }

                // Set supported intervals, if available
                if (!empty($task->interval)) {
                    $task->supported_intervals = $this->getIntervals($task->key, $task->task_type);

                    if ($task->interval == 1) {
                        $one_minute = true;
                    }
                }
            }
        }

        if (!empty($this->post)) {
            // Ensure the 'enabled' setting is available even when all checkboxes are unchecked
            $this->post = array_merge(['enabled' => []], $this->post);

            // Start a transaction
            $this->CronTasks->begin();

            $errors = [];

            // Update the provided fields on each cron task
            foreach ($task_runs as &$task) {
                foreach ($this->post as $key => $task_run_ids) {
                    // Skip updating cron task settings that are not in this list
                    if (!in_array($key, ['enabled', 'time', 'interval'])) {
                        continue;
                    }

                    // Set the selected value for this field
                    if (array_key_exists($task->task_run_id, (array) $task_run_ids)) {
                        $task->{$key} = $task_run_ids[$task->task_run_id];
                    } elseif ($key == 'enabled') {
                        // The 'enabled' checkbox should be set to disabled if it's not given
                        $task->enabled = '0';
                    }
                }

                $this->CronTasks->editTaskRun($task->task_run_id, (array) $task);

                // Keep the most recent errors by breaking out
                if ($this->CronTasks->errors()) {
                    break;
                }
            }

            // Use only the most recent cron task's errors.
            // Note: there should never be errors
            if (($errors = $this->CronTasks->errors())) {
                // Error, rollback and reset vars
                $this->CronTasks->rollBack();

                $this->setMessage('error', $errors);
                $vars = $this->groupTasks($this->appendTaskInfo($task_runs));
            } else {
                // Success, commit changes
                $this->CronTasks->commit();

                $this->flashMessage('message', Language::_('AdminCompanyAutomation.!success.automation_updated', true));
                $this->redirect($this->base_uri . 'settings/company/automation/index/' . $task_type . '/');
            }
        }

        // Verify if the cron is running every minute
        $last_runs = $this->Logs->getCronLastExecution(2);
        if (count($last_runs) == 2) {
            $runtime = round(
                (strtotime($last_runs[0]->start_date) - strtotime($last_runs[1]->start_date)) / 60,
                0,
                PHP_ROUND_HALF_DOWN
            );

            // Show the message if the cron is running within an interval less than 2 minutes (leaving 1 of tolerance)
            // and there is at least one automation task set to an interval of 1 minute
            if ($one_minute && $runtime >= 2) {
                $this->setMessage('notice', Language::_('AdminCompanyAutomation.!notice.cron_task_interval', true));
            }
        }

        // Fetch task execution timeline data for the past 24 hours (all task types)
        $all_task_runs = $this->CronTasks->getAllTaskRun();
        $timeline_series = $this->getTaskTimelineSeries($all_task_runs);
        $timeline_now = strtotime(date('c')) * 1000;

        $this->set('company_timezone', str_replace('_', ' ', Configure::get('Blesta.company_timezone')));
        $this->set('time_values', $this->getTimes(5));
        $this->set('task_types', $task_types);
        $this->set('tab', $task_type);
        $this->set('timeline_now', $timeline_now);
        $this->set('timeline_hours', self::TIMELINE_HOURS);
        $this->set('timeline_series', $timeline_series);
        $this->set('vars', $vars);
    }

    /**
     * Clears the POSTed cron task
     */
    public function clearTask()
    {
        // Clear the cron task
        if (!empty($this->post) && !empty($this->post['run_id'])) {
            $this->Logs->clearCronTask($this->post['run_id']);
        }

        $this->flashMessage('message', Language::_('AdminCompanyAutomation.!success.task_cleared', true));
        $this->redirect(
            $this->base_uri . 'settings/company/automation/'
                . (isset($this->get[0]) ? 'index/' . $this->get[0] . '/' : '')
        );
    }

    /**
     * Retrieve a list of available cron task intervals (in minutes)
     *
     * @param mixed $task The task to fetch the interval (optional)
     * @return array A list of intervals
     */
    private function getIntervals($task = null, $task_type = null)
    {
        if (is_null($task_type)) {
            $task_type = 'system';
        }

        $intervals = [1 => 1, 5 => 5, 10 => 10, 15 => 15, 30 => 30, 45 => 45];

        // Add 1-minute interval for supported system tasks
        if ($task_type == 'system') {
            $intervals = [5 => 5, 10 => 10, 15 => 15, 30 => 30, 45 => 45];
            $tasks = [
                'process_service_changes',
                'process_renewing_services',
                'provision_pending_services',
                'unsuspend_services'
            ];

            if (in_array($task, $tasks) && !is_null($task)) {
                $intervals = [1 => 1] + $intervals;
            }
        }

        foreach ($intervals as &$interval) {
            $interval = $interval . ' '
                . Language::_('AdminCompanyAutomation.getintervals.text_minute' . ($interval == 1 ? '' : 's'), true);
        }

        // Set each hour up to 24 hours
        for ($i = 1; $i <= 24; $i++) {
            $intervals[$i * 60] = $i . ' '
                . Language::_('AdminCompanyAutomation.getintervals.text_hour' . (($i == 1) ? '' : 's'), true);
        }

        return $intervals;
    }

    /**
     * Updates the given task objects to append properties representing additional information on the task
     *
     * @param array $task_runs array An array of stdClass objects representing each cron task run
     * @return array An array of stdClass objects representing each cron task run, but now including:
     *
     *  - last_ran
     *  - is_running
     *  - is_stalled
     */
    private function appendTaskInfo(array $task_runs)
    {
        // Fetch currently running tasks
        $running_tasks = [];
        foreach ($this->Logs->getRunningCronTasks() as $task) {
            $running_tasks[$task->run_id] = $task;
        }

        $base_cron_tasks = [];
        foreach ($task_runs as &$task) {
            // Fetch the base cron task in this tasks' group to check whether it has completed
            $group = null;
            if (array_key_exists($task->task_run_id, $running_tasks)) {
                // Create a hash of base cron tasks by group
                if (!isset($base_cron_tasks[$running_tasks[$task->task_run_id]->group])) {
                    $base_cron_tasks[$running_tasks[$task->task_run_id]->group] = $this->Logs->getSystemCronLastRun(
                        $running_tasks[$task->task_run_id]->group
                    );
                }

                // Set the group to filter this latest cron task on
                if ($base_cron_tasks[$running_tasks[$task->task_run_id]->group]) {
                    $base_task = $base_cron_tasks[$running_tasks[$task->task_run_id]->group];
                    $group = ($base_task->end_date !== null ? $base_task->group : null);
                    unset($base_task);
                }
            }

            // Fetch the latest cron task
            $task_log = $this->Logs->getLatestCron($task->task_run_id, $group);

            // Set the date this cron task last ran
            $task->last_ran = (!empty($task_log) ? $this->Date->cast($task_log->start_date, 'date_time') : null);
            $task->is_running = (!empty($task_log) && $task_log->end_date === null);
            $task->is_stalled = (!empty($task_log) && $task_log->end_date === null && $group !== null);
        }

        return $task_runs;
    }

    /**
     * Builds ApexCharts range-bar series for cron task timeline data
     *
     * @param array $task_runs An array of cron task runs
     * @return array A list of series entries for ApexCharts
     */
    private function getTaskTimelineSeries(array $task_runs)
    {
        if (empty($task_runs)) {
            return [];
        }

        $task_name_map = [];
        $task_run_ids = [];
        foreach ($task_runs as $task) {
            $task_run_ids[] = (int) $task->task_run_id;
            $task_name_map[(int) $task->task_run_id] = $task->real_name;
        }

        $timeline_logs = $this->Logs->getCronTimelineByTaskRuns($task_run_ids, self::TIMELINE_HOURS);
        if (empty($timeline_logs)) {
            return [];
        }

        $now = strtotime(date('c')) * 1000;
        $window_start = $now - (self::TIMELINE_HOURS * 60 * 60 * 1000);

        // Normalize each log into a run grouped by task (logs are ordered by start_date ASC)
        $runs_by_task = [];
        foreach ($timeline_logs as $log) {
            $run_id = (int) $log->run_id;
            $task_name = ($task_name_map[$run_id] ?? ('Run #' . $run_id));
            $start = strtotime($log->start_date) * 1000;
            $end = ($log->end_date !== null ? strtotime($log->end_date) * 1000 : $now);

            // Ignore invalid entries and anything completely outside the window
            if ($start <= 0 || $end <= 0 || $end < $window_start) {
                continue;
            }

            $runs_by_task[$task_name][] = [
                'start' => $start,
                'end' => $end,
                'status' => ($log->end_date === null ? 'running' : 'completed')
            ];
        }

        // Merge consecutive, near-continuous completed runs into a single span. This keeps
        // the chart legible (and renderable) for tasks that run every minute, without ever
        // hiding a genuine idle period: a gap larger than the threshold breaks the span,
        // and in-progress (running) tasks are never merged.
        $gap_threshold = self::TIMELINE_MERGE_GAP_SECONDS * 1000;
        $series = [];
        foreach ($runs_by_task as $task_name => $runs) {
            $spans = [];
            $current = null;

            foreach ($runs as $run) {
                if ($current !== null
                    && $current['status'] === 'completed'
                    && $run['status'] === 'completed'
                    && ($run['start'] - $current['end']) <= $gap_threshold
                ) {
                    // Extend the current span to cover this run
                    $current['end'] = max($current['end'], $run['end']);
                    $current['count']++;
                    $current['active'] += max(0, $run['end'] - $run['start']);
                } else {
                    if ($current !== null) {
                        $spans[] = $current;
                    }
                    $current = [
                        'start' => $run['start'],
                        'end' => $run['end'],
                        'status' => $run['status'],
                        'count' => 1,
                        'active' => max(0, $run['end'] - $run['start'])
                    ];
                }
            }

            if ($current !== null) {
                $spans[] = $current;
            }

            $data = [];
            foreach ($spans as $span) {
                // Clamp start so spans crossing the boundary remain visible
                $clamped_start = max($span['start'], $window_start);
                $color = ($span['status'] == 'running' ? '#ffc107' : '#198754');

                $data[] = [
                    'x' => Language::_('AdminCompanyAutomation.index.text_timeline_execution', true),
                    'y' => [$clamped_start, $span['end']],
                    'fillColor' => $color,
                    'status' => $span['status'],
                    'duration' => (int) round($span['active'] / 1000),
                    'runs' => $span['count'],
                    'hasEndTime' => ($span['status'] != 'running')
                ];
            }

            $series[] = ['name' => $task_name, 'data' => $data];
        }

        return $series;
    }

    /**
     * Creates a set of task groups from runnable tasks based on the module or plugin
     *
     * @param array $task_runs array An array of stdClass objects representing each cron task run
     * @return array An array of plugin/module groups containing the name of the group and the task IDs in that group:
     *
     *  - name The name of the group
     *  - task_ids An array of each task ID in the group
     */
    private function groupTasks(array $task_runs)
    {
        // Group each task by system/plugin/module
        $groups = [];
        foreach ($task_runs as $task) {
            if (!empty($task->plugin)) {
                if (!isset($groups[$task->plugin->dir])) {
                    $groups[$task->plugin->dir] = ['name' => $task->plugin->name, 'tasks' => []];
                }

                $groups[$task->plugin->dir]['tasks'][$task->task_run_id] = $task;
            } elseif (!empty($task->module)) {
                if (!isset($groups[$task->module->class])) {
                    $groups[$task->module->class] = ['name' => $task->module->name, 'tasks' => []];
                }

                $groups[$task->module->class]['tasks'][$task->task_run_id] = $task;
            } else {
                if (!isset($groups['system'])) {
                    $groups['system'] = ['name' => '', 'tasks' => []];
                }

                $groups['system']['tasks'][$task->task_run_id] = $task;
            }
        }

        // Sort the groups by name
        $names = [];
        foreach ($groups as $index => &$group) {
            $names[$index] = $group['name'];

            // Sort the tasks within each group by name
            $task_names = [];
            foreach ($group['tasks'] as $key => $task) {
                $task_names[$key] = $task->real_name;
            }
            array_multisort($task_names, SORT_NATURAL, $group['tasks']);
        }
        array_multisort($names, SORT_NATURAL, $groups);

        return $groups;
    }
}
