<?php
/**
 * This class provides access to the definition for a Perforce spec type.
 * This includes: field names, field types, field options, preset values,
 * comments, etc.
 *
 * Fields with the dataType 'text' have several issues with whitespace:
 * - Any trailing whitespace will be stripped
 * - A trailing new-line will be added if not present
 * - Any leading/intermediate lines will have trailing whitespace removed
 * - Any line with non-whitespace content will preserve all trailing whitespace
 *
 * @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\Spec\Exception\Exception;
use P4\Connection\ConnectionInterface;
use P4\Model\Connected\ConnectedAbstract;

class Definition extends ConnectedAbstract
{
    protected $type         = null;
    protected $data         = array();
    protected $isPopulated  = false;

    protected static $cache = array();

    /**
     * Get the type of spec that this defines.
     *
     * @return  string  the type of spec this defines.
     */
    public function getType()
    {
        return $this->type;
    }

    /**
     * Set the type of spec this defines.
     *
     * @param   string  $type   the type of spec to define.
     * @return  Definition      provides a fluent interface
     */
    public function setType($type)
    {
        if (!is_string($type)) {
            throw new \InvalidArgumentException("Type must be a string.");
        }

        $this->type = $type;

        return $this;
    }

    /**
     * Get the definition for a given spec type from Perforce.
     *
     * @param   string                  $type       the type of the spec to get the definition for.
     * @param   ConnectionInterface     $connection optional - a specific connection to use.
     * @returns Definition              instance containing details about this spec type.
     */
    public static function fetch($type, ConnectionInterface $connection = null)
    {
        // if no connection given, use default.
        $connection = $connection ?: static::getDefaultConnection();

        // cache is per-server - ensure cache initialized for this server.
        $port = $connection->getPort();
        if (!array_key_exists($port, static::$cache)) {
            static::$cache[$port] = array();
        }

        // create and populate spec definition if not cached.
        if (!array_key_exists($type, static::$cache[$port]) ||
            !static::$cache[$port][$type] instanceof Definition) {

            // construct spec def instance.
            $definition = new static($connection);
            $definition->setType($type);

            // call get fields to force a populate and ensure type is valid.
            $definition->getFields();

            static::$cache[$port][$type] = $definition;

        }

        // return cloned copy so that changes don't pollute the cache.
        return clone static::$cache[$port][$type];
    }

    /**
     * Get multi-dimensional array of detailed field information for this spec type.
     *
     * @return  array   detailed field information for this spec type.
     */
    public function getFields()
    {
        // only populate if fields array is unset.
        if (!array_key_exists('fields', $this->data)) {
            $this->populate();
        }

        return $this->data['fields'];
    }

    /**
     * Get array of detailed information about a particular field.
     *
     * @param   string  $field  the field to get information about.
     * @return  array   detailed field information for this spec type.
     * @throws  Exception   if the field does not exist.
     */
    public function getField($field)
    {
        // verify field exists.
        if (!$this->hasField($field)) {
            throw new Exception("Can't get field '$field'. Field does not exist.");
        }

        $fields = $this->getFields();

        return $fields[$field];
    }

    /**
     * Check if this spec definition has a particular field.
     *
     * @param   string      $field  the field to check for the existence of.
     * @return  boolean     true if the spec has the named field, false otherwise.
     */
    public function hasField($field)
    {
        $fields = array_keys($this->getFields());
        return in_array((string)$field, $fields);
    }

    /**
     * Determine if the given field is required.
     *
     * @param   string  $field  the field to check if required.
     * @return  bool    true if the field is required, false otherwise.
     */
    public function isRequiredField($field)
    {
        $field = $this->getField($field);
        if ($field['fieldType'] === 'required' || $field['fieldType'] === 'key') {
            return true;
        } else {
            return false;
        }
    }

    /**
     * Determine if the given field is read-only.
     *
     * @param   string  $field  the field to check if required.
     * @return  bool    true if the field is read-only, false otherwise.
     */
    public function isReadOnlyField($field)
    {
        $field = $this->getField($field);

        if ($field['fieldType'] == 'once') {
            return true;
        } else {
            return false;
        }
    }

    /**
     * Set the fields that define this specification.
     *
     * @param   array   $fields     multi-dimensional array of detailed field information.
     * @return  Definition          provides a fluent interface
     * @todo    better validate fields array format.
     */
    public function setFields($fields)
    {
        if (!is_array($fields)) {
            throw new \InvalidArgumentException("Fields must be an array.");
        }

        $this->data['fields'] = $fields;

        return $this;
    }

    /**
     * Get comments for this spec type.
     *
     * @return  string  comments describing this spec type.
     */
    public function getComments()
    {
        // only populate if comments are unset.
        if (!array_key_exists('comments', $this->data)) {
            $this->populate();
        }

        return $this->data['comments'];
    }

    /**
     * Set the comments for this specification.
     *
     * Comments are stored as 'text' fields which causes whitespace issues.
     * See Definition for details.
     *
     * @param   string  $comments   comments describing this spec type.
     * @return  Definition          provides a fluent interface
     * @todo    validate comments format more thoroughly.
     */
    public function setComments($comments)
    {
        if (!is_string($comments)) {
            throw new \InvalidArgumentException("Comments must be a string.");
        }

        $this->data['comments'] = $comments;

        return $this;
    }

    /**
     * Save this spec definition to Perforce.
     *
     * @return  Definition  provides a fluent interface
     */
    public function save()
    {
        // save spec definition in Perforce.
        $connection = static::getDefaultConnection();
        $result     = $connection->run(
            'spec',
            array("-i", $this->getType()),
            $this->toSpecArray($this->data)
        );

        $this->clearCache();

        return $this;
    }

    /**
     * Given a field name this function will return the associated field code.
     *
     * @param   string  $name   String representing the field's name.
     * @return  int     The field code associated with the passed name.
     */
    public function fieldNameToCode($name)
    {
        $field = $this->getField($name);

        return (int) $field['code'];
    }

    /**
     * Given a field code this function will return the associated field name.
     *
     * @param   int|string  $code   Int or string representing code
     * @return  string  The field name associated with the passed code
     * @throws  \InvalidArgumentException   If passed an invalid or non-existent field code
     */
    public function fieldCodeToName($code)
    {
        // if we are passed a string, and casting through int doesn't change it,
        // it is purely numeric, cast to an int.
        if (is_string($code) &&
            $code === (string)(int)$code) {
            $code = (int)$code;
        }

        // if we made it this far, fail unless we have an int
        if (!is_int($code)) {
            throw new \InvalidArgumentException('Field must be a purely numeric string or int.');
        }

        $fields = $this->getFields();

        foreach ($fields as $name => $field) {
            if ($field['code'] == $code) {
                return $name;
            }
        }

        throw new \InvalidArgumentException('Specified field code does not exist.');
    }

    /**
     * Clear the shared 'fetch' class and also clear this instances fields/comments.
     *
     * @todo    If clearCache is called and this instance is subsequently populated, the shared
     *          fetch cache won't be updated. If fetch is later called an additional populate will
     *          be executed. This could be optimized but is a fairly narrow case.
     */
    public function clearCache()
    {
        $type = $this->getType();

        // Remove the static cache; helps with future 'fetch' calls
        unset(static::$cache[$this->getConnection()->getPort()][$type]);

        // Remove our instances values
        $this->data = array();

        // Ensure our instance will re-populate
        $this->isPopulated = false;
    }

    /**
     * Expand preset values that are expected to be interpreted client side.
     * For example, '$user' should be set to the name of the current Perforce
     * user. '$now' should be set to the current time. See 'p4 help undoc'
     * for additional details.
     *
     * @param   string                  $default    the default value to be expanded.
     * @param   ConnectionInterface     $connection optional - a specific connection to use
     *                                              when expanding default values.
     * @return  string|null             the expanded default value.
     * @throws  \InvalidArgumentException   if default value is not a string
     * @todo    job specs have additional 'expansions'; there may be more outside jobs too.
     */
    public static function expandDefault($default, ConnectionInterface $connection = null)
    {
        if (!is_string($default)) {
            throw new \InvalidArgumentException('Default value must be a string.');
        }

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

        switch ($default) {
            case '$user':
                return $connection->getUser();
                break;
            case '$blank':
                return null;
                break;
            default:
                return $default;
                break;
        }
    }

    /**
     * Get the spec definition from Perforce if not already populated.
     */
    protected function populate()
    {
        // only populate once.
        if ($this->isPopulated) {
            return;
        }

        // query perforce to get spec definition.
        $connection = $this->getConnection();
        $result     = $connection->run(
            'spec',
            array("-o", $this->getType())
        );

        // ensure all sequences are expanded into arrays
        $result->expandSequences();

        // ensure spec output is an array.
        $spec = $result->getData(-1);

        if (!is_array($spec) || empty($spec)) {
            throw new Exception(
                "Failed to populate spec definition. Perforce result invalid."
            );
        }

        // convert spec to internal format.
        $data = $this->fromSpecArray($spec);

        // don't clobber fields/comments if already set.
        if (!array_key_exists('fields', $this->data)) {
            $this->data['fields'] = $data['fields'];
        }
        if (!array_key_exists('comments', $this->data)) {
            $this->data['comments'] = $data['comments'];
        }

        // flag as populated.
        $this->isPopulated = true;
    }

    /**
     * Convert 'p4 spec -o' output into the format of the internal data structure
     * used by this class.
     *
     * The data structure groups field metadata by field name and breaks multi-word
     * fields into their component parts. For example:
     *
     *  array (
     *    'fields' => array (
     *      'Field1' => array (
     *        'code'          => '310',
     *        'dataType'      => 'select',
     *        'displayLength' => '12',
     *        'fieldType'     => 'optional',
     *        'order'         => '0',
     *        'position'      => 'L',
     *        'options'       => array (
     *          0 => 'local',
     *          1 => 'unix',
     *        )
     *      ),
     *      'Field2' => array (
     *        'code'          => '311',
     *        'dataType'      => 'wlist',
     *        'displayLength' => '64',
     *        'fieldType'     => 'optional',
     *        'wordCount'     => '2'
     *      )
     *    ),
     *    'comments' => '# Comments for this spec.'
     *  )
     *
     * @param   array   $spec   the raw output from 'p4 spec -o'
     * @return  array   the converted spec definition data structure.
     */
    protected function fromSpecArray($spec)
    {
        $data = array(
            'fields'    => array(),
            'comments'  => null
        );

        // break apart fields word-list in spec array.
        foreach ($spec['Fields'] as $fieldInfo) {
            list($code, $name, $dataType, $length, $fieldType) = explode(" ", $fieldInfo);

            $data['fields'][$name] = array(
                'code'          => $code,
                'dataType'      => $dataType,
                'displayLength' => $length,
                'fieldType'     => $fieldType
            );

            // hack because Perforce doesn't provide word count
            // for single column wlist's.
            if ($dataType == 'wlist') {
                $data['fields'][$name]['wordCount'] = 1;
            }
        }

        // add word count information for multi-word fields.
        if (isset($spec['Words']) && is_array($spec['Words'])) {
            foreach ($spec['Words'] as $wordInfo) {
                list($fieldName, $wordCount) = explode(" ", $wordInfo);

                $data['fields'][$fieldName]['wordCount'] = $wordCount;
            }
        }

        // add format information.
        if (isset($spec['Formats']) && is_array($spec['Formats'])) {
            foreach ($spec['Formats'] as $formatInfo) {
                list($fieldName, $order, $position) = explode(" ", $formatInfo);

                $data['fields'][$fieldName]['order']    = $order;
                $data['fields'][$fieldName]['position'] = $position;
            }
        }

        // add options for select fields.
        if (isset($spec['Values']) && is_array($spec['Values'])) {
            foreach ($spec['Values'] as $selectInfo) {
                list($fieldName, $options) = explode(" ", $selectInfo);

                $data['fields'][$fieldName]['options'] = explode('/', $options);
            }
        }

        // add default field values.
        if (isset($spec['Presets']) && is_array($spec['Presets'])) {
            foreach ($spec['Presets'] as $defaultInfo) {
                list($fieldName, $default) = explode(" ", $defaultInfo);

                $data['fields'][$fieldName]['default'] = $default;
            }
        }

        // add spec comments to data structure.
        if (isset($spec['Comments']) && is_string($spec['Comments'])) {
            $data['comments'] = $spec['Comments'];
        }

        return $data;
    }

    /**
     * Convert the internal data structure of this class into a 'p4 spec -i'
     * compatible array of comments and field details. See fromSpecArray for
     * data structure format.
     *
     * @param   array   $data   an internal spec definition data structure.
     * @return  array   spec definition array suitable for 'p4 spec -i'.
     */
    protected function toSpecArray($data)
    {
        if (!is_array($data) ||
            !array_key_exists('fields', $data) ||
            !array_key_exists('comments', $data)) {
            throw InvalidArgumentException("Data must be array with fields and comments.");
        }

        $spec = array(
            'Fields'    => array(),
            'Words'     => array(),
            'Formats'   => array(),
            'Values'    => array(),
            'Presets'   => array(),
            'Comments'  => null
        );

        // convert fields back into spec array format.
        foreach ($data['fields'] as $name => $field) {

            $spec['Fields'][] = implode(
                " ",
                array(
                    $field['code'],
                    $name,
                    $field['dataType'],
                    $field['displayLength'],
                    $field['fieldType']
                )
            );

            // only include word count if > 1.
            if (isset($field['wordCount']) && $field['wordCount'] > 1) {
                $spec['Words'][] = implode(" ", array($name, $field['wordCount']));
            }

            if (isset($field['order'], $field['position'])) {
                $spec['Formats'][] = implode(" ", array($name, $field['order'], $field['position']));
            }

            if (isset($field['options']) && is_array($field['options'])) {
                $spec['Values'][] = implode(" ", array($name, implode("/", $field['options'])));
            }

            if (isset($field['default'])) {
                $spec['Presets'][] = implode(" ", array($name, $field['default']));
            }
        }

        // remove empty elements.
        foreach ($spec as $key => $value) {
            if (empty($value)) {
                unset($spec[$key]);
            }
        }

        // add comments to spec array.
        // Perforce will keep existing value if no comments entry is present.
        // We ensure it is at least blank to force an update.
        if (isset($data['comments']) && is_string($data['comments'])) {
            $spec['Comments'] = $data['comments'];
        }

        return $spec;
    }
}