<?php

/**
 * An abstract custom-field class
 * @package Am_Record
 */
abstract class Am_CustomField
{

    public $name;
    public $title;
    public $type;
    public $description;
    public $validateFunc;
    protected $qfType;
    protected $isArray = false;

    function __construct($name, $title, $description=null, $validateFunc=null, $moreParams=array())
    {
        foreach ((array) $moreParams as $k => $v)
            $this->$k = $v;
        $this->type = $this->getType();
        $this->name = $name;
        $this->title = $title;
        $this->description = $description;
        $this->validateFunc = $validateFunc;
    }

    function getType()
    {
        return strtolower(str_replace('Am_CustomField', '', get_class($this)));
    }

    function getName()
    {
        return $this->name;
    }

    /**
     * Set description
     * @return Am_CustomField
     */
    function setDescription($description)
    {
        $this->description = $description;
        return $this;
    }

    /**
     * Set validation function
     * @return Am_CustomField
     */
    function setValidateFunc($callback)
    {
        $this->validateFunc = $callback;
        return $this;
    }

    /**
     * Set any parameter
     * @param string $name
     * @param mixed $value
     * @return Am_CustomField
     */
    function setParam($name, $value)
    {
        $this->$name = $value;
        return $this;
    }

    function toArray()
    {
        return (array) $this;
    }

    /**
     * Create and return a field
     * @param <type> $name
     * @param <type> $title
     * @param <type> $type
     * @param <type> $description
     * @param <type> $validateFunc
     * @param <type> $moreParams
     * @return Am_CustomField custom field of given $type
     */
    static function create($name, $title, $type='text', $description=null, $validateFunc=null, $moreParams=array())
    {
        if ($type == '')
            throw new Am_Exception_InternalError("Empty field [type] passed to Am_CustomField::create");
        $className = 'Am_CustomField' . ucfirst($type);
        if (!class_exists($className, false))
            throw new Am_Exception_InternalError("Wrong custom type passed [$type] to Am_CustomField::create");
        $o = new $className($name, $title, $description, $validateFunc, $moreParams);
        return $o;
    }

    /**
     * Add the current field to QF2 container
     * @param HTML_QuickForm2_Container
     * @return HTML_QuickForm2_Node
     */
    public function addToQF2(HTML_QuickForm2_Container $container, $params = array())
    {
        $el = $container->addElement($this->qfType, $this->name, $params)
            ->setLabel(!empty($this->description) ? ___($this->title)."\n".___($this->description) : ___($this->title));
        if (!empty($this->size))
            $el->setAttribute('size', $this->size);
        if (!empty($this->default))
            $el->setValue($this->default);
        if(!(defined('AM_ADMIN') && AM_ADMIN)) $this->addValidateFunction($el);
        return $el;
    }
    
    function addValidateFunction(HTML_QuickForm2_Node $el)
    {
        foreach ((array)$this->validateFunc as $f)
        {
            switch ($f)
            {
                case 'required' :
                    $el->addRule('required');
                    break;
                case 'integer':
                    $el->addRule('regex', ___("Integer value required"), '/^\d+$/');
                    break;
                case 'number':
                    $el->addRule('regex', ___("Numeric value required"), '/^\d+(|\.\d+)$/');
                    break;
                case 'email':
                    $el->addRule('callback', ___("Please enter a valid e-mail address"), array('Am_Validate', 'empty_or_email'));
                    break;
                default:
                    if (is_callable($f)) $el->addRule('callback2', '--error--', $f);
                    break;
            };
        }
    }

    function valueFromTable($val)
    {
        if (!$this->isArray) return $val;
        return is_array($val) ? $val : (array)unserialize($val);
    }

    function valueToTable($val)
    {
        return $this->isArray ? serialize($val) : $val;
    }

}

;

class Am_CustomFieldHidden extends Am_CustomField
{

    public function addToQF2(HTML_QuickForm2_Container $container, $params = array())
    {
        return null;
    }

}

class Am_CustomFieldUnknown extends Am_CustomFieldHidden
{
    
}

class Am_CustomFieldText extends Am_CustomField
{

    public $qfType = 'text';

}

