<?php
/**
 * This class layers support for plural specs such as changes, jobs,
 * users, etc. on top of the singular spec support already present
 * in P4\Spec\SingularAbstract.
 *
 * @copyright   2011 Perforce Software. All rights reserved.
 * @license     Please see LICENSE.txt in top-level folder of this distribution.
 * @version     <release>/<patch>
 */

namespace P4\Spec;

use P4;
use P4\Validate;
use P4\Spec\Exception\Exception;
use P4\Spec\Exception\NotFoundException;
use P4\Connection\ConnectionInterface;
use P4\Model\Fielded\Iterator as FieldedIterator;
use P4\OutputHandler\Limit;

abstract class PluralAbstract extends SingularAbstract
{
    const ID_FIELD              = null;
    const FETCH_MAXIMUM         = 'maximum';
    const FETCH_AFTER           = 'after';
    const TEMP_ID_PREFIX        = '~tmp';
    const TEMP_ID_DELIMITER     = ".";

    /**
     * Get the id of this spec entry.
     *
     * @return  null|string     the id of this entry.
     */
    public function getId()
    {
        if (array_key_exists(static::ID_FIELD, $this->values)) {
            return $this->values[static::ID_FIELD];
        } else {
            return null;
        }
    }

    /**
     * Set the id of this spec entry. Id must be in a valid format or null.
     *
     * @param   null|string     $id     the id of this entry - pass null to clear.
     * @return  PluralAbstract          provides a fluent interface
     * @throws  \InvalidArgumentException   if id does not pass validation.
     */
    public function setId($id)
    {
        if ($id !== null && !static::isValidId($id)) {
            throw new \InvalidArgumentException("Cannot set id. Id is invalid.");
        }

        // if populate was deferred, caller expects it
        // to have been populated already.
        $this->populate();

        $this->values[static::ID_FIELD] = $id;

        return $this;
    }

    /**
     * Determine if a spec record with the given id exists.
     * Must be implemented by sub-classes because this test
     * is impractical to generalize.
     *
     * @param   string                  $id             the id to check for.
     * @param   ConnectionInterface     $connection     optional - a specific connection to use.
     * @return  bool    true if the given id matches an existing record.
     */
    abstract public static function exists($id, ConnectionInterface $connection = null);

    /**
     * Get the requested spec entry from Perforce.
     *
     * @param   string                  $id         the id of the entry to fetch.
     * @param   ConnectionInterface     $connection optional - a specific connection to use.
     * @return  PluralAbstract          instace of the requested entry.
     * @throws  \InvalidArgumentException   if no id is given.
     */
    public static function fetch($id, ConnectionInterface $connection = null)
    {
        // ensure a valid id is provided.
        if (!static::isValidId($id)) {
            throw new \InvalidArgumentException("Must supply a valid id to fetch.");
        }

        // if no connection given, use default.
        $connection = $connection ?: static::getDefaultConnection();

        // ensure id exists.
        if (!static::exists($id, $connection)) {
            throw new NotFoundException(
                "Cannot fetch " . static::SPEC_TYPE . " $id. Record does not exist."
            );
        }

        // construct spec instance.
        $spec = new static($connection);
        $spec->setId($id)
             ->deferPopulate();

        return $spec;
    }

