<?php
/** 
 * @package     VikBooking
 * @subpackage  core
 * @author      E4J s.r.l.
 * @copyright   Copyright (C) 2025 E4J s.r.l. All Rights Reserved.
 * @license     http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
 * @link        https://vikwp.com
 */

// No direct access
defined('ABSPATH') or die('No script kiddies please!');

/**
 * Declares all task driver methods.
 * 
 * @since   1.18.0 (J) - 1.8.0 (WP)
 */
abstract class VBOTaskDriveraware implements VBOTaskDriverinterface
{
    /**
     * @var  ?VBOTaskArea
     */
    protected $area;

    /**
     * @var  array
     */
    protected $settings = [];

    /**
     * @var  array
     */
    protected $operators = [];

    /**
     * @var  VBOTaskDrivercollector
     */
    protected $collector;

    /**
     * Proxy to construct the task driver object.
     * 
     * @param   ?VBOTaskArea     $area   The task area object.
     * 
     * @return  VBOTaskDriverinterface
     */
    public static function getInstance(?VBOTaskArea $area = null)
    {
        return new static($area);
    }

    /**
     * Class constructor.
     * 
     * @param   ?VBOTaskArea     $area   The task area object.
     */
    public function __construct(?VBOTaskArea $area = null)
    {
        // set task area
        $this->area = $area;

        if ($this->area) {
            // load task settings from current task area
            $this->settings = $this->area->loadSettings();
        }

        // start a new collector registry
        $this->collector = VBOTaskDrivercollector::getInstance();
    }

    /**
     * Returns the name of the task driver.
     * 
     * @return  string  The driver readable name.
     */
    public function getName()
    {
        return ucfirst($this->getID());
    }

    /**
     * Returns the task driver icon.
     * 
     * @return  string  The font-icon class identifier.
     */
    public function getIcon()
    {
        return VikBookingIcons::i('tasks');
    }

    /**
     * Returns the task driver parameters to configure an area.
     * 
     * @return  array   List of driver parameters.
     */
    public function getParams()
    {
        return [];
    }

    /**
     * @inheritDoc
     */
    public function scheduleBookingConfirmation(VBOTaskBooking $booking)
    {
        // no automatic scheduling supported upon booking confirmation
    }

    /**
     * @inheritDoc
     */
    public function scheduleBookingAlteration(VBOTaskBooking $booking)
    {
        // no automatic scheduling supported upon booking alteration
    }

    /**
     * @inheritDoc
     */
    public function scheduleBookingCancellation(VBOTaskBooking $booking)
    {
        // no automatic scheduling supported upon booking cancellation
    }

    /**
     * Returns the task driver settings for the configured area.
     * 
     * @return  array
     */
    public function getSettings()
    {
        return $this->settings;
    }

    /**
     * Sets the task driver settings.
     * 
     * @param   array   $settings   The settings to set.
     * @param   bool    $merge      True for merging the previous settings.
     * 
     * @return  void
     */
    public function setSettings(array $settings, bool $merge = false)
    {
        $this->settings = array_merge(($merge ? $this->settings : []), $settings);
    }

    /**
     * Saves the task driver settings into its current area.
     * 
     * @param   ?array  $settings   Optional settings to save.
     * 
     * @return  void
     */
    public function saveSettings(?array $settings = null)
    {
        $this->area->saveSettings((is_array($settings) ? $settings : $this->settings));
    }

    /**
     * Returns a specific task driver setting.
     * 
     * @param   string  $name       The setting name.
     * @param   mixed   $default    The default setting.
     * 
     * @return  mixed
     */
    public function getSetting(string $name, $default = null)
    {
        return $this->settings[$name] ?? $default;
    }

    /**
     * Sets a value for a specific task driver setting.
     * 
     * @param   string  $name   The setting name.
     * @param   mixed   $value  The value to set.
     * 
     * @return  void
     */
    public function setSetting(string $name, $value)
    {
        $this->settings[$name] = $value;
    }

    /**
     * Returns the current task driver collector.
     * 
     * @param   bool    $reset  True for resetting the collector.
     * 
     * @return  VBOTaskDrivercollector
     */
    public function getCollector(bool $reset = false)
    {
        if ($reset) {
            return $this->collector->reset();
        }

        return $this->collector;
    }

    /**
     * Returns the current project/area ID, if available.
     * 
     * @return  int     The current area ID or 0.
     */
    public function getAreaID()
    {
        return $this->area ? $this->area->getID() : 0;
    }