class Am_CustomFieldReadonly extends Am_CustomFieldText
{

    public function addToQF2(HTML_QuickForm2_Container $container, $params = array())
    {
        $el = parent::addToQF2($container);
        $el->toggleFrozen(true); //@todo fix
        return $el;
    }

}

class Am_CustomFieldSelect extends Am_CustomField
{
    public $qfType = 'select';

    public function addToQF2(HTML_QuickForm2_Container $container, $params = array())
    {
        $el = parent::addToQF2($container);
        $o = array();
        foreach ($this->options as $k => $v)
            $o[htmlentities($k, ENT_QUOTES, 'UTF-8')] = htmlentities(___($v), ENT_QUOTES, 'UTF-8');
        $el->loadOptions($o);
        $el->setAttribute('size', !empty($this->size) ? $this->size : 1);
        return $el;
    }

}

class Am_CustomFieldRadio extends Am_CustomField
{
    public $qfType = 'advradio';

    public function addToQF2(HTML_QuickForm2_Container $container, $params = array())
    {
        $el = parent::addToQF2($container);
        $el->loadOptions(array_map('___', $this->options));
        return $el;
    }
}

class Am_CustomFieldMulti_Select extends Am_CustomFieldSelect
{

    public $qfType = 'multi_select';
    protected $isArray = true;

    public function addToQF2(HTML_QuickForm2_Container $container, $params = array())
    {
        $el = parent::addToQF2($container);
        $el->setAttribute('size', 5);
        $el->setAttribute('multiple', 'multiple');
        return $el;
    }

}

class Am_CustomFieldPeriod extends Am_CustomFieldText
{
    
}

class Am_CustomFieldMoney extends Am_CustomFieldText
{
    
}

class Am_CustomFieldCheckbox extends Am_CustomField
{

    var $qfType = 'advcheckbox';
    protected $isArray = true;

    public function addToQF2(HTML_QuickForm2_Container $container, $params = array())
    {
        if (empty($this->options))
            $el = parent::addToQF2($container, array('value' => 1));
        else
        {
            $this->qfType = 'group';
            $el = parent::addToQF2($container);
            $el->setSeparator("<br />");
            foreach ($this->options as $k => $v){
                $chkbox = $el->addAdvCheckbox(null, array('value' => $k))->setContent(___($v));
                if(in_array($k, (array)$this->default)) $chkbox->setAttribute('checked', 'checked');
            }
            $el->addHidden(null, array('value'=>''));
            $el->addFilter('array_filter');
        }
        return $el;
    }

}

class Am_CustomFieldTextarea extends Am_CustomFieldText
{

    var $qfType = 'textarea';

    public function addToQF2(HTML_QuickForm2_Container $container, $params = array())
    {
        $el = parent::addToQF2($container);
        $el->setAttribute('rows', !empty($this->rows) ? $this->rows : 2);
        $el->setAttribute('cols', !empty($this->cols) ? $this->cols : 40);
        return $el;
    }

}

class Am_CustomFieldHeader extends Am_CustomFieldText
{

    public function addToQF2(HTML_QuickForm2_Container $container, $params = array())
    {
        throw new Am_Exception_InternalError("Not Implemented");
    }

}

class Am_CustomFieldDate extends Am_CustomFieldText{
    public $qfType = 'Date';
}

/**
 * Custom fields manager, can be added for example to @see Am_Table
 * It just contains fields definitions, it does not affect record saving itself
 * @package Am_Record
 */
class Am_CustomFieldsManager
{

    protected $ignoreFields = array();
    protected $fields = array();
    protected $callbacks = array();

    /**
     * If called with one argument, will add field as is,
     * if called with 2 or more arguments, all of them will be passed
     * to Am_CustomField constructor, and resulting object will be added
     * @param Am_CustomField $field
     * @return Am_CustomField inserted field
     */
    function add($field)
    {
        if (!$field instanceof Am_CustomField)
        {
            $args = func_get_args();
            if (count($args) == 1)
                $args[1] = $args[0]; // set fieldtitle == fieldname
            $field = call_user_func_array(array('Am_CustomField', 'create'), $args);
        }
        $this->fields[$field->name] = $field;
        return $field;
    }

    /**
     * Register a callback that will be called before first call of
     * @see Am_CustomFieldManager::getAll()
     * @param <type> $callback
     * @return Am_CustomFieldsManager provides fluent interface
     */
    function addCallback($callback)
    {
        if (!is_callable($callback, true))
            throw new Am_Exception_InternalError("Wrong callback passed to Am_CustomFieldsManager::addCallback()");
        $this->callbacks[] = $callback;
        return $this;
    }

    /**
     * Delete field by name
     * @param string $name
     * @return Am_CustomFieldManager
     */
    function del($name)
    {
        $this->runCallbacks();
        if (array_key_exists($name, $this->fields))
            unset($this->fields[$name]);
        return $this;
    }

    /**
     * Return array of registered fields. If callbacks
     * are registered, it runs all of them and cleans up
     * callbacks list (so every callback is called only once)
     * @return array of Am_CustomField objects
     */
    function getAll()
    {
        $this->runCallbacks();
        return $this->fields;
    }

    /**
     * Return list of added fields like deprecated global
     * $member_additional_fields, $payment_additional_fields,
     * $product_additional_fields variables
     * @return array list of field defs
     * @deprecated will be removed asap
     */
    function compatGetAll()
    {
        $res = array();
        foreach ($this->getAll() as $f)
        {
            $a = $f->toArray();
            $a['type'] = $f->getType();
            if ($a['type'] == 'unknown')
                $a['type'] = '';
            $a['validate_func'] = $a['validateFunc'];
            unset($a['validateFunc']);
            unset($a['qfType']);
            $res[] = $a;
        }
        return $res;
    }

    protected function runCallbacks()
    {
        if (!$this->callbacks)
            return;
        foreach ($this->callbacks as $c)
            call_user_func($c, $this);
        $this->callbacks = array();
    }

    /**
     * Return field by name
     * @param string $name
     * @return Am_CustomField|null
     */
    function get($name)
    {
        $this->runCallbacks();
        if (array_key_exists($name, $this->fields))
            return $this->fields[$name];
    }

    /**
     * To allow calling addText, addMulti_Select and everything
     * if class Am_CustomField<suffix> existing, then use add<Suffix>(name, description, ...)
     */
    function __call($method, $arguments)
    {
        if (strpos($method, 'add') === 0)
        {
            $class = 'Am_CustomField' . substr($method, 3);
            if (!class_exists($class, false))
                throw new Am_Exception_InternalError("Method " . get_class($this) . "->" . $method . " is not implemented, because [$class] is not defined");
            $reflectionObj = new ReflectionClass($class);
            return $this->add($reflectionObj->newInstanceArgs($arguments));
        } else
            throw new Am_Exception_InternalError("Method " . get_class($this) . "->" . $method . " is not implemented");
    }

    /** for testing only! */
    function _setDb($db)
    {
        $this->_db = $db;
    }

    function valuesFromTable(array $vars)
    {
        foreach ($this->getAll() as $field)
        {
            $fn = $field->name;
            if (isset($vars[$fn]))
                $vars[$fn] = $field->valueFromTable($vars[$fn]);
        }
        return $vars;
    }

    function valuesToTable(array $vars)
    {
        foreach ($this->getAll() as $field)
        {
            $fn = $field->name;
            if (isset($vars[$fn]))
                $vars[$fn] = $field->valueToTable($vars[$fn]);
        }
        return $vars;
    }

}

/**
 * Manipulation and storage of custom field values
 * @package Am_Record
 */
class Am_DataFieldStorage
{
    const TYPE_SCALAR = 0;
    const TYPE_SERIALIZED = 1;
    const TYPE_BLOB = 16;
    /**
     * If you have got this value in get or getAll, use getBlob() to retreive value
     */
    const BLOB_VALUE = 'BLOB_VALUE';
    /** @var Am_Record */
    protected $record;
    protected $data = array();
    protected $blobData = array();
    protected $changedFields = array();
    protected $deletedFields = array();
    protected $blobFields = array();
    protected $isLoaded = false;

    function __construct(Am_Record $record)
    {
        $this->record = $record;
    }

    function get($fieldName)
    {
        $this->load();
        if (array_key_exists($fieldName, $this->data))
        {
            if (!empty($this->blobFields[$fieldName]))
            {
                return self::BLOB_VALUE;
            } else
                return $this->data[$fieldName];
        }
    }

    /**
     * @return Am_DataFieldStorage
     */
    function set($fieldName, $value)
    {
        $this->load();
        $this->data[$fieldName] = $value;
        if ($value === null)
        {
            $this->deletedFields[$fieldName] = true;
            unset($this->changedFields[$fieldName]);
        }
        else
        {
            $this->changedFields[$fieldName] = true;
            unset($this->deletedFields[$fieldName]);
        }
        return $this;
    }

    /**
     * Set blob value for later saving
     * @param string $fieldName
     * @param string|stream $value
     * @return Am_DataFieldStorage
     */
    function setBlob($fieldName, $value)
    {
        $this->set($fieldName, $value);
        $this->blobFields[$fieldName] = true;
        return $this;
    }

    /**
     * @return blob value as stream
     */
    function getBlob($fieldName)
    {
        return $this->record->getAdapter()->selectCell("SELECT `blob`
            FROM ?_data WHERE `table`=? AND `id`=? AND `key`=?", $this->record->getTable()->getName(true), $this->record->pk(), $fieldName);
    }

    /**
     * Return all records, for blobs Am_DataFieldStorage::BLOB_VALUE returned
     */
    function getAll()
    {
        $this->load();
        return $this->data;
    }

    function load()
    {
        if ($this->isLoaded || $this->changedFields || !$this->record->pk())
            return false;
        $this->data = $this->record->getAdapter()->select(
                "SELECT `key` as ARRAY_KEY,`type`,
            CASE `type`
                WHEN ? THEN NULL
                WHEN ? THEN `blob`
                ELSE `value`
            END AS `value`
            FROM ?_data WHERE `table`=? AND `id`=?
            ", self::TYPE_BLOB, self::TYPE_SERIALIZED, $this->record->getTable()->getName(true), $this->record->pk());
        foreach ($this->data as $k => $arr)
        {
            switch ($arr['type'])
            {
                case self::TYPE_SCALAR: $this->data[$k] = $arr['value'];
                    break;
                case self::TYPE_SERIALIZED: $this->data[$k] = unserialize($arr['value']);
                    break;
                case self::TYPE_BLOB: $this->data[$k] = self::BLOB_VALUE;
                    break;
                default:
                    throw new Am_Exception_InternalError("Unknown record type {$arr['type']} in ?_data");
            }
        }
    }

    protected function getInsertRows()
    {
        $rows = array();
        $db = $this->record->getAdapter();
        foreach (array_keys($this->changedFields) as $fieldName)
        {
            $blob = null;
            $val = $this->data[$fieldName];
            if (!empty($this->blobFields[$fieldName]))
            {
                $type = self::TYPE_BLOB;
                $blob = $val;
                $val = null;
            }
            elseif (is_scalar($val) || is_null($val))
            {
                $type = self::TYPE_SCALAR;
                $val = $this->data[$fieldName];
            }
            else
            {
                $type = self::TYPE_SERIALIZED;
                $blob = serialize($val);
                $val = null;
            }
            $rows[] = "(" . implode(',', array(
                    $db->escape($this->record->getTable()->getName(true)),
                    $this->record->pk(),
                    $db->escape($fieldName),
                    $type,
                    $db->escape($val),
                    $db->escape($blob),
                )) . ")";
        }
        return $rows;
    }

    function insert()
    {
        if (!$this->record->pk())
            throw new Am_Exception_Db("Could not insert() datafields on not saved Am_Record..." . get_class($this->record));
        $rows = $this->getInsertRows();
        if (!$rows)
            return;
        $this->record->getAdapter()->query(
            "INSERT INTO ?_data (`table`,`id`,`key`,`type`,`value`,`blob`) " .
            " VALUES " . implode(",", $rows));
        $this->changedFields = $this->deletedFields = array();
    }

    function update()
    {
        $fields = array_keys($this->deletedFields);
        if ($fields)
            $this->record->getAdapter()->query(
                "DELETE FROM ?_data WHERE `table`=? AND `id`=? AND `key` IN (?a)", $this->record->getTable()->getName(true), $this->record->pk(), $fields);
        $rows = $this->getInsertRows();
        if (!$rows)
            return;
        $this->record->getAdapter()->query(
            "INSERT INTO ?_data (`table`,`id`,`key`,`type`,`value`,`blob`) " .
            " VALUES " . implode(",", $rows) .
            " ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), `type`=VALUES(`type`), `blob`=VALUES(`blob`)");
        $this->changedFields = $this->deletedFields = array();
    }

    function delete()
    {
        $this->record->getAdapter()->query("DELETE FROM ?_data WHERE `table`=? AND `id`=?", $this->record->getTable()->getName(true), $this->record->pk());
        $this->data = array();
        $this->changedFields = array();
        $this->blobData = array();
        $this->blobFields = array();
        $this->deletedFields = array();
        $this->isLoaded = true;
    }
}

/**
 * Class to handle related meta-data from "am_data" table 
 */
class Am_Record_WithData extends Am_Record
{

    /** @var Am_DataFieldStorage objects */
    protected $_dataStorage;

    /**
     * @return Am_DataFieldStorage
     */
    public function data()
    {
        if (empty($this->_dataStorage))
            $this->_dataStorage = new Am_DataFieldStorage($this);
        return $this->_dataStorage;
    }

    public function insert($reload = true)
    {
        parent::insert($reload);
        if ($this->_dataStorage)
            $this->_dataStorage->insert();
        return $this;
    }

    public function update()
    {
        parent::update();
        if ($this->_dataStorage)
            $this->_dataStorage->update();
        return $this;
    }

    /**
     * Convert currently set object variables to database record array
     * @return array
     */
    public function toRow()
    {
        return $this->getTable()->customFields()->valuesToTable(parent::toRow());
    }

    /**
     * Set fields from array retreived from database.
     * If $this->_fields is a not-empty array, only these fields will be set
     *
     * @param array $vars
     * @return Am_Record provides fluent interface
     */
    public function fromRow(array $vars)
    {
        return parent::fromRow($this->getTable()->customFields()->valuesFromTable($vars));
    }

    public function delete()
    {
        parent::delete();
        $this->data()->delete();
        return $this;
    }

    public function toArray()
    {
        $data = $this->data()->getAll();
        $arr = parent::toArray();
        return array_merge($data, $arr);
    }

    public function setForInsert($vars)
    {
        $this->_prepareForSet($vars);
        return parent::setForInsert($vars);
    }

    public function setForUpdate($vars)
    {
        $this->_prepareForSet($vars);
        return parent::setForUpdate($vars);
    }

    protected function _prepareForSet(& $vars)
    {
        $extra = array();
        $fields = $this->getTable()->getFields(true);
        foreach ($vars as $k => $v)
        {
            if (in_array($k, $fields))
            {
                // nop
            }
            elseif ($this->getTable()->customFields()->get($k))
            {
                $this->data()->set($k, $v);
                unset($vars[$k]);
            }
            else
            {
                unset($vars[$k]);
            }
        }
    }
}

class Am_Table_WithData extends Am_Table
{
    protected $_recordClass = 'Am_Record_WithData';
    /** @var Am_CustomFieldsManager */
    protected $_customFields;
    /** @return Am_CustomFieldsManager */
    public function customFields()
    {
        if (!$this->_customFields)
            $this->_customFields = new Am_CustomFieldsManager;
        return $this->_customFields;
    }
    /**
     * Find records by related ?_data record
     * @param type $key
     * @param type $value
     */
    function findByData($key, $value, $limit = 100)
    {
        return $this->selectObjects("SELECT t.*
            FROM {$this->_table} t
                LEFT JOIN ?_data d ON (d.`table` = ? AND d.`id` = t.{$this->_key} AND d.`key`=?)
            WHERE d.`value`=? LIMIT ?d", $this->getName(true), $key, $value, $limit);
    }
    /**
     * First first record by related ?_data record
     * @param string $key
     * @param string $value
     * @return Am_Record|null
     */
    function findFirstByData($key, $value)
    {
        return current($this->findByData($key, $value, 1));
    }
}