    /**
     * Get all entries of this type from Perforce.
     *
     * @param   array   $options    optional - array of options to augment fetch behavior.
     *                              supported options are:
     *
     *                                  FETCH_MAXIMUM - set to integer value to limit to the
     *                                                  first 'max' number of entries.
     *                                    FETCH_AFTER - set to an id _after_ which to start collecting entries
     *                                                  note: entries seen before 'after' count towards max.
     *
     * @param   ConnectionInterface     $connection optional - a specific connection to use.
     * @return  FieldedIterator         all records of this type.
     * @todo    make limit work for depot (in a P4\Spec\Depot sub-class)
     */
    public static function fetchAll($options = array(), ConnectionInterface $connection = null)
    {
        // if no connection given, use default.
        $connection = $connection ?: static::getDefaultConnection();

        // get command to use
        $command = static::getFetchAllCommand();

        // get command flags for given fetch options.
        $flags = static::getFetchAllFlags($options);

        // fetch all specs.
        // configure a handler to enforce 'after' (skip entries up to and including 'after')
        $after = isset($options[static::FETCH_AFTER]) ? $options[static::FETCH_AFTER] : null;
        if (strlen($after)) {
            $idField = static::ID_FIELD;
            $isAfter = false;
            $handler = new Limit;
            $handler->setFilterCallback(
                function ($data) use ($after, $idField, &$isAfter) {
                    if ($after && !$isAfter) {
                        // id field could be upper or lower case in list output.
                        $id      = isset($data[lcfirst($idField)]) ? $data[lcfirst($idField)] : null;
                        $id      = !$id && isset($data[$idField])  ? $data[$idField]          : $id;
                        $isAfter = ($after == $id);
                        return false;
                    }
                    return true;
                }
            );
            $result = $connection->runHandler($handler, $command, $flags);
        } else {
            $result = $connection->run($command, $flags);
        }

        // expand any sequences present
        $result->expandSequences();

        // convert result data to spec objects.
        $specs = new FieldedIterator;
        foreach ($result->getData() as $data) {
            $spec = static::fromSpecListEntry($data, $flags, $connection);
            $specs[$spec->getId()] = $spec;
        }

        return $specs;
    }

    /**
     * Create a temporary entry.
     *
     * The passed values can, optionally, specify the id of the temp entry.
     * If no id is passed in values, one will be generated following the
     * conventions described in makeTempId().
     *
     * Temp entries are deleted when the connection is closed.
     *
     * @param   array|null              $values             optional - values to set on temp entry,
     *                                                      can include ID
     * @param   function|null           $cleanupCallback    optional - callback to use for cleanup.
     *                                                      signature is:
     *                                                      function($entry, $defaultCallback)
     * @param   ConnectionInterface     $connection optional - a specific connection to use.
     * @return  PluralAbstract          instace of the temp entry.
     */
    public static function makeTemp(
        array $values = null,
        $cleanupCallback = null,
        ConnectionInterface $connection = null
    ) {
        // normalize to array
        $values = $values ?: array();

        // generate an id if no value for our id field is present
        if (!isset($values[static::ID_FIELD])) {
            $values[static::ID_FIELD] = static::makeTempId();
        }

        // create the temporary instance.
        $temp = new static($connection);
        $temp->set($values)->save();

        // remove the temp entry when the connection terminates.
        $defaultCallback = static::getTempCleanupCallback();
        $temp->getConnection()->addDisconnectCallback(
            function ($connection) use ($temp, $cleanupCallback, $defaultCallback) {
                try {
                    // use the passed callback if valid, fallback to the default callback
                    if (is_callable($cleanupCallback)) {
                        $cleanupCallback($temp, $defaultCallback);
                    } else {
                        $defaultCallback($temp);
                    }
                } catch (\Exception $e) {
                    P4\Log::logException("Failed to delete temporary entry.", $e);
                }
            }
        );

        return $temp;
    }

    /**
     * Generate a temporary id by combining the id prefix
     * with the current time, pid and a random uniqid():
     *
     *  ~tmp.<unixtime>.<pid>.<uniqid>
     *
     * The leading tilde ('~') places the temporary id at the end of
     * the list.  The unixtime ensures that the oldest ids will
     * appear first (among temp ids), while the pid and uniqid provide
     * reasonable assurance that no two ids will collide.
     *
     * @return  string  an id suitable for use with temporary specs.
     */
    public static function makeTempId()
    {
        return implode(
            static::TEMP_ID_DELIMITER,
            array(
                static::TEMP_ID_PREFIX,
                time(),
                getmypid(),
                uniqid("", true)
            )
        );
    }