    /**
     * Returns the current project/area name, if available.
     * 
     * @return  string     The current area name or empty string.
     */
    public function getAreaName()
    {
        return $this->area ? $this->area->getName() : '';
    }

    /**
     * Returns the default status for new tasks for the current project/area, if any.
     * 
     * @return  ?string  The default status enumeration or null.
     */
    public function getDefaultStatus()
    {
        return $this->area ? ($this->area->getDefaultStatus() ?: null) : null;
    }

    /**
     * Returns the default task duration in minutes.
     * 
     * @return  int
     */
    public function getDefaultDuration()
    {
        // the driver may declare a parameter for the task default duration in minutes
        return intval($this->getSetting('taskduration', 0)) ?: 60;
    }

    /**
     * Returns the eligible operator IDs for the task driver.
     * 
     * @return  array   List of eligible operator IDs or empty array.
     */
    public function getOperatorIds()
    {
        // the driver may declare a parameter to filter the eligible operators
        return array_values(array_filter((array) $this->getSetting('operators', [])));
    }

    /**
     * Returns the eligible listing IDs for the task driver.
     * 
     * @return  array   List of eligible listing IDs or empty array.
     */
    public function getListingIds()
    {
        // the driver may declare a parameter to filter the eligible listings
        return array_values(array_filter((array) $this->getSetting('listings', [])));
    }

    /**
     * Tells whether a listing ID is eligible according to the current task driver settings.
     * 
     * @param   int     $listingId  The listing ID to evaluate.
     * 
     * @return  bool
     */
    public function isListingEligible(int $listingId)
    {
        $eligible_ids = array_map('intval', $this->getListingIds());

        return !$eligible_ids || in_array($listingId, $eligible_ids);
    }

    /**
     * Loads the eligible operators for the task driver.
     * 
     * @param   bool    $elements           True for getting the operators as elements to render.
     * @param   array   $activeAssignees    Optional list of active assignee IDs to merge.
     * 
     * @return  array   Associative (by ID) list of operator array records.
     */
    public function getOperators(bool $elements = false, array $activeAssignees = [])
    {
        $operatorIds = array_values(array_unique(array_merge($this->getOperatorIds(), array_filter($activeAssignees))));

        if ($elements) {
            // always avoid caching when element records are requested
            return VikBooking::getOperatorInstance()->getElements($operatorIds);
        }

        if ($this->operators) {
            // return the cached operator records
            return $this->operators;
        }

        // get all the eligible operators
        $operators = VikBooking::getOperatorInstance()->getAll($operatorIds);

        // map some internal properties
        $operators = array_map(function($operator) {
            // decode or set the needed information
            $operator['perms'] = !empty($operator['perms']) ? (is_string($operator['perms']) ? (array) json_decode($operator['perms'], true) : $operator['perms']) : [];
            $operator['work_days_week'] = !empty($operator['work_days_week']) ? (is_string($operator['work_days_week']) ? (array) json_decode($operator['work_days_week'], true) : $operator['work_days_week']) : [];
            $operator['work_days_exceptions'] = !empty($operator['work_days_exceptions']) ? (is_string($operator['work_days_exceptions']) ? (array) json_decode($operator['work_days_exceptions'], true) : $operator['work_days_exceptions']) : [];

            // return the manipulated operator record
            return $operator;
        }, $operators);

        // cache the eligible operator records
        $this->operators = $operators;

        return $operators;
    }

    /**
     * Returns the operator record ID, if any.
     * 
     * @param   int     $operatorId     The operator ID.
     * 
     * @return  array
     */
    public function getOperatorFromId(int $operatorId)
    {
        foreach ($this->getOperators() as $operator) {
            if ($operator['id'] == $operatorId) {
                // return the requested record found
                return $operator;
            }
        }

        return [];
    }

