<?php
/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */

namespace Icinga\Module\Monitoring\Timeline;

use DateTime;
use Exception;
use ArrayIterator;
use Icinga\Exception\IcingaException;
use IteratorAggregate;
use Icinga\Data\Filter\Filter;
use Icinga\Web\Hook;
use Icinga\Web\Session\SessionNamespace;
use Icinga\Module\Monitoring\DataView\DataView;
use Traversable;

/**
 * Represents a set of events in a specific range of time
 */
class TimeLine implements IteratorAggregate
{
    /**
     * The resultset returned by the dataview
     *
     * @var array
     */
    private $resultset;

    /**
     * The groups this timeline uses for display purposes
     *
     * @var array
     */
    private $displayGroups;

    /**
     * The session to use
     *
     * @var SessionNamespace
     */
    protected $session;

    /**
     * The base that is used to calculate each circle's diameter
     *
     * @var float
     */
    protected $calculationBase;

    /**
     * The dataview to fetch entries from
     *
     * @var DataView
     */
    protected $dataview;

    /**
     * The names by which to group entries
     *
     * @var array
     */
    protected $identifiers;

    /**
     * The range of time for which to display entries
     *
     * @var TimeRange
     */
    protected $displayRange;

    /**
     * The range of time for which to calculate forecasts
     *
     * @var TimeRange
     */
    protected $forecastRange;

    /**
     * The maximum diameter each circle can have
     *
     * @var float
     */
    protected $circleDiameter = 100.0;

    /**
     * The minimum diameter each circle can have
     *
     * @var float
     */
    protected $minCircleDiameter = 1.0;

    /**
     * The unit of a circle's diameter
     *
     * @var string
     */
    protected $diameterUnit = 'px';

    /**
     * Return a iterator for this timeline
     *
     * @return  ArrayIterator
     */
    public function getIterator(): Traversable
    {
        return new ArrayIterator($this->toArray());
    }

    /**
     * Create a new timeline
     *
     * The given dataview must provide the following columns:
     * - name   A string identifying an entry (Corresponds to the keys of "$identifiers")
     * - time   A unix timestamp that defines where to place an entry on the timeline
     *
     * @param   DataView    $dataview       The dataview to fetch entries from
     * @param   array       $identifiers    The names by which to group entries
     */
    public function __construct(DataView $dataview, array $identifiers)
    {
        $this->dataview = $dataview;
        $this->identifiers = $identifiers;
    }

    /**
     * Set the session to use
     *
     * @param   SessionNamespace    $session    The session to use
     */
    public function setSession(SessionNamespace $session)
    {
        $this->session = $session;
    }

    /**
     * Set the range of time for which to display elements
     *
     * @param   TimeRange   $range      The range of time for which to display elements
     */
    public function setDisplayRange(TimeRange $range)
    {
        $this->displayRange = $range;
    }

    /**
     * Set the range of time for which to calculate forecasts
     *
     * @param   TimeRange   $range      The range of time for which to calculate forecasts
     */
    public function setForecastRange(TimeRange $range)
    {
        $this->forecastRange = $range;
    }

    /**
     * Set the maximum diameter each circle can have
     *
     * @param   string      $width      The diameter to set, suffixed with its unit
     *
     * @throws  Exception               If the given diameter is invalid
     */
    public function setMaximumCircleWidth($width)
    {
        $matches = array();
        if (preg_match('#([\d|\.]+)([a-z]+|%)#', $width, $matches)) {
            $this->circleDiameter = floatval($matches[1]);
            $this->diameterUnit = $matches[2];
        } else {
            throw new IcingaException(
                'Width "%s" is not a valid width',
                $width
            );
        }
    }

    /**
     * Set the minimum diameter each circle can have
     *
     * @param   string      $width      The diameter to set, suffixed with its unit
     *
     * @throws  Exception               If the given diameter is invalid or its unit differs from the maximum
     */
    public function setMinimumCircleWidth($width)
    {
        $matches = array();
        if (preg_match('#([\d|\.]+)([a-z]+|%)#', $width, $matches)) {
            if ($matches[2] === $this->diameterUnit) {
                $this->minCircleDiameter = floatval($matches[1]);
            } else {
                throw new IcingaException(
                    'Unit needs to be in "%s"',
                    $this->diameterUnit
                );
            }
        } else {
            throw new IcingaException(
                'Width "%s" is not a valid width',
                $width
            );
        }
    }

    /**
     * Return all known group types (identifiers) with their respective labels and classess as array
     *
     * @return  array
     */
    public function getGroupInfo()
    {
        $groupInfo = array();
        foreach ($this->identifiers as $name => $attributes) {
            if (isset($attributes['groupBy'])) {
                $name = $attributes['groupBy'];
            }

            $groupInfo[$name]['class'] = $attributes['class'];
            $groupInfo[$name]['label'] = $attributes['label'];
        }

        return $groupInfo;
    }

    /**
     * Return the circle's diameter for the given event group
     *
     * @param   TimeEntry   $group          The group for which to return a circle width
     * @param   int         $precision      Amount of decimal places to preserve
     *
     * @return  string
     */
    public function calculateCircleWidth(TimeEntry $group, $precision = 0)
    {
        $base = $this->getCalculationBase(true);
        $factor = log($group->getValue() * $group->getWeight(), $base) / 100;
        $width = $this->circleDiameter * $factor;
        return sprintf(
            '%.' . $precision . 'F%s',
            $width > $this->minCircleDiameter ? $width : $this->minCircleDiameter,
            $this->diameterUnit
        );
    }

    /**
     * Return an extrapolated circle width for the given event group
     *
     * @param   TimeEntry   $group          The event group for which to return an extrapolated circle width
     * @param   int         $precision      Amount of decimal places to preserve
     *
     * @return  string
     */
    public function getExtrapolatedCircleWidth(TimeEntry $group, $precision = 0)
    {
        $eventCount = 0;
        foreach ($this->displayGroups as $groups) {
            if (array_key_exists($group->getName(), $groups)) {
                $eventCount += $groups[$group->getName()]->getValue();
            }
        }

        $extrapolatedCount = (int) $eventCount / count($this->displayGroups);
        if ($extrapolatedCount < $group->getValue()) {
            return $this->calculateCircleWidth($group, $precision);
        }

        return $this->calculateCircleWidth(
            TimeEntry::fromArray(
                array(
                    'value'     => $extrapolatedCount,
                    'weight'    => $group->getWeight()
                )
            ),
            $precision
        );
    }

    /**
     * Return the base that should be used to calculate circle widths
     *
     * @param   bool    $create     Whether to generate a new base if none is known yet
     *
     * @return  float|null
     */
    public function getCalculationBase($create)
    {
        if ($this->calculationBase === null) {
            $calculationBase = $this->session !== null ? $this->session->get('calculationBase') : null;

            if ($create) {
                $new = $this->generateCalculationBase();
                if ($new > $calculationBase) {
                    $this->calculationBase = $new;

                    if ($this->session !== null) {
                        $this->session->calculationBase = $new;
                    }
                } else {
                    $this->calculationBase = $calculationBase;
                }
            } else {
                return $calculationBase;
            }
        }

        return $this->calculationBase;
    }

    /**
     * Generate a new base to calculate circle widths with
     *
     * @return  float
     */
    protected function generateCalculationBase()
    {
        $allEntries = $this->groupEntries(
            array_merge(
                $this->fetchEntries(),
                $this->fetchForecasts()
            ),
            new TimeRange(
                $this->displayRange->getStart(),
                $this->forecastRange->getEnd(),
                $this->displayRange->getInterval()
            )
        );

        $highestValue = 0;
        foreach ($allEntries as $groups) {
            foreach ($groups as $group) {
                if ($group->getValue() * $group->getWeight() > $highestValue) {
                    $highestValue = $group->getValue() * $group->getWeight();
                }
            }
        }

        return pow($highestValue, 1 / 100); // 100 == 100%
    }

    /**
     * Fetch all entries and forecasts by using the dataview associated with this timeline
     *
     * @return  array       The dataview's result
     */
    private function fetchResults()
    {
        $hookResults = array();
        foreach (Hook::all('timeline') as $timelineProvider) {
            $hookResults = array_merge(
                $hookResults,
                $timelineProvider->fetchEntries($this->displayRange),
                $timelineProvider->fetchForecasts($this->forecastRange)
            );

            foreach ($timelineProvider->getIdentifiers() as $identifier => $attributes) {
                if (!array_key_exists($identifier, $this->identifiers)) {
                    $this->identifiers[$identifier] = $attributes;
                }
            }
        }

        $query = $this->dataview;
        $filter = Filter::matchAll(
            Filter::where('type', array_keys($this->identifiers)),
            Filter::expression('timestamp', '<=', $this->displayRange->getStart()->getTimestamp()),
            Filter::expression('timestamp', '>', $this->displayRange->getEnd()->getTimestamp())
        );
        $query->applyFilter($filter);
        return array_merge($query->getQuery()->fetchAll(), $hookResults);
    }

    /**
     * Fetch all entries
     *
     * @return  array       The entries to display on the timeline
     */
    protected function fetchEntries()
    {
        if ($this->resultset === null) {
            $this->resultset = $this->fetchResults();
        }

        $range = $this->displayRange;
        return array_filter(
            $this->resultset,
            function ($e) use ($range) {
                return $range->validateTime((int) $e->time);
            }
        );
    }

    /**
     * Fetch all forecasts
     *
     * @return  array       The entries to calculate forecasts with
     */
    protected function fetchForecasts()
    {
        if ($this->resultset === null) {
            $this->resultset = $this->fetchResults();
        }

        $range = $this->forecastRange;
        return array_filter(
            $this->resultset,
            function ($e) use ($range) {
                return $range->validateTime($e->time);
            }
        );
    }

    /**
     * Return the given entries grouped together
     *
     * @param   array       $entries        The entries to group
     * @param   TimeRange   $timeRange      The range of time to group by
     *
     * @return  array                 displayGroups      The grouped entries
     */
    protected function groupEntries(array $entries, TimeRange $timeRange)
    {
        $counts = array();
        foreach ($entries as $entry) {
            $entryTime = new DateTime();
            $entryTime->setTimestamp($entry->time);
            $timestamp = $timeRange->findTimeframe($entryTime, true);

            if ($timestamp !== null) {
                if (array_key_exists($entry->name, $counts)) {
                    if (array_key_exists($timestamp, $counts[$entry->name])) {
                        $counts[$entry->name][$timestamp] += 1;
                    } else {
                        $counts[$entry->name][$timestamp] = 1;
                    }
                } else {
                    $counts[$entry->name][$timestamp] = 1;
                }
            }
        }

        $groups = array();
        foreach ($counts as $name => $data) {
            foreach ($data as $timestamp => $count) {
                $dateTime = new DateTime();
                $dateTime->setTimestamp($timestamp);

                $groupName = $name;
                if (isset($this->identifiers[$name]['groupBy'])) {
                    $groupName = $this->identifiers[$name]['groupBy'];
                }

                if (isset($groups[$timestamp][$groupName])) {
                    $groups[$timestamp][$groupName]->setValue(
                        $groups[$timestamp][$groupName]->getValue() + $count
                    );
                } else {
                    $groups[$timestamp][$groupName] = TimeEntry::fromArray(
                        array(
                            'name'      => $groupName,
                            'value'     => $count,
                            'dateTime'  => $dateTime,
                            'class'     => $this->identifiers[$name]['class'],
                            'detailUrl' => $this->identifiers[$name]['detailUrl'],
                            'label'     => $this->identifiers[$name]['label']
                        )
                    );
                }
            }
        }

        return $groups;
    }

    /**
     * Return the contents of this timeline as array
     *
     * @return  array
     */
    protected function toArray()
    {
        $this->displayGroups = $this->groupEntries($this->fetchEntries(), $this->displayRange);

        $array = array();
        foreach ($this->displayRange as $timestamp => $timeframe) {
            $array[] = array(
                $timeframe,
                array_key_exists($timestamp, $this->displayGroups) ? $this->displayGroups[$timestamp] : array()
            );
        }

        return $array;
    }
}