    /**
     * Delete this spec entry.
     *
     * @param   array   $params     optional - additional flags to pass to delete
     *                              (e.g. some specs support -f to force delete).
     * @return  PluralAbstract      provides a fluent interface
     * @throws  Exception           if no id has been set.
     */
    public function delete(array $params = null)
    {
        $id = $this->getId();
        if ($id === null) {
            throw new Exception("Cannot delete. No id has been set.");
        }

        // ensure id exists.
        $connection = $this->getConnection();
        if (!static::exists($id, $connection)) {
            throw new NotFoundException(
                "Cannot delete " . static::SPEC_TYPE . " $id. Record does not exist."
            );
        }

        $params = array_merge((array) $params, array("-d", $id));
        $result = $connection->run(static::SPEC_TYPE, $params);

        // should re-populate.
        $this->deferPopulate(true);

        return $this;
    }

    /**
     * Get a field's raw value.
     * Extend parent to use getId() for id field.
     *
     * @param   string      $field  the name of the field to get the value of.
     * @return  mixed       the value of the field.
     * @throws  Exception   if the field does not exist.
     */
    public function getRawValue($field)
    {
        if ($field === static::ID_FIELD) {
            return $this->getId();
        }

        // call-through.
        return parent::getRawValue($field);
    }

    /**
     * Set a field's raw value.
     * Extend parent to use setId() for id field.
     *
     * @param   string  $field      the name of the field to set the value of.
     * @param   mixed   $value      the value to set in the field.
     * @return  SingularAbstract    provides a fluent interface
     * @throws  Exception           if the field does not exist.
     */
    public function setRawValue($field, $value)
    {
        if ($field === static::ID_FIELD) {
            return $this->setId($value);
        }

        // call-through.
        return parent::setRawValue($field, $value);
    }

    /**
     * Extended to preserve id when values are cleared.
     * Schedule populate to run when data is requested (lazy-load).
     *
     * @param   bool    $reset  optionally clear instance values.
     */
    public function deferPopulate($reset = false)
    {
        if ($reset) {
            $id = $this->getId();
        }

        parent::deferPopulate($reset);

        if ($reset) {
            $this->setId($id);
        }
    }

    /**
     * Provide a callback function to be used during cleanup of
     * temp entries. The callback should expect a single parameter,
     * the entry being removed.
     *
     * @return callable     A callback function with the signature function($entry)
     */
    protected static function getTempCleanupCallback()
    {
        return function ($entry) {
            // remove the temp entry we are responsible for
            $entry->delete();
        };
    }

    /**
     * Check if the given id is in a valid format for this spec type.
     *
     * @param   string      $id     the id to check
     * @return  bool        true if id is valid, false otherwise
     */
    protected static function isValidId($id)
    {
        $validator = new Validate\SpecName;
        return $validator->isValid($id);
    }

    /**
     * Extend parent populate to exit early if id is null.
     */
    protected function populate()
    {
        // early exit if populate not needed.
        if (!$this->needsPopulate) {
            return;
        }

        // don't attempt populate if id null.
        if ($this->getId() === null) {
            return;
        }

        parent::populate();
    }

    /**
     * Get raw spec data direct from Perforce. No caching involved.
     * Extends parent to supply an id to the spec -o command.
     *
     * @return  array   $data   the raw spec output from Perforce.
     */
    protected function getSpecData()
    {
        $result = $this->getConnection()->run(
            static::SPEC_TYPE,
            array("-o", $this->getId())
        );
        return $result->expandSequences()->getData(-1);
    }