    /**
     * Returns the working hours configured by the specified operator for the requested date.
     * 
     * @param   int|array  $operator  Either the operator ID or its details.
     * @param   DateTime   $date      The requested date.
     * 
     * @return  int        The number of working hours.
     */
    public function getDateWorkingHours($operator, DateTime $date)
    {
        if (is_numeric($operator)) {
            $operator = $this->getOperatorFromId((int) $operator);
        }

        if (empty($operator)) {
            return 0;
        }

        if (!is_array($operator['work_days_week'] ?? null)) {
            $operator['work_days_week'] = [];
        }

        if (!is_array($operator['work_days_exceptions'] ?? null)) {
            $operator['work_days_exceptions'] = [];
        }

        $ymd = $date->format('Y-m-d');

        // scan working day exceptions backward, to give higher priority to rules created last
        for ($i = count($operator['work_days_exceptions']) - 1; $i >= 0; $i--) {
            $rule = $operator['work_days_exceptions'][$i];

            if (empty($rule['from'])) {
                // missing from date, malformed rule, move on
                continue;
            }

            if (empty($rule['to'])) {
                // single date provided, to date same as from date
                $rule['to'] = $rule['from'];
            }

            // check whether the date is contained within the configured range
            if ($rule['from'] <= $ymd && $ymd <= $rule['to']) {
                // yep, return the number of working hours, if any
                return (int) ($rule['hours'] ?? 0);
            }
        }

        // no exceptions for the specified date, fallback to the default week days
        $weekDay = (int) $date->format('w');

        foreach ($operator['work_days_week'] as $rule) {
            if (!isset($rule['wday'])) {
                // missing day of the week, malformed rule, move on
                continue;
            }

            // check whether the day of the week matches the specified date
            if ($rule['wday'] == $weekDay) {
                // yep, return the number of working hours, if any
                return (int) ($rule['hours'] ?? 0);
            }
        }

        // no working hours defined, we have a day off for this operator
        return 0;
    }

    /**
     * Loads the eligible listings for the task driver.
     * 
     * @return  array   List of listing array records.
     */
    public function getListings()
    {
        return VikBooking::getAvailabilityInstance(true)->loadRooms($this->getListingIds(), 0, true);
    }

    /**
     * Given a list of scheduling interval enumerations for a specific booking, builds
     * and returns a list of task schedule objects for when tasks should be scheduled.
     * 
     * @param   array           $scheduling  List of scheduling interval enumerations.
     * @param   VBOTaskBooking  $booking     The current task booking registry.
     * 
     * @return  VBOTaskScheduleInterface[]
     */
    public function getBookingSchedulingDates(array $scheduling, VBOTaskBooking $booking)
    {
        $schedulesList = [];

        foreach ($scheduling as $scheduleEnum) {
            // obtain the schedule data for the current interval type
            $schedule = VBOTaskSchedule::getType($scheduleEnum, $booking);
            if ($schedule) {
                // push the identified schedule data
                $schedulesList[] = $schedule;
            }
        }

        // sort schedule objects by ordering (ascending)
        usort($schedulesList, function($a, $b) {
            return $a->getOrdering() <=> $b->getOrdering();
        });

        return $schedulesList;
    }

    /**
     * Returns the first available operator on the given date to handle the provided booking task.
     * 
     * @param   DateTime  $dt         The date (local timezone) for which the operator should be available.
     * @param   int       $areaId     The area where the new task should be scheduled.
     * 
     * 
     * @return  array     Available operator record or empty array.
     */
    public function getAvailableOperator(DateTime $dt, int $areaId)
    {
        $dbo = JFactory::getDbo();

        // build a list of available operator IDs according to their work days
        $availableOperators = [];

        foreach ($this->getOperators() as $operator) {
            // get operator working hours for the specified date
            $workingHours = $this->getDateWorkingHours($operator, $dt);

            if ($workingHours) {
                // register available operator with available minutes
                $availableOperators[(int) $operator['id']] = $workingHours * 60;
            }
        }

        if (!$availableOperators) {
            // no operators configured to be available for work on this day
            return [];
        }

        // sort available operators by working hours descending
        arsort($availableOperators);

        // obtain a date object in UTC and related SQL dates
        $utc_dt = JFactory::getDate($dt->format('Y-m-d H:i:s'), $dt->getTimezone()->getName());
        $utc_dt->modify('00:00:00');
        $utc_start_sql = $utc_dt->toSql();
        $utc_dt->modify('23:59:59');
        $utc_end_sql = $utc_dt->toSql();

        $areas = [
            // preload the details for the requested area
            $areaId => VBOTaskArea::getRecordInstance($areaId),
        ];

        // query the database to see what operators have got tasks assigned for this day
        // this would be the right query to eventually implement a number of do-able tasks per day per operator (default to 1)
        $dbo->setQuery(
            $dbo->getQuery(true)
                ->select($dbo->qn('ta.id_operator'))
                ->select($dbo->qn('t.id_area'))
                ->select('COUNT(1) AS ' . $dbo->qn('tot_tasks'))
                ->from($dbo->qn('#__vikbooking_tm_tasks', 't'))
                ->innerJoin($dbo->qn('#__vikbooking_tm_task_assignees', 'ta') . ' ON ' . $dbo->qn('t.id') . ' = ' . $dbo->qn('ta.id_task'))
                ->where($dbo->qn('ta.id_operator') . ' IN (' . implode(', ', array_keys($availableOperators)) . ')')
                ->where($dbo->qn('t.dueon') . ' BETWEEN ' . $dbo->q($utc_start_sql) . ' AND ' . $dbo->q($utc_end_sql))
                ->group($dbo->qn('ta.id_operator'))
                ->group($dbo->qn('t.id_area'))
        );

        foreach ($dbo->loadObjectList() as $operatorTasks) {
            if (!isset($availableOperators[$operatorTasks->id_operator])) {
                // operator not found, move on
                continue;
            }

            if (!isset($areas[$operatorTasks->id_area])) {
                // cache task area details
                $areas[$operatorTasks->id_area] = VBOTaskArea::getRecordInstance($operatorTasks->id_area);
            }

            // get default duration per task
            $duration = $areas[$operatorTasks->id_area]->getDefaultDuration();

            // decrease working minutes by the duration of all scheduled tasks
            $availableOperators[$operatorTasks->id_operator] -= $duration * $operatorTasks->tot_tasks;
        }

        // take only the operators that still have enough space to accept the new task
        $availableOperators = array_keys(array_filter($availableOperators, function($minutes) use ($areas, $areaId) {
            return ($minutes - $areas[$areaId]->getDefaultDuration()) >= 0;
        }));

        if (!$availableOperators) {
            // no operators are free on this day
            return [];
        }

        if (count($availableOperators) === 1 || VBOFactory::getConfig()->get('tm_op_assignment_strategy') === 'sequential') {
            // there's only one free operator, or the assignment strategy is not "balanced", so we return the first one
            return $this->getOperatorFromId($availableOperators[0]);
        }

        // check what operators have worked more on the closest dates (one week less and one week more)
        $utc_dt->modify('00:00:00');
        $utc_dt->modify('-7 days');
        $utc_back_sql = $utc_dt->toSql();
        $utc_dt->modify('+14 days');
        $utc_forth_sql = $utc_dt->toSql();

        // query the database to see what operators have got more tasks assigned on the closest dates
        $dbo->setQuery(
            $dbo->getQuery(true)
                ->select($dbo->qn('ta.id_operator'))
                ->select('COUNT(*) AS ' . $dbo->qn('tot_tasks'))
                ->from($dbo->qn('#__vikbooking_tm_tasks', 't'))
                ->innerJoin($dbo->qn('#__vikbooking_tm_task_assignees', 'ta') . ' ON ' . $dbo->qn('t.id') . ' = ' . $dbo->qn('ta.id_task'))
                ->where($dbo->qn('ta.id_operator') . ' IN (' . implode(', ', $availableOperators) . ')')
                ->where($dbo->qn('t.dueon') . ' BETWEEN ' . $dbo->q($utc_back_sql) . ' AND ' . $dbo->q($utc_forth_sql))
                ->group($dbo->qn('ta.id_operator'))
        );

        $operatorTasks = $dbo->loadAssocList();

        if (!$operatorTasks) {
            // nobody has got tasks assigned on the closest dates, so we return the first operator available
            return $this->getOperatorFromId($availableOperators[0]);
        }

        // build a list of operator IDs and number of assigned tasks
        $workersTaskCount = array_combine(array_column($operatorTasks, 'id_operator'), array_column($operatorTasks, 'tot_tasks'));

        $workersRanking = [];
        foreach ($availableOperators as $operator_id) {
            $workersRanking[] = [
                'id_operator' => $operator_id,
                'tot_tasks' => (int) ($workersTaskCount[$operator_id] ?? 0),
            ];
        }

        // sort the operators task counter in ascending order
        usort($workersRanking, function($a, $b) {
            return $a['tot_tasks'] <=> $b['tot_tasks'];
        });

        // ensure spreading tasks across all the operators by taking the first sorted, hence with less tasks assigned
        return $this->getOperatorFromId($workersRanking[0]['id_operator']);
    }

    /**
     * Common method for all task drivers that support tasks scheduling upon booking confirmation.
     * 
     * @param   VBOTaskBooking  $booking    The current task booking registry.
     * @param   array           $options    Associative list of task scheduling options.
     * 
     * @return  int                         Number of tasks created.
     */
    protected function createBookingConfirmationTasks(VBOTaskBooking $booking, array $options = [])
    {
        // start counter
        $created = 0;

        // access the task model
        $model = VBOTaskModelTask::getInstance();

        // get all records that belong to this project/area and booking ID
        $prevRecords = $model->getItemIds([
            'id_area'  => [
                'value' => $this->getAreaID(),
            ],
            'id_order' => [
                'value' => $booking->getID(),
            ],
        ]);

        if ($prevRecords) {
            // prevent duplicate tasks for the same project/area and booking ID from being created
            return $created;
        }

        // prepare associative task/area information for the task(s) description
        $info = [
            'booking_id' => $booking->getID(),
            'task_enum'  => $this->getID(),
            'area_id'    => $this->getAreaID(),
            'area_name'  => $this->getAreaName(),
        ];

        // iterate over the listings involved in the reservation
        foreach ($booking->getRooms() as $index => $listing) {
            // set current room index
            $booking->setCurrentRoomIndex($index);

            if (!$this->isListingEligible((int) $listing['idroom'])) {
                // listing not eligible in the current project/area settings
                continue;
            }

            // iterate over the task scheduling dates
            foreach ($this->getBookingSchedulingDates((array) ($options['scheduling'] ?? []), $booking) as $schedule) {
                // get the scheduler type (frequency)
                $scheduler = $schedule->getType();

                // iterate over the schedule dates, if any
                foreach ($schedule->getDates() as $scheduleCounter => $dt) {
                    // prepare booking task record
                    $task = [
                        'id_area'     => $this->getAreaID(),
                        'status_enum' => $this->getDefaultStatus(),
                        'scheduler'   => $scheduler,
                        'title'       => $schedule->getDescription($info, $scheduleCounter) . ' - ' . JText::translate('VBDASHBOOKINGID') . ' ' . $booking->getID(),
                        'id_order'    => $booking->getID(),
                        'id_room'     => $listing['idroom'],
                        'room_index'  => $listing['roomindex'] ?: null,
                        'dueon'       => $dt->format('Y-m-d H:i:s'),
                        'assignees'   => [],
                    ];

                    $warnAdmin = false;

                    if ($options['autoassignment'] ?? null) {
                        // fetch the first available operator
                        $assignee = $this->getAvailableOperator($dt, $task['id_area']);

                        if ($assignee) {
                            // push the available operator ID
                            $task['assignees'][] = $assignee['id'];
                        } else {
                            // unable to automatically assign the task to an operator, warn the admin after saving the task
                            $warnAdmin = true;
                        }
                    }

                    /**
                     * Trigger event to allow third-party plugins to manipulate the task payload.
                     */
                    VBOFactory::getPlatform()->getDispatcher()->trigger('onBeforeScheduleBookingConfirmationTask', [&$task, $booking, $options]);

                    // store the task record
                    $taskId = $model->save($task);

                    if (!$taskId) {
                        continue;
                    }

                    // register the new task within the collector by setting the ID obtained
                    $this->getCollector()->register(array_merge($task, ['id' => $taskId]));

                    // increase counter
                    $created++;

                    if ($warnAdmin) {
                        try {
                            // store a notification to warn the administrator that we have a scheduled task without assignee
                            VBOFactory::getNotificationCenter()->store([
                                [
                                    'sender' => 'operators',
                                    'type' => 'task.unassigned',
                                    'title' => JText::translate('VBO_TASK_NOTIF_SCHEDULING_UNASSIGNED_TITLE'),
                                    'summary' => JText::sprintf('VBO_TASK_NOTIF_SCHEDULING_UNASSIGNED_SUMMARY', $task['title']),
                                    'widget' => 'booking_details',
                                    'widget_options' => [
                                        'bid' => $task['id_order'],
                                        'task_id' => $taskId,
                                    ],
                                    // always skip signature check, so that we can allow a duplicate insert
                                    '_signature' => md5(time()),
                                ],
                            ]);
                        } catch (Exception $e) {
                            // silently catch the error
                            return false;
                        }
                    }
                }
            }
        }

        return $created;
    }
}