    /**
     * Given a spec entry from spec list output (e.g. 'p4 jobs'), produce
     * an instance of this spec with field values set where possible.
     *
     * @param   array                   $listEntry      a single spec entry from spec list output.
     * @param   array                   $flags          the flags that were used for this 'fetchAll' run.
     * @param   ConnectionInterface     $connection     a specific connection to use.
     * @return  PluralAbstract          a (partially) populated instance of this spec class.
     */
    protected static function fromSpecListEntry($listEntry, $flags, ConnectionInterface $connection)
    {
        // most spec list entries have leading lower-case field
        // names which is inconsistent with defined field names.
        // make all field names lead with an upper-case letter.
        $keys      = array_map('ucfirst', array_keys($listEntry));
        $listEntry = array_combine($keys, $listEntry);

        // convert common timestamps to dates
        if (isset($listEntry['Time'])) {
            $listEntry['Date']   = static::timeToDate($listEntry['Time'],   $connection);
            unset($listEntry['Time']);
        }
        if (isset($listEntry['Update'])) {
            $listEntry['Update'] = static::timeToDate($listEntry['Update'], $connection);
            unset($listEntry['Update']);
        }
        if (isset($listEntry['Access'])) {
            $listEntry['Access'] = static::timeToDate($listEntry['Access'], $connection);
            unset($listEntry['Access']);
        }

        // instantiate new spec object and set raw field values.
        $spec = new static($connection);
        $spec->setRawValues($listEntry)
             ->deferPopulate();

        return $spec;
    }

    /**
     * Convert the given unix timestamp into the server's typical date
     * format accounting for the server's current timezone.
     *
     * @param   int|string          $time       the timestamp to convert
     * @param   ConnectionInterface $connection the connection to use
     * @return  string              date in the typical server format
     */
    protected static function timeToDate($time, ConnectionInterface $connection)
    {
        $date = new \DateTime('@' . $time);

        // try and use the p4 info timezone, if that fails fall back to our local timezone
        try {
            $date->setTimeZone($connection->getTimeZone());
        } catch (\Exception $e) {
            // we tried and failed; just let it use php's default time zone
            // note when creating a DateTime from a unix timestamp the timezone will
            // be UTC, we need to explicitly set it to the default time zone.
            $date->setTimeZone(new \DateTimeZone(date_default_timezone_get()));
        }

        return $date->format('Y/m/d H:i:s');
    }

    /**
     * Inverse function to timeToDate(), it converts the given date in server's typical
     * format into a unix timestamp accounting for the server's current timezone.
     *
     * @param  string               $date           date in typical server's format (Y/m/d H:i:s) to convert
     * @param  ConnectionInterface  $connection     the connection to use
     * @return int|false            date in unix timestamp or false if unable to convert
     */
    protected static function dateToTime($date, ConnectionInterface $connection)
    {
        // try and use the p4 info timezone, if that fails fall back to our local timezone
        $dateTimeZone = null;
        try {
            $dateTimeZone = $connection->getTimeZone();
        } catch (\Exception $e) {
            // we tried and failed; just let it use php's default time zone
        }

        $dateTime = $dateTimeZone
            ? \DateTime::createFromFormat('Y/m/d H:i:s', $date, $dateTimeZone)
            : \DateTime::createFromFormat('Y/m/d H:i:s', $date);

        return $dateTime ? (int) $dateTime->format('U') : false;
    }

    /**
     * Produce set of flags for the spec list command, given fetch all options array.
     *
     * @param   array   $options    array of options to augment fetch behavior.
     *                              see fetchAll for documented options.
     * @return  array   set of flags suitable for passing to spec list command.
     */
    protected static function getFetchAllFlags($options)
    {
        $flags = array();

        if (isset($options[self::FETCH_MAXIMUM])) {
            $flags[] = "-m";
            $flags[] = (int) $options[self::FETCH_MAXIMUM];
        }

        return $flags;
    }

    /**
     * Get the fetch all command, generally a plural version of the spec type.
     *
     * @return  string  Perforce command to use for fetchAll
     */
    protected static function getFetchAllCommand()
    {
        // derive list command from spec type by adding 's'
        // this works for most of the known plural specs
        return static::SPEC_TYPE . "s";
    }
}