<?php

// vim:ts=4:sw=4:et:fdm=marker

namespace atk4\dsql;

/**
 * Perform query operation on SQL server (such as select, insert, delete, etc).
 *
 * @license MIT
 * @copyright Agile Toolkit (c) http://agiletoolkit.org/
 */
class Query extends Expression
{
    /**
     * Query will use one of the predefined templates. The $mode will contain
     * name of template used. Basically it's part of Query property name -
     * Query::template_[$mode].
     *
     * @var string
     */
    public $mode = 'select';

    /**
     * If no fields are defined, this field is used.
     *
     * @var string|Expression
     */
    public $defaultField = '*';

    /** @var string Expression classname */
    protected $expression_class = 'atk4\dsql\Expression';

    /**
     * SELECT template.
     *
     * @var string
     */
    protected $template_select = 'select[option] [field] [from] [table][join][where][group][having][order][limit]';

    /**
     * INSERT template.
     *
     * @var string
     */
    protected $template_insert = 'insert[option] into [table_noalias] ([set_fields]) values ([set_values])';

    /**
     * REPLACE template.
     *
     * @var string
     */
    protected $template_replace = 'replace[option] into [table_noalias] ([set_fields]) values ([set_values])';

    /**
     * DELETE template.
     *
     * @var string
     */
    protected $template_delete = 'delete [from] [table_noalias][where][having]';

    /**
     * UPDATE template.
     *
     * @var string
     */
    protected $template_update = 'update [table_noalias] set [set] [where]';

    /**
     * TRUNCATE template.
     *
     * @var string
     */
    protected $template_truncate = 'truncate table [table_noalias]';

    /**
     * Name or alias of base table to use when using default join().
     *
     * This is set by table(). If you are using multiple tables,
     * then $main_table is set to false as it is irrelevant.
     *
     * @var null|false|string
     */
    protected $main_table = null;

    // {{{ Field specification and rendering

    /**
     * Adds new column to resulting select by querying $field.
     *
     * Examples:
     *  $q->field('name');
     *
     * You can use a dot to prepend table name to the field:
     *  $q->field('user.name');
     *  $q->field('user.name')->field('address.line1');
     *
     * Array as a first argument will specify multiple fields, same as calling field() multiple times
     *  $q->field(['name', 'surname', 'address.line1']);
     *
     * You can pass first argument as Expression or Query
     *  $q->field( $q->expr('2+2'), 'alias');   // must always use alias
     *
     * You can use $q->dsql() for subqueries. Subqueries will be wrapped in
     * brackets.
     *  $q->field( $q->dsql()->table('x')... , 'alias');
     *
     * Associative array will assume that "key" holds the field alias.
     * Value may be field name, Expression or Query.
     *  $q->field(['alias' => 'name', 'alias2' => 'mother.surname']);
     *  $q->field(['alias' => $q->expr(..), 'alias2' => $q->dsql()->.. ]);
     *
     * If you need to use funky name for the field (e.g, one containing
     * a dot or a space), you should wrap it into expression:
     *  $q->field($q->expr('{}', ['fun...ky.field']), 'f');
     *
     * @param mixed  $field Specifies field to select
     * @param string $alias Specify alias for this field
     *
     * @return $this
     */
    public function field($field, $alias = null)
    {
        // field is passed as string, may contain commas
        if (is_string($field) && strpos($field, ',') !== false) {
            $field = explode(',', $field);
        }

        // recursively add array fields
        if (is_array($field)) {
            if ($alias !== null) {
                throw new Exception([
                    'Alias must not be specified when $field is an array',
                    'alias' => $alias,
                ]);
            }

            foreach ($field as $alias => $f) {
                if (is_numeric($alias)) {
                    $alias = null;
                }
                $this->field($f, $alias);
            }

            return $this;
        }

        // save field in args
        $this->_set_args('field', $alias, $field);

        return $this;
    }

    /**
     * Returns template component for [field].
     *
     * @param bool $add_alias Should we add aliases, see _render_field_noalias()
     *
     * @return string Parsed template chunk
     */
    protected function _render_field($add_alias = true)
    {
        // will be joined for output
        $ret = [];

        // If no fields were defined, use defaultField
        if (empty($this->args['field'])) {
            if ($this->defaultField instanceof Expression) {
                return $this->_consume($this->defaultField);
            }

            return (string) $this->defaultField;
        }

        // process each defined field
        foreach ($this->args['field'] as $alias => $field) {
            // Do not add alias, if:
            //  - we don't want aliases OR
            //  - alias is the same as field OR
            //  - alias is numeric
            if (
                $add_alias === false
                || (is_string($field) && $alias === $field)
                || is_numeric($alias)
            ) {
                $alias = null;
            }

            // Will parameterize the value and escape if necessary
            $field = $this->_consume($field, 'soft-escape');

            if ($alias) {
                // field alias cannot be expression, so simply escape it
                $field .= ' '.$this->_escape($alias);
            }

            $ret[] = $field;
        }

        return implode(',', $ret);
    }

    /**
     * Renders part of the template: [field_noalias]
     * Do not call directly.
     *
     * @return string Parsed template chunk
     */
    protected function _render_field_noalias()
    {
        return $this->_render_field(false);
    }

    // }}}

    // {{{ Table specification and rendering

    /**
     * Specify a table to be used in a query.
     *
     * @param mixed  $table Specifies table
     * @param string $alias Specify alias for this table
     *
     * @return $this
     */
    public function table($table, $alias = null)
    {
        // comma-separated table names
        if (is_string($table) && strpos($table, ',') !== false) {
            $table = explode(',', $table);
        }

        // array of tables - recursively process each
        if (is_array($table)) {
            if ($alias !== null) {
                throw new Exception([
                    'You cannot use single alias with multiple tables',
                    'alias' => $alias,
                ]);
            }

            foreach ($table as $alias => $t) {
                if (is_numeric($alias)) {
                    $alias = null;
                }
                $this->table($t, $alias);
            }

            return $this;
        }

        // if table is set as sub-Query, then alias is mandatory
        if ($table instanceof self && $alias === null) {
            throw new Exception('If table is set as Query, then table alias is mandatory');
        }

        if (is_string($table) && $alias === null) {
            $alias = $table;
        }

        // main_table will be set only if table() is called once.
        // it's used as "default table" when joining with other tables, see join().
        // on multiple calls, main_table will be false and we won't
        // be able to join easily anymore.
        $this->main_table = ($this->main_table === null && $alias !== null ? $alias : false);

        // save table in args
        $this->_set_args('table', $alias, $table);

        return $this;
    }

    /**
     * Renders part of the template: [table]
     * Do not call directly.
     *
     * @param bool $add_alias Should we add aliases, see _render_table_noalias()
     *
     * @return string Parsed template chunk
     */
    protected function _render_table($add_alias = true)
    {
        // will be joined for output
        $ret = [];

        if (empty($this->args['table'])) {
            return '';
        }

        // process tables one by one
        foreach ($this->args['table'] as $alias => $table) {

            // throw exception if we don't want to add alias and table is defined as Expression
            if ($add_alias === false && $table instanceof self) {
                throw new Exception('Table cannot be Query in UPDATE, INSERT etc. query modes');
            }

            // Do not add alias, if:
            //  - we don't want aliases OR
            //  - alias is the same as table name OR
            //  - alias is numeric
            if (
                $add_alias === false
                || (is_string($table) && $alias === $table)
                || is_numeric($alias)
            ) {
                $alias = null;
            }

            // consume or escape table
            $table = $this->_consume($table, 'soft-escape');

            // add alias if needed
            if ($alias) {
                $table .= ' '.$this->_escape($alias);
            }

            $ret[] = $table;
        }

        return implode(',', $ret);
    }

    /**
     * Renders part of the template: [table_noalias]
     * Do not call directly.
     *
     * @return string Parsed template chunk
     */
    protected function _render_table_noalias()
    {
        return $this->_render_table(false);
    }

    /**
     * Renders part of the template: [from]
     * Do not call directly.
     *
     * @return string Parsed template chunk
     */
    protected function _render_from()
    {
        return empty($this->args['table']) ? '' : 'from';
    }

    /// }}}

    // {{{ join()

    /**
     * Joins your query with another table. Join will use $main_table
     * to reference the main table, unless you specify it explicitly.
     *
     * Examples:
     *  $q->join('address');         // on user.address_id=address.id
     *  $q->join('address.user_id'); // on address.user_id=user.id
     *  $q->join('address a');       // With alias
     *  $q->join(array('a'=>'address')); // Also alias
     *
     * Second argument may specify the field of the master table
     *  $q->join('address', 'billing_id');
     *  $q->join('address.code', 'code');
     *  $q->join('address.code', 'user.code');
     *
     * Third argument may specify which kind of join to use.
     *  $q->join('address', null, 'left');
     *  $q->join('address.code', 'user.code', 'inner');
     *
     * Using array syntax you can join multiple tables too
     *  $q->join(array('a'=>'address', 'p'=>'portfolio'));
     *
     * You can use expression for more complex joins
     *  $q->join('address',
     *      $q->orExpr()
     *          ->where('user.billing_id=address.id')
     *          ->where('user.technical_id=address.id')
     *  )
     *
     * @param string|array $foreign_table  Table to join with
     * @param mixed        $master_field   Field in master table
     * @param string       $join_kind      'left' or 'inner', etc
     * @param string       $_foreign_alias Internal, don't use
     *
     * @return $this
     */
    public function join(
        $foreign_table,
        $master_field = null,
        $join_kind = null,
        $_foreign_alias = null
    ) {
        // If array - add recursively
        if (is_array($foreign_table)) {
            foreach ($foreign_table as $alias => $foreign) {
                if (is_numeric($alias)) {
                    $alias = null;
                }

                $this->join($foreign, $master_field, $join_kind, $alias);
            }

            return $this;
        }
        $j = [];

        // try to find alias in foreign table definition
        if ($_foreign_alias === null) {
            list($foreign_table, $_foreign_alias) = array_pad(explode(' ', $foreign_table, 2), 2, null);
        }

        // Split and deduce fields
        list($f1, $f2) = array_pad(explode('.', $foreign_table, 2), 2, null);

        if (is_object($master_field)) {
            $j['expr'] = $master_field;
        } else {
            // Split and deduce primary table
            if ($master_field === null) {
                list($m1, $m2) = [null, null];
            } else {
                list($m1, $m2) = array_pad(explode('.', $master_field, 2), 2, null);
            }
            if ($m2 === null) {
                $m2 = $m1;
                $m1 = null;
            }
            if ($m1 === null) {
                $m1 = $this->main_table;
            }

            // Identify fields we use for joins
            if ($f2 === null && $m2 === null) {
                $m2 = $f1.'_id';
            }
            if ($m2 === null) {
                $m2 = 'id';
            }
            $j['m1'] = $m1;
            $j['m2'] = $m2;
        }

        $j['f1'] = $f1;
        if ($f2 === null) {
            $f2 = 'id';
        }
        $j['f2'] = $f2;

        $j['t'] = $join_kind ?: 'left';
        $j['fa'] = $_foreign_alias;

        $this->args['join'][] = $j;

        return $this;
    }

    /**
     * Renders [join].
     *
     * @return string rendered SQL chunk
     */
    public function _render_join()
    {
        if (!isset($this->args['join'])) {
            return '';
        }
        $joins = [];
        foreach ($this->args['join'] as $j) {
            $jj = '';

            $jj .= $j['t'].' join ';

            $jj .= $this->_escape($j['f1']);

            if ($j['fa'] !== null) {
                $jj .= ' as '.$this->_escape($j['fa']);
            }

            $jj .= ' on ';

            if (isset($j['expr'])) {
                $jj .= $this->_consume($j['expr']);
            } else {
                $jj .=
                    $this->_escape($j['fa'] ?: $j['f1']).'.'.
                    $this->_escape($j['f2']).' = '.
                    ($j['m1'] === null ? '' : $this->_escape($j['m1']).'.').
                    $this->_escape($j['m2']);
            }
            $joins[] = $jj;
        }

        return ' '.implode(' ', $joins);
    }

    // }}}

    // {{{ where() and having() specification and rendering

    /**
     * Adds condition to your query.
     *
     * Examples:
     *  $q->where('id',1);
     *
     * By default condition implies equality. You can specify a different comparison
     * operator by either including it along with the field or using 3-argument
     * format:
     *  $q->where('id>','1');
     *  $q->where('id','>',1);
     *
     * You may use Expression as any part of the query.
     *  $q->where($q->expr('a=b'));
     *  $q->where('date>',$q->expr('now()'));
     *  $q->where($q->expr('length(password)'),'>',5);
     *
     * If you specify Query as an argument, it will be automatically
     * surrounded by brackets:
     *  $q->where('user_id',$q->dsql()->table('users')->field('id'));
     *
     * You can specify OR conditions by passing single argument - array:
     *  $q->where([
     *      ['a','is',null],
     *      ['b','is',null]
     *  ]);
     *
     * If entry of the OR condition is not an array, then it's assumed to
     * be an expression;
     *
     *  $q->where([
     *      ['age',20],
     *      'age is null'
     *  ]);
     *
     * The above use of OR conditions rely on orExpr() functionality. See
     * that method for more information.
     *
     * To specify OR conditions
     *  $q->where($q->orExpr()->where('a',1)->where('b',1));
     *
     * @param mixed  $field    Field, array for OR or Expression
     * @param mixed  $cond     Condition such as '=', '>' or 'is not'
     * @param mixed  $value    Value. Will be quoted unless you pass expression
     * @param string $kind     Do not use directly. Use having()
     * @param string $num_args When $kind is passed, we can't determine number of
     *                         actual arguments, so this argument must be specified.
     *
     * @return $this
     */
    public function where($field, $cond = null, $value = null, $kind = 'where', $num_args = null)
    {
        // Number of passed arguments will be used to determine if arguments were specified or not
        if ($num_args === null) {
            $num_args = func_num_args();
        }

        // Array as first argument means we have to replace it with orExpr()
        if (is_array($field)) {
            // or conditions
            $or = $this->orExpr();
            foreach ($field as $row) {
                if (is_array($row)) {
                    call_user_func_array([$or, 'where'], $row);
                } else {
                    $or->where($row);
                }
            }
            $field = $or;
        }

        if ($num_args === 1 && is_string($field)) {
            $this->args[$kind][] = [$this->expr($field)];

            return $this;
        }

        // first argument is string containing more than just a field name and no more than 2
        // arguments means that we either have a string expression or embedded condition.
        if ($num_args === 2 && is_string($field) && !preg_match('/^[.a-zA-Z0-9_]*$/', $field)) {
            // field contains non-alphanumeric values. Look for condition
            preg_match(
                '/^([^ <>!=]*)([><!=]*|( *(not|is|in|like))*) *$/',
                $field,
                $matches
            );

            // matches[2] will contain the condition, but $cond will contain the value
            $value = $cond;
            $cond = $matches[2];

            // if we couldn't clearly identify the condition, we might be dealing with
            // a more complex expression. If expression is followed by another argument
            // we need to add equation sign  where('now()',123).
            if (!$cond) {
                $matches[1] = $this->expr($field);

                $cond = '=';
            } else {
                $num_args++;
            }

            $field = $matches[1];
        }

        switch ($num_args) {
            case 1:
                $this->args[$kind][] = [$field];
                break;
            case 2:
                if (is_object($cond) && !$cond instanceof Expressionable && !$cond instanceof Expression) {
                    throw new Exception([
                        'Value cannot be converted to SQL-compatible expression',
                        'field'=> $field,
                        'value'=> $cond,
                    ]);
                }

                $this->args[$kind][] = [$field, $cond];
                break;
            case 3:
                if (is_object($value) && !$value instanceof Expressionable && !$value instanceof Expression) {
                    throw new Exception([
                        'Value cannot be converted to SQL-compatible expression',
                        'field'=> $field,
                        'cond' => $cond,
                        'value'=> $value,
                    ]);
                }

                $this->args[$kind][] = [$field, $cond, $value];
                break;
        }

        return $this;
    }

    /**
     * Same syntax as where().
     *
     * @param mixed  $field Field, array for OR or Expression
     * @param string $cond  Condition such as '=', '>' or 'is not'
     * @param string $value Value. Will be quoted unless you pass expression
     *
     * @return $this
     */
    public function having($field, $cond = null, $value = null)
    {
        $num_args = func_num_args();

        return $this->where($field, $cond, $value, 'having', $num_args);
    }

    /**
     * Subroutine which renders either [where] or [having].
     *
     * @param string $kind 'where' or 'having'
     *
     * @return array Parsed chunks of query
     */
    protected function __render_where($kind)
    {
        // will be joined for output
        $ret = [];

        // where() might have been called multiple times. Collect all conditions,
        // then join them with AND keyword
        foreach ($this->args[$kind] as $row) {
            $ret[] = $this->__render_condition($row);
        }

        return $ret;
    }

    /**
     * Renders one condition.
     *
     * @param array $row Condition
     *
     * @return string
     */
    protected function __render_condition($row)
    {
        if (count($row) === 3) {
            list($field, $cond, $value) = $row;
        } elseif (count($row) === 2) {
            list($field, $cond) = $row;
        } elseif (count($row) === 1) {
            list($field) = $row;
        }

        $field = $this->_consume($field, 'soft-escape');

        if (count($row) == 1) {
            // Only a single parameter was passed, so we simply include all
            return $field;
        }

        // below are only cases when 2 or 3 arguments are passed

        // if no condition defined - set default condition
        if (count($row) == 2) {
            $value = $cond;

            if (is_array($value)) {
                $cond = 'in';
            } elseif ($value instanceof self && $value->mode === 'select') {
                $cond = 'in';
            } else {
                $cond = '=';
            }
        } else {
            $cond = trim(strtolower($cond));
        }

        // below we can be sure that all 3 arguments has been passed

        // special conditions (IS | IS NOT) if value is null
        if ($value === null) {
            if ($cond === '=') {
                $cond = 'is';
            } elseif (in_array($cond, ['!=', '<>', 'not'])) {
                $cond = 'is not';
            }
        }

        // value should be array for such conditions
        if (in_array($cond, ['in', 'not in', 'not']) && is_string($value)) {
            $value = array_map('trim', explode(',', $value));
        }

        // special conditions (IN | NOT IN) if value is array
        if (is_array($value)) {
            $value = '('.implode(',', $this->_param($value)).')';
            $cond = in_array($cond, ['!=', '<>', 'not', 'not in']) ? 'not in' : 'in';

            return $field.' '.$cond.' '.$value;
        }

        // if value is object, then it should be Expression or Query itself
        // otherwise just escape value
        $value = $this->_consume($value, 'param');

        return $field.' '.$cond.' '.$value;
    }

    /**
     * Renders [where].
     *
     * @return string rendered SQL chunk
     */
    protected function _render_where()
    {
        if (!isset($this->args['where'])) {
            return;
        }

        return ' where '.implode(' and ', $this->__render_where('where'));
    }

    /**
     * Renders [orwhere].
     *
     * @return string rendered SQL chunk
     */
    protected function _render_orwhere()
    {
        if (!isset($this->args['where'])) {
            return;
        }

        return implode(' or ', $this->__render_where('where'));
    }

    /**
     * Renders [andwhere].
     *
     * @return string rendered SQL chunk
     */
    protected function _render_andwhere()
    {
        if (!isset($this->args['where'])) {
            return;
        }

        return implode(' and ', $this->__render_where('where'));
    }

    /**
     * Renders [having].
     *
     * @return string rendered SQL chunk
     */
    protected function _render_having()
    {
        if (!isset($this->args['having'])) {
            return;
        }

        return ' having '.implode(' and ', $this->__render_where('having'));
    }

    // }}}

    // {{{ group()

    /**
     * Implements GROUP BY functionality. Simply pass either field name
     * as string or expression.
     *
     * @param mixed $group Group by this
     *
     * @return $this
     */
    public function group($group)
    {
        // Case with comma-separated fields
        if (is_string($group) && !$this->isUnescapablePattern($group) && strpos($group, ',') !== false) {
            $group = explode(',', $group);
        }

        if (is_array($group)) {
            foreach ($group as $g) {
                $this->args['group'][] = $g;
            }

            return $this;
        }

        $this->args['group'][] = $group;

        return $this;
    }

    /**
     * Renders [group].
     *
     * @return string rendered SQL chunk
     */
    protected function _render_group()
    {
        if (!isset($this->args['group'])) {
            return '';
        }

        $g = array_map(function ($a) {
            return $this->_consume($a, 'soft-escape');
        }, $this->args['group']);

        return ' group by '.implode(', ', $g);
    }

    // }}}

    // {{{ Set field implementation

    /**
     * Sets field value for INSERT or UPDATE statements.
     *
     * @param string|array $field Name of the field
     * @param mixed        $value Value of the field
     *
     * @return $this
     */
    public function set($field, $value = null)
    {
        if ($value === false) {
            throw new Exception([
                'Value "false" is not supported by SQL',
                'field' => $field,
                'value' => $value,
            ]);
        }

        if (is_array($value)) {
            throw new Exception([
                'Array values are not supported by SQL',
                'field' => $field,
                'value' => $value,
            ]);
        }

        if (is_array($field)) {
            foreach ($field as $key => $value) {
                $this->set($key, $value);
            }

            return $this;
        }

        if (is_string($field) || $field instanceof Expression || $field instanceof Expressionable) {
            $this->args['set'][] = [$field, $value];
        } else {
            throw new Exception([
                'Field name should be string or Expressionable',
                'field' => $field,
            ]);
        }

        return $this;
    }

    /**
     * Renders [set] for UPDATE query.
     *
     * @return string rendered SQL chunk
     */
    protected function _render_set()
    {
        // will be joined for output
        $ret = [];

        if (isset($this->args['set']) && $this->args['set']) {
            foreach ($this->args['set'] as list($field, $value)) {
                $field = $this->_consume($field, 'escape');
                $value = $this->_consume($value, 'param');

                $ret[] = $field.'='.$value;
            }
        }

        return implode(', ', $ret);
    }

    /**
     * Renders [set_fields] for INSERT.
     *
     * @return string rendered SQL chunk
     */
    protected function _render_set_fields()
    {
        // will be joined for output
        $ret = [];

        if ($this->args['set']) {
            foreach ($this->args['set'] as list($field/*, $value*/)) {
                $field = $this->_consume($field, 'escape');

                $ret[] = $field;
            }
        }

        return implode(',', $ret);
    }

    /**
     * Renders [set_values] for INSERT.
     *
     * @return string rendered SQL chunk
     */
    protected function _render_set_values()
    {
        // will be joined for output
        $ret = [];

        if ($this->args['set']) {
            foreach ($this->args['set'] as list(/*$field*/, $value)) {
                $value = $this->_consume($value, 'param');

                $ret[] = $value;
            }
        }

        return implode(',', $ret);
    }

    // }}}

    // {{{ Option

    /**
     * Set options for particular mode.
     *
     * @param mixed  $option
     * @param string $mode   select|insert|replace
     *
     * @return $this
     */
    public function option($option, $mode = 'select')
    {
        // Case with comma-separated options
        if (is_string($option) && strpos($option, ',') !== false) {
            $option = explode(',', $option);
        }

        if (is_array($option)) {
            foreach ($option as $opt) {
                $this->args['option'][$mode][] = $opt;
            }

            return $this;
        }

        $this->args['option'][$mode][] = $option;

        return $this;
    }

    /**
     * Renders [option].
     *
     * @return string rendered SQL chunk
     */
    protected function _render_option()
    {
        if (!isset($this->args['option'][$this->mode])) {
            return '';
        }

        return ' '.implode(' ', $this->args['option'][$this->mode]);
    }

    // }}}

    // {{{ Query Modes

    /**
     * Execute select statement.
     *
     * @return \PDOStatement
     */
    public function select()
    {
        return $this->mode('select')->execute();
    }

    /**
     * Execute insert statement.
     *
     * @return \PDOStatement
     */
    public function insert()
    {
        return $this->mode('insert')->execute();
    }

    /**
     * Execute update statement.
     *
     * @return \PDOStatement
     */
    public function update()
    {
        return $this->mode('update')->execute();
    }

    /**
     * Execute replace statement.
     *
     * @return \PDOStatement
     */
    public function replace()
    {
        return $this->mode('replace')->execute();
    }

    /**
     * Execute delete statement.
     *
     * @return \PDOStatement
     */
    public function delete()
    {
        return $this->mode('delete')->execute();
    }

    /**
     * Execute truncate statement.
     *
     * @return \PDOStatement
     */
    public function truncate()
    {
        return $this->mode('truncate')->execute();
    }

    // }}}

    // {{{ Limit

    /**
     * Limit how many rows will be returned.
     *
     * @param int $cnt   Number of rows to return
     * @param int $shift Offset, how many rows to skip
     *
     * @return $this
     */
    public function limit($cnt, $shift = null)
    {
        $this->args['limit'] = [
            'cnt'   => $cnt,
            'shift' => $shift,
        ];

        return $this;
    }

    /**
     * Renders [limit].
     *
     * @return string rendered SQL chunk
     */
    public function _render_limit()
    {
        if (isset($this->args['limit'])) {
            return ' limit '.
                (int) $this->args['limit']['shift'].
                ', '.
                (int) $this->args['limit']['cnt'];
        }
    }

    // }}}

    // {{{ Order

    /**
     * Orders results by field or Expression. See documentation for full
     * list of possible arguments.
     *
     * $q->order('name');
     * $q->order('name desc');
     * $q->order('name desc, id asc')
     * $q->order('name',true);
     *
     * @param string|array $order Order by
     * @param string|bool  $desc  true to sort descending
     *
     * @return $this
     */
    public function order($order, $desc = null)
    {
        // Case with comma-separated fields or first argument being an array
        if (is_string($order) && strpos($order, ',') !== false) {
            $order = explode(',', $order);
        }

        if (is_array($order)) {
            if ($desc !== null) {
                throw new Exception(
                    'If first argument is array, second argument must not be used'
                );
            }
            foreach (array_reverse($order) as $o) {
                $this->order($o);
            }

            return $this;
        }

        // First argument may contain space, to divide field and ordering keyword.
        // Explode string only if ordering keyword is 'desc' or 'asc'.
        if ($desc === null && is_string($order) && strpos($order, ' ') !== false) {
            $_chunks = explode(' ', $order);
            $_desc = strtolower(array_pop($_chunks));
            if (in_array($_desc, ['desc', 'asc'])) {
                $order = implode(' ', $_chunks);
                $desc = $_desc;
            }
        }

        if (is_bool($desc)) {
            $desc = $desc ? 'desc' : '';
        } elseif (strtolower($desc) === 'asc') {
            $desc = '';
        } else {
            // allows custom order like "order by name desc nulls last" for Oracle
        }

        $this->args['order'][] = [$order, $desc];

        return $this;
    }

    /**
     * Renders [order].
     *
     * @return string rendered SQL chunk
     */
    public function _render_order()
    {
        if (!isset($this->args['order'])) {
            return'';
        }

        $x = [];
        foreach ($this->args['order'] as $tmp) {
            list($arg, $desc) = $tmp;
            $x[] = $this->_consume($arg, 'soft-escape').($desc ? (' '.$desc) : '');
        }

        return ' order by '.implode(', ', array_reverse($x));
    }

    // }}}

    public function __debugInfo()
    {
        $arr = [
            'R'          => false,
            'mode'       => $this->mode,
            //'template'   => $this->template,
            //'params'     => $this->params,
            //'connection' => $this->connection,
            //'main_table' => $this->main_table,
            //'args'       => $this->args,
        ];

        try {
            $arr['R'] = $this->getDebugQuery();
        } catch (\Exception $e) {
            $arr['R'] = $e->getMessage();
        }

        return $arr;
    }

    // {{{ Miscelanious

    /**
     * Renders query template. If the template is not explicitly set will use "select" mode.
     *
     * @return string
     */
    public function render()
    {
        if (!$this->template) {
            $this->mode('select');
        }

        return parent::render();
    }

    /**
     * Switch template for this query. Determines what would be done
     * on execute.
     *
     * By default it is in SELECT mode
     *
     * @param string $mode
     *
     * @return $this
     */
    public function mode($mode)
    {
        $prop = 'template_'.$mode;

        if (isset($this->{$prop})) {
            $this->mode = $mode;
            $this->template = $this->{$prop};
        } else {
            throw new Exception([
                'Query does not have this mode',
                'mode' => $mode,
            ]);
        }

        return $this;
    }

    /**
     * Use this instead of "new Query()" if you want to automatically bind
     * query to the same connection as the parent.
     *
     * @param array $properties
     *
     * @return Query
     */
    public function dsql($properties = [])
    {
        $q = new static($properties);
        $q->connection = $this->connection;

        return $q;
    }

    /**
     * Returns Expression object for the corresponding Query
     * sub-class (e.g. Query_MySQL will return Expression_MySQL).
     *
     * Connection is not mandatory, but if set, will be preserved. This
     * method should be used for building parts of the query internally.
     *
     * @param array $properties
     * @param array $arguments
     *
     * @return Expression
     */
    public function expr($properties = [], $arguments = null)
    {
        $c = $this->expression_class;
        $e = new $c($properties, $arguments);
        $e->connection = $this->connection;

        return $e;
    }

    /**
     * Returns new Query object of [or] expression.
     *
     * @return Query
     */
    public function orExpr()
    {
        return $this->dsql(['template' => '[orwhere]']);
    }

    /**
     * Returns new Query object of [and] expression.
     *
     * @return Query
     */
    public function andExpr()
    {
        return $this->dsql(['template' => '[andwhere]']);
    }

    /**
     * Returns Query object of [case] expression.
     *
     * @param mixed $operand Optional operand for case expression.
     *
     * @return Query
     */
    public function caseExpr($operand = null)
    {
        $q = $this->dsql(['template' => '[case]']);

        if ($operand !== null) {
            $q->args['case_operand'] = $operand;
        }

        return $q;
    }

    /**
     * Returns a query for a function, which can be used as part of the GROUP
     * query which would concatenate all matching fields.
     *
     * MySQL, SQLite - group_concat
     * PostgreSQL - string_agg
     * Oracle - listagg
     *
     * @param mixed  $field
     * @param string $delimiter
     *
     * @return Expression
     */
    public function groupConcat($field, $delimeter = ',')
    {
        throw new Exception('groupConcat() is SQL-dependent, so use a correct class');
    }

    /**
     * Add when/then condition for [case] expression.
     *
     * @param mixed $when Condition as array for normal form [case] statement or just value in case of short form [case] statement
     * @param mixed $then Then expression or value
     *
     * @return $this
     */
    public function when($when, $then)
    {
        $this->args['case_when'][] = [$when, $then];

        return $this;
    }

    /**
     * Add else condition for [case] expression.
     *
     * @param mixed $else Else expression or value
     *
     * @return $this
     */
    //public function else($else) // PHP 5.6 restricts to use such method name. PHP 7 is fine with it
    public function otherwise($else)
    {
        $this->args['case_else'] = $else;

        return $this;
    }

    /**
     * Renders [case].
     *
     * @return string rendered SQL chunk
     */
    protected function _render_case()
    {
        if (!isset($this->args['case_when'])) {
            return;
        }

        $ret = '';

        // operand
        if ($short_form = isset($this->args['case_operand'])) {
            $ret .= ' '.$this->_consume($this->args['case_operand'], 'soft-escape');
        }

        // when, then
        foreach ($this->args['case_when'] as $row) {
            if (!array_key_exists(0, $row) || !array_key_exists(1, $row)) {
                throw new Exception([
                    'Incorrect use of "when" method parameters',
                    'row'  => $row,
                ]);
            }

            $ret .= ' when ';
            if ($short_form) {
                // short-form
                if (is_array($row[0])) {
                    throw new Exception([
                        'When using short form CASE statement, then you should not set array as when() method 1st parameter',
                        'when'  => $row[0],
                    ]);
                }
                $ret .= $this->_consume($row[0], 'param');
            } else {
                $ret .= $this->__render_condition($row[0]);
            }

            // then
            $ret .= ' then '.$this->_consume($row[1], 'param');
        }

        // else
        if (array_key_exists('case_else', $this->args)) {
            $ret .= ' else '.$this->_consume($this->args['case_else'], 'param');
        }

        return ' case'.$ret.' end';
    }

    /**
     * Sets value in args array. Doesn't allow duplicate aliases.
     *
     * @param string $what  Where to set it - table|field
     * @param string $alias Alias name
     * @param mixed  $value Value to set in args array
     */
    protected function _set_args($what, $alias, $value)
    {
        // save value in args
        if ($alias === null) {
            $this->args[$what][] = $value;
        } else {

            // don't allow multiple values with same alias
            if (isset($this->args[$what][$alias])) {
                throw new Exception([
                    'Alias should be unique',
                    'what'  => $what,
                    'alias' => $alias,
                ]);
            }

            $this->args[$what][$alias] = $value;
        }
    }

    /// }}}
}


<?php

// vim:ts=4:sw=4:et:fdm=marker

namespace atk4\dsql;

/**
 * Perform query operation on Oracle server.
 *
 * @license MIT
 * @copyright Agile Toolkit (c) http://agiletoolkit.org/
 */
class Query_Oracle extends Query_Oracle_Abstract
{
    /**
     * SELECT template.
     *
     * Default [limit] syntax not supported. Add rownum implementation instead.
     *
     * @var string
     */
    protected $template_select = 'select[option] [field] [from] [table][join][where][group][having][order]';
    protected $template_select_limit = 'select * from (select rownum "__dsql_rownum","__t".* [from] (select[option] [field] [from] [table][join][where][group][having][order]) "__t") where "__dsql_rownum">[limit_start][and_limit_end]';

    /**
     * Limit how many rows will be returned.
     *
     * @param int $cnt   Number of rows to return
     * @param int $shift Offset, how many rows to skip
     *
     * @return $this
     */
    public function limit($cnt, $shift = null)
    {
        // This is for pre- 12c version
        $this->template_select = $this->template_select_limit;

        return parent::limit($cnt, $shift);
    }

    /**
     * Renders [limit_start].
     *
     * @return string rendered SQL chunk
     */
    public function _render_limit_start()
    {
        return (int) $this->args['limit']['shift'];
    }

    /**
     * Renders [and_limit_end].
     *
     * @return string rendered SQL chunk
     */
    public function _render_and_limit_end()
    {
        if (!$this->args['limit']['cnt']) {
            return '';
        }

        return ' and "__dsql_rownum"<='.
            ((int) ($this->args['limit']['cnt'] + $this->args['limit']['shift']));
    }
}


<?php

/**
 * This file is part of the m1\env library
 *
 * (c) m1 <hello@milescroxford.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @package     m1/env
 * @version     2.0.0
 * @author      Miles Croxford <hello@milescroxford.com>
 * @copyright   Copyright (c) Miles Croxford <hello@milescroxford.com>
 * @license     http://github.com/m1/env/blob/master/LICENSE.md
 * @link        http://github.com/m1/env/blob/master/README.md Documentation
 */

namespace M1\Env\Parser;

use M1\Env\Exception\ParseException;

/**
 * The value parser for Env
 *
 * @since 0.2.0
 */
class ValueParser extends AbstractParser
{
    /**
     * The regex to get the content between double quote (") strings, ignoring escaped quotes.
     * Unescaped: "(?:[^"\\]*(?:\\.)?)*"
     *
     * @var string REGEX_QUOTE_DOUBLE_STRING
     */
    const REGEX_QUOTE_DOUBLE_STRING = '"(?:[^\"\\\\]*(?:\\\\.)?)*\"';

    /**
     * The regex to get the content between single quote (') strings, ignoring escaped quotes
     * Unescaped: '(?:[^'\\]*(?:\\.)?)*'
     *
     * @var string REGEX_QUOTE_SINGLE_STRING
     */
    const REGEX_QUOTE_SINGLE_STRING = "'(?:[^'\\\\]*(?:\\\\.)?)*'";

    /**
     * The value types that Env supports
     *
     * @var array $value_types
     */
    private static $value_types = array(
        'string',
        'bool',
        'number',
        'null',
    );

    /**
     * The map to convert escaped characters into real characters
     *
     * @var array $character_map
     */
    private static $character_map = array(
        "\\n" => "\n",
        "\\\"" => "\"",
        '\\\'' => "'",
        '\\t' => "\t"
    );

    /**
     * The parser for variables
     *
     * @var \M1\Env\Parser\VariableParser $variable_parser
     */
    private $variable_parser;

    /**
     * {@inheritdoc}
     *
     * @param \M1\Env\Parser $parser The parent parser
     * @param array $context Variables context
     */
    public function __construct($parser, array $context = array())
    {
        parent::__construct($parser);

        $this->variable_parser = new VariableParser($parser, $context);
    }

    /**
     * Parses a .env value
     *
     * @param string $value    The value to parse
     *
     * @return string|null The parsed key, or null if the key is a comment
     */
    public function parse($value)
    {
        $value = trim($value);

        if ($this->parser->string_helper->startsWith('#', $value)) {
            return null;
        }

        return $this->parseValue($value);
    }

    /**
     * Parses a .env value
     *
     * @param string $value The value to parse
     *
     * @return string|null The parsed value, or null if the value is null
     *
     * @uses parseString
     * @uses parseNull
     * @uses parseBool
     * @uses parseNumber
     */
    private function parseValue($value)
    {
        foreach (self::$value_types as $type) {
            $parsed_value = $value;

            if ($type !== 'string') {
                $parsed_value = $this->parser->string_helper->stripComments($value);
            }

            list($is_function, $parse_function) = $this->fetchFunctionNames($type);

            if ($this->parser->string_helper->$is_function($parsed_value)) {
                return $this->$parse_function($parsed_value);
            }
        }

        return (isset($parsed_value)) ? $this->parseUnquotedString($parsed_value) : $value;
    }

    /**
     * Gets the functions for the value type
     *
     * @param string $type The value type
     *
     * @return string[] The is and parse function names
     */
    private function fetchFunctionNames($type)
    {
        $type = ucfirst($type);

        return array(
            'is'.$type,
            'parse'.$type
        );
    }

    /**
     * Parses a .env string
     *
     * @param string $value    The value to parse
     *
     * @return string The parsed string
     */
    private function parseString($value)
    {
        $single = false;
        $regex = self::REGEX_QUOTE_DOUBLE_STRING;
        $symbol = '"';

        if ($this->parser->string_helper->startsWith('\'', $value)) {
            $single = true;
            $regex =  self::REGEX_QUOTE_SINGLE_STRING;
            $symbol = "'";
        }

        $matches = $this->fetchStringMatches($value, $regex, $symbol);

        $value = trim($matches[0], $symbol);
        $value = strtr($value, self::$character_map);

        return ($single) ? $value : $this->variable_parser->parse($value, true);
    }

    /**
     * Gets the regex matches in the string
     *
     * @param string $regex    The regex to use
     * @param string $value    The value to parse
     * @param string $symbol   The symbol we're parsing for
     *
     * @throws \M1\Env\Exception\ParseException If the string has a missing end quote
     *
     * @return string[] The matches based on the regex and the value
     */
    private function fetchStringMatches($value, $regex, $symbol)
    {
        if (!preg_match('/'.$regex.'/', $value, $matches)) {
            throw new ParseException(
                sprintf('Missing end %s quote', $symbol),
                $value,
                $this->parser->line_num
            );
        }

        return $matches;
    }

    /**
     * Parses a .env null value
     *
     * @param string $value The value to parse
     *
     * @return null Null value
     */
    private function parseNull($value)
    {
        return (is_null($value) || $value === "null") ? null : false;
    }

    /**
     * Parses a .env unquoted string
     *
     * @param string $value The value to parse
     *
     * @return string The parsed string
     */
    private function parseUnquotedString($value)
    {
        if ($value === "") {
            return null;
        }

        return $this->variable_parser->parse($value);
    }

    /**
     * Parses a .env bool
     *
     * @param string $value The value to parse
     *
     * @return bool The parsed bool
     */
    private function parseBool($value)
    {
        $value = strtolower($value);

        return $value === "true" || $value === "yes";
    }

    /**
     * Parses a .env number
     *
     * @param string $value The value to parse
     *
     * @return int|float The parsed bool
     */
    private function parseNumber($value)
    {
        if (strpos($value, '.') !== false) {
            return (float) $value;
        }

        return (int) $value;
    }
}


<?php

/**
 * This file is part of the m1\env library
 *
 * (c) m1 <hello@milescroxford.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @package     m1/env
 * @version     2.0.0
 * @author      Miles Croxford <hello@milescroxford.com>
 * @copyright   Copyright (c) Miles Croxford <hello@milescroxford.com>
 * @license     http://github.com/m1/env/blob/master/LICENSE.md
 * @link        http://github.com/m1/env/blob/master/README.md Documentation
 */

namespace M1\Env\Exception;

/**
 * Env ParseException
 *
 * @since 0.1.0
 */
class ParseException extends \ErrorException
{
    /**
     * Constructs a ParseException
     *
     * @param string $message          The value to parse
     * @param string $line             The line of the value
     * @param int    $line_num         The line num of the value
     */
    public function __construct($message, $line = null, $line_num = null)
    {
        $message = $this->createMessage($message, $line, $line_num);

        parent::__construct($message);
    }

    /**
     * Constructs a ParseException message
     *
     * @param string $message          The value to parse
     * @param string $line             The line of the value
     * @param int    $line_num         The line num of the value
     *
     * @return string The exception message
     */
    private function createMessage($message, $line, $line_num)
    {
        if (!is_null($line)) {
            $message .= sprintf(" near %s", $line);
        }

        if (!is_null($line_num)) {
            $message .= sprintf(" at line %d", $line_num);
        }

        return $message;
    }
}


<?php

/**
 * This file is part of the m1\env library
 *
 * (c) m1 <hello@milescroxford.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @package     m1/env
 * @version     2.0.0
 * @author      Miles Croxford <hello@milescroxford.com>
 * @copyright   Copyright (c) Miles Croxford <hello@milescroxford.com>
 * @license     http://github.com/m1/env/blob/master/LICENSE.md
 * @link        http://github.com/m1/env/blob/master/README.md Documentation
 */

namespace M1\Env\Helper;

/**
 * The class for string helping
 *
 * @since 1.1.0
 */
class StringHelper
{
    /**
     * The bool variants available in .env
     *
     * @var array $bool_variants
     */
    private static $bool_variants = array(
        'true', 'false', 'yes', 'no'
    );

    /**
     * Returns if value is a bool
     *
     * @param string $value The value to test
     *
     * @return bool Is a value a bool
     */
    public function isBool($value)
    {
        return in_array(strtolower($value), self::$bool_variants);
    }

    /**
     * Returns if the bool is in a string
     *
     * @param string $value         The value to test
     * @param bool   $quoted_string Is the context a quoted string
     * @param int    $word_count    The amount of words in the sentence
     *
     * @return bool Is a value a bool in a string
     */
    public function isBoolInString($value, $quoted_string, $word_count)
    {
        return (is_bool($value)) && ($quoted_string || $word_count >= 2);
    }

    /**
     * Returns if value is null
     *
     * @param string $value The value to test
     *
     * @return bool Is a value null
     */
    public function isNull($value)
    {
        return $value === 'null';
    }

    /**
     * Returns if value is number
     *
     * @param string $value The value to test
     *
     * @return bool Is a value a number
     */
    public function isNumber($value)
    {
        return is_numeric($value);
    }

    /**
     * Returns if value is a string
     *
     * @param string $value The value to test
     *
     * @return bool Is a value a string
     */
    public function isString($value)
    {
        return $this->startsWith('\'', $value) || $this->startsWith('"', $value);
    }

    /**
     * Returns if variable value is a clone, e.g. BOOL = $(BOOL_1)
     *
     * @param string $value         The value to test
     * @param array  $matches       The matches of the variables
     * @param bool   $quoted_string If the value is in a quoted string
     *
     * @return bool Is a value null
     */
    public function isVariableClone($value, $matches, $quoted_string)
    {
        return (count($matches[0]) === 1) && $value == $matches[0][0] && !$quoted_string;
    }

    /**
     * Returns if value starts with a value
     *
     * @param string $string The value to search for
     * @param string $line   The line to test
     *
     * @return bool Returns if the line starts with value
     */
    public function startsWith($string, $line)
    {
        return $string === "" || strrpos($line, $string, -strlen($line)) !== false;
    }

    /**
     * Returns if value starts with a number
     *
     * @param string $line   The line to test
     *
     * @return bool Returns if the line starts with a number
     */
    public function startsWithNumber($line)
    {
        return is_numeric(substr($line, 0, 1));
    }

    /**
     * Strips comments from a value
     *
     * @param string $value The value to strip comments from
     *
     * @return string value
     */
    public function stripComments($value)
    {
        $value = explode(" #", $value, 2);
        return trim($value[0]);
    }
}


<?php

/**
 * This file is part of the m1\env library
 *
 * (c) m1 <hello@milescroxford.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @package     m1/env
 * @version     2.0.0
 * @author      Miles Croxford <hello@milescroxford.com>
 * @copyright   Copyright (c) Miles Croxford <hello@milescroxford.com>
 * @license     http://github.com/m1/env/blob/master/LICENSE.md
 * @link        http://github.com/m1/env/blob/master/README.md Documentation
 */

namespace M1\Env\Parser;

use M1\Env\Exception\ParseException;

/**
 * The value parser for Env
 *
 * @since 1.1.0
 */
class VariableParser extends AbstractParser
{
    /**
     * The regex to get variables '$(VARIABLE)' in .env
     * Unescaped: ${(.*?)}
     *
     * @var string REGEX_ENV_VARIABLE
     */
    const REGEX_ENV_VARIABLE = '\\${(.*?)}';

    /**
     * The symbol for the assign default value parameter expansion
     *
     * @var string SYMBOL_ASSIGN_DEFAULT_VALUE
     */
    const SYMBOL_ASSIGN_DEFAULT_VALUE = '=';

    /**
     * The symbol for the default value parameter expansion
     *
     * @var string SYMBOL_DEFAULT_VALUE
     */
    const SYMBOL_DEFAULT_VALUE = '-';

    /**
     * Variables context
     *
     * @var array
     */
    private $context;

    /**
     * {@inheritdoc}
     *
     * @param \M1\Env\Parser $parser The parent parser
     * @param array $context Variables context
     */
    public function __construct($parser, array $context = array())
    {
        parent::__construct($parser);

        $this->context = $context;
    }

    /**
     * Parses a .env variable
     *
     * @param string $value         The value to parse
     * @param bool   $quoted_string Is the value in a quoted string
     *
     * @return string The parsed value
     */
    public function parse($value, $quoted_string = false)
    {
        $matches = $this->fetchVariableMatches($value);

        if (is_array($matches)) {
            if ($this->parser->string_helper->isVariableClone($value, $matches, $quoted_string)) {
                return $this->fetchVariable($value, $matches[1][0], $matches, $quoted_string);
            }

            $value = $this->doReplacements($value, $matches, $quoted_string);
        }

        return $value;
    }

    /**
     * Get variable matches inside a string
     *
     * @param string $value The value to parse
     *
     * @return array The variable matches
     */
    private function fetchVariableMatches($value)
    {
        preg_match_all('/' . self::REGEX_ENV_VARIABLE . '/', $value, $matches);

        if (!is_array($matches) || !isset($matches[0]) || empty($matches[0])) {
            return false;
        }

        return $matches;
    }

    /**
     * Parses a .env variable
     *
     * @param string $value The value to parse
     * @param string $variable_name The variable name to get
     * @param array $matches The matches of the variables
     * @param bool $quoted_string Is the value in a quoted string
     *
     * @return string The parsed value
     * @throws \M1\Env\Exception\ParseException If the variable can not be found
     */
    private function fetchVariable($value, $variable_name, $matches, $quoted_string)
    {
        if ($this->hasParameterExpansion($variable_name)) {
            $replacement = $this->fetchParameterExpansion($variable_name);
        } elseif ($this->hasVariable($variable_name)) {
            $replacement = $this->getVariable($variable_name);
        } else {
            throw new ParseException(
                sprintf('Variable has not been defined: %s', $variable_name),
                $value,
                $this->parser->line_num
            );
        }

        if ($this->parser->string_helper->isBoolInString($replacement, $quoted_string, count($matches[0]))) {
            $replacement = ($replacement) ? 'true' : 'false';
        }

        return $replacement;
    }

    /**
     * Checks to see if the variable has a parameter expansion
     *
     * @param string $variable The variable to check
     *
     * @return bool Does the variable have a parameter expansion
     */
    private function hasParameterExpansion($variable)
    {
        if ((strpos($variable, self::SYMBOL_DEFAULT_VALUE) !== false) ||
            (strpos($variable, self::SYMBOL_ASSIGN_DEFAULT_VALUE) !== false)
        ) {
            return true;
        }

        return false;
    }

    /**
     * Fetches and sets the parameter expansion
     *
     * @param string $variable_name The parameter expansion inside this to fetch
     *
     * @return string The parsed value
     */
    private function fetchParameterExpansion($variable_name)
    {
        $parameter_type = $this->fetchParameterExpansionType($variable_name);

        list($parameter_symbol, $empty_flag) = $this->fetchParameterExpansionSymbol($variable_name, $parameter_type);
        list($variable, $default) = $this->splitVariableDefault($variable_name, $parameter_symbol);

        $value = $this->getVariable($variable);

        return $this->parseVariableParameter(
            $variable,
            $default,
            $this->hasVariable($variable),
            $empty_flag && empty($value),
            $parameter_type
        );
    }

    /**
     * Fetches the parameter expansion type
     *
     * @param string $variable_name The parameter expansion type to get from this
     *
     * @return string The parameter expansion type
     */
    private function fetchParameterExpansionType($variable_name)
    {
        if (strpos($variable_name, self::SYMBOL_ASSIGN_DEFAULT_VALUE) !== false) {
            return 'assign_default_value';
        }

        return 'default_value'; // self::DEFAULT_VALUE_SYMBOL
    }

    /**
     * Fetches the parameter type symbol
     *
     * @param string $variable_name The variable
     * @param string $type          The type of parameter expansion
     *
     * @return array The symbol and if there is a empty check
     */
    private function fetchParameterExpansionSymbol($variable_name, $type)
    {
        $class = new \ReflectionClass($this);
        $symbol = $class->getConstant('SYMBOL_'.strtoupper($type));
        $pos = strpos($variable_name, $symbol);

        $check_empty = substr($variable_name, ($pos - 1), 1) === ":";

        if ($check_empty) {
            $symbol = sprintf(":%s", $symbol);
        }

        return array($symbol, $check_empty);
    }

    /**
     * Splits the parameter expansion into variable and default
     *
     * @param string $variable_name    The variable name to split
     * @param string $parameter_symbol The parameter expansion symbol
     *
     * @throws \M1\Env\Exception\ParseException If the parameter expansion if not valid syntax
     *
     * @return array The split variable and default value
     */
    private function splitVariableDefault($variable_name, $parameter_symbol)
    {
        $variable_default = explode($parameter_symbol, $variable_name, 2);

        if (count($variable_default) !== 2 || empty($variable_default[1])) {
            throw new ParseException(
                'You must have valid parameter expansion syntax, eg. ${parameter:=word}',
                $variable_name,
                $this->parser->line_num
            );
        }

        return array(trim($variable_default[0]), trim($variable_default[1]));
    }

    /**
     * Parses and sets the variable and default if needed
     *
     * @param string $variable The variable to parse
     * @param string $default  The default value
     * @param bool   $exists   Does the variable exist
     * @param bool   $empty    Is there the variable empty if exists and the empty flag is set
     * @param string $type     The type of parameter expansion
     *
     * @return string The parsed value
     */
    private function parseVariableParameter($variable, $default, $exists, $empty, $type)
    {
        if ($exists && !$empty) {
            return $this->getVariable($variable);
        }

        return $this->assignVariableParameterDefault($variable, $default, $empty, $type);
    }

    /**
     * Parses and sets the variable parameter to default
     *
     * @param string $variable The variable to parse
     * @param string $default  The default value
     * @param bool   $empty    Is there the variable empty if exists and the empty flag is set
     * @param string $type     The type of parameter expansion
     *
     * @return string The parsed default value
     */
    private function assignVariableParameterDefault($variable, $default, $empty, $type)
    {
        $default = $this->parser->value_parser->parse($default);

        if ($type === "assign_default_value" && $empty) {
            $this->parser->lines[$variable] = $default;
        }

        return $default;
    }

    /**
     * Checks to see if a variable exists
     *
     * @param string $variable The variable name to get
     *
     * @return bool
     */
    private function hasVariable($variable)
    {
        if (array_key_exists($variable, $this->parser->lines)) {
            return true;
        }

        if (array_key_exists($variable, $this->context)) {
            return true;
        }

        return false;
    }

    /**
     * Get variable value
     *
     * @param string $variable
     *
     * @return mixed
     */
    private function getVariable($variable)
    {
        if (array_key_exists($variable, $this->parser->lines)) {
            return $this->parser->lines[$variable];
        }

        if (array_key_exists($variable, $this->context)) {
            return $this->context[$variable];
        }

        return null;
    }

    /**
     * Do the variable replacements
     *
     * @param string $value         The value to throw an error with if doesn't exist
     * @param array  $matches       The matches of the variables
     * @param bool   $quoted_string Is the value in a quoted string
     *
     * @return string The parsed value
     */
    public function doReplacements($value, $matches, $quoted_string)
    {
        $replacements = array();

        for ($i = 0; $i <= (count($matches[0]) - 1); $i++) {
            $replacement = $this->fetchVariable($value, $matches[1][$i], $matches, $quoted_string);
            $replacements[$matches[0][$i]] = $replacement;
        }

        if (!empty($replacements)) {
            $value = strtr($value, $replacements);
        }

        return $value;
    }
}


<?php

/**
 * This file is part of the m1\env library
 *
 * (c) m1 <hello@milescroxford.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @package     m1/env
 * @version     2.0.0
 * @author      Miles Croxford <hello@milescroxford.com>
 * @copyright   Copyright (c) Miles Croxford <hello@milescroxford.com>
 * @license     http://github.com/m1/env/blob/master/LICENSE.md
 * @link        http://github.com/m1/env/blob/master/README.md Documentation
 */

namespace M1\Env\Parser;

use M1\Env\Exception\ParseException;

/**
 * The key parser for Env
 *
 * @since 0.2.0
 */
class KeyParser extends AbstractParser
{
    /**
     * Parses a .env key
     *
     * @param string $key The key string
     *
     * @throws \M1\Env\Exception\ParseException If key contains a character that isn't alphanumeric or a _
     *
     * @return string|false The parsed key, or false if the key is a comment
     */
    public function parse($key)
    {
        $key = trim($key);

        if ($this->parser->string_helper->startsWith('#', $key)) {
            return false;
        }

        if (!ctype_alnum(str_replace('_', '', $key)) || $this->parser->string_helper->startsWithNumber($key)) {
            throw new ParseException(
                sprintf('Key can only contain alphanumeric and underscores and can not start with a number: %s', $key),
                $key,
                $this->parser->line_num
            );
        }

        return $key;
    }
}


<?php

/**
 * This file is part of the m1\env library
 *
 * (c) m1 <hello@milescroxford.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @package     m1/env
 * @version     2.0.0
 * @author      Miles Croxford <hello@milescroxford.com>
 * @copyright   Copyright (c) Miles Croxford <hello@milescroxford.com>
 * @license     http://github.com/m1/env/blob/master/LICENSE.md
 * @link        http://github.com/m1/env/blob/master/README.md Documentation
 */

namespace M1\Env;

use M1\Env\Exception\ParseException;
use M1\Env\Helper\StringHelper;
use M1\Env\Parser\ValueParser;
use M1\Env\Parser\KeyParser;

/**
 * The .env parser
 *
 * @since 0.1.0
 */
class Parser
{
    /**
     * The Env key parser
     *
     * @var \M1\Env\Parser\KeyParser $key_parser
     */
    private $key_parser;

    /**
     * The line num of the current value
     *
     * @var int $line_num
     */
    public $line_num;

    /**
     * The current parsed values/lines
     *
     * @var array $lines
     */
    public $lines = array();

    /**
     * The String helper class
     *
     * @var \M1\Env\Helper\StringHelper $string_helper
     */
    public $string_helper;

    /**
     * The Env value parser
     *
     * @var \M1\Env\Parser\ValueParser $value_parser
     */
    public $value_parser;

    /**
     * The parser constructor
     *
     * @param string $content The content to parse
     * @param array $context Variables context
     */
    public function __construct($content, array $context = array())
    {
        $this->key_parser = new KeyParser($this);
        $this->value_parser = new ValueParser($this, $context);
        $this->string_helper = new StringHelper();

        $this->doParse($content);
    }

    /**
     * Parses the .env and returns the contents statically
     *
     * @param string $content The content to parse
     * @param array $context Variables context
     *
     * @return array The .env contents
     */
    public static function parse($content, array $context = array())
    {
        $parser = new Parser($content, $context);

        return $parser->getContent();
    }

    /**
     * Opens the .env, parses it then returns the contents
     *
     * @param string $content The content to parse
     *
     * @return array The .env contents
     */
    protected function doParse($content)
    {
        $raw_lines = array_filter($this->makeLines($content), 'strlen');

        if (empty($raw_lines)) {
            return;
        }

        return $this->parseContent($raw_lines);
    }

    /**
     * Splits the string into an array
     *
     * @param string $content The string content to split
     *
     * @return array The array of lines to parse
     */
    private function makeLines($content)
    {
        return explode("\n", str_replace(array("\r\n", "\n\r", "\r"), "\n", $content));
    }

    /**
     * Parses the .env line by line
     *
     * @param array $raw_lines The raw content of the file
     *
     * @throws \M1\Env\Exception\ParseException If the file does not have a key=value structure
     *
     * @return array The .env contents
     */
    private function parseContent(array $raw_lines)
    {
        $this->lines = array();
        $this->line_num = 0;

        foreach ($raw_lines as $raw_line) {
            $this->line_num++;

            if ($this->string_helper->startsWith('#', $raw_line) || !$raw_line) {
                continue;
            }

            $this->parseLine($raw_line);
        }

        return $this->lines;
    }

    /**
     * Parses a line of the .env
     *
     * @param string $raw_line The raw content of the line
     *
     * @return array The parsed lines
     */
    private function parseLine($raw_line)
    {
        $raw_line = $this->parseExport($raw_line);

        list($key, $value) = $this->parseKeyValue($raw_line);

        $key = $this->key_parser->parse($key);

        if (!is_string($key)) {
            return;
        }

        $this->lines[$key] = $this->value_parser->parse($value);
    }

    /**
     * Parses a export line of the .env
     *
     * @param string $raw_line The raw content of the line
     *
     * @throws \M1\Env\Exception\ParseException If the file does not have a key=value structure
     *
     * @return string The parsed line
     */
    private function parseExport($raw_line)
    {
        $line = trim($raw_line);

        if ($this->string_helper->startsWith("export", $line)) {
            $export_line = explode("export", $raw_line, 2);

            if (count($export_line) !== 2 || empty($export_line[1])) {
                throw new ParseException(
                    'You must have a export key = value',
                    $raw_line,
                    $this->line_num
                );
            }

            $line = trim($export_line[1]);
        }

        return $line;
    }

    /**
     * Gets the key = value items from the line
     *
     * @param string $raw_line The raw content of the line
     *
     * @throws \M1\Env\Exception\ParseException If the line does not have a key=value structure
     *
     * @return array The parsed lines
     */
    private function parseKeyValue($raw_line)
    {
        $key_value = explode("=", $raw_line, 2);

        if (count($key_value) !== 2) {
            throw new ParseException(
                'You must have a key = value',
                $raw_line,
                $this->line_num
            );
        }

        return $key_value;
    }

    /**
     * Returns the contents of the .env
     *
     * @return array The .env contents
     */
    public function getContent($keyName = null)
    {
		if (!is_null($keyName)) {
			return (array_key_exists($keyName, $this->lines)) ? $this->lines[$keyName] : null;
		}
        return $this->lines;
    }
}


<?php

namespace Zenaton;

use Zenaton\Exceptions\AgentException;
use Zenaton\Exceptions\AgentNotListeningException;
use Zenaton\Exceptions\AgentUpdateRequiredException;
use Zenaton\Exceptions\ApiException;
use Zenaton\Exceptions\InvalidArgumentException;
use Zenaton\Interfaces\EventInterface;
use Zenaton\Interfaces\TaskInterface;
use Zenaton\Interfaces\WorkflowInterface;
use Zenaton\Services\Http;
use Zenaton\Services\Properties;
use Zenaton\Services\Serializer;
use Zenaton\Traits\SingletonTrait;
use Zenaton\Workflows\Version as VersionedWorkflow; // Alias is required because of a bug in PHP 5.6 namespace shadowing. See https://bugs.php.net/bug.php?id=66862.

class Client
{
    use SingletonTrait;

    const ZENATON_API_URL = 'https://api.zenaton.com/v1';
    const ZENATON_WORKER_URL = 'http://localhost';
    const DEFAULT_WORKER_PORT = 4001;
    const WORKER_API_VERSION = 'v_newton';

    const MAX_ID_SIZE = 256;

    const APP_ENV = 'app_env';
    const APP_ID = 'app_id';
    const API_TOKEN = 'api_token';

    const ATTR_ID = 'custom_id';
    const ATTR_NAME = 'name';
    const ATTR_CANONICAL = 'canonical_name';
    const ATTR_DATA = 'data';
    const ATTR_PROG = 'programming_language';
    const ATTR_MODE = 'mode';
    const ATTR_MAX_PROCESSING_TIME = 'maxProcessingTime';

    const PROG = 'PHP';

    const EVENT_INPUT = 'event_input';
    const EVENT_NAME = 'event_name';

    const WORKFLOW_KILL = 'kill';
    const WORKFLOW_PAUSE = 'pause';
    const WORKFLOW_RUN = 'run';

    protected $appId;
    protected $apiToken;
    protected $appEnv;
    /** @var Http */
    protected $http;
    /** @var Serializer */
    protected $serializer;
    /** @var Properties */
    protected $properties;

    public static function init($appId, $apiToken, $appEnv)
    {
        Client::getInstance()
          ->setAppId($appId)
          ->setApiToken($apiToken)
          ->setAppEnv($appEnv);
    }

    public function construct()
    {
        $this->http = new Http();
        $this->serializer = new Serializer();
        $this->properties = new Properties();
    }

    public function setAppId($appId)
    {
        $this->appId = $appId;

        return $this;
    }

    public function setApiToken($apiToken)
    {
        $this->apiToken = $apiToken;

        return $this;
    }

    public function setAppEnv($appEnv)
    {
        $this->appEnv = $appEnv;

        return $this;
    }

    /**
     * @param string       $ressources
     * @param array|string $params
     *
     * @return string
     *
     * @internal Used by the Zenaton agent. Should not be called by user code.
     */
    public function getWorkerUrl($ressources = '', $params = [])
    {
        if (is_array($params)) {
            return $this->getWorkerUrlV2($ressources, $params);
        }

        $url = (getenv('ZENATON_WORKER_URL') ?: self::ZENATON_WORKER_URL)
            .':'.(getenv('ZENATON_WORKER_PORT') ?: self::DEFAULT_WORKER_PORT)
            .'/api/'.self::WORKER_API_VERSION
            .'/'.$ressources.'?';

        return $this->addAppEnv($url, $params);
    }

    /**
     * @param string       $ressources
     * @param array|string $params
     *
     * @return string
     *
     * @internal Used by the Zenaton agent. Should not be called by user code.
     */
    public function getWebsiteUrl($ressources = '', $params = [])
    {
        if (is_array($params)) {
            return $this->getWebsiteUrlV2($ressources, $params);
        }

        $url = (getenv('ZENATON_API_URL') ?: self::ZENATON_API_URL)
            .'/'.$ressources.'?'
            .self::API_TOKEN.'='.$this->apiToken.'&';

        return $this->addAppEnv($url, $params);
    }

    /**
     * Start a single task.
     *
     * @throws AgentNotListeningException   If the agent is not listening to the application
     * @throws AgentUpdateRequiredException If the agent does not have the minimum required version
     * @throws AgentException               For any other error coming from the agent
     */
    public function startTask(TaskInterface $task)
    {
        $response = $this->http->post($this->getTaskWorkerUrl(), [
            self::ATTR_PROG => self::PROG,
            self::ATTR_NAME => get_class($task),
            self::ATTR_DATA => $this->serializer->encode($this->properties->getPropertiesFromObject($task)),
            self::ATTR_MAX_PROCESSING_TIME => method_exists($task, 'getMaxProcessingTime') ? $task->getMaxProcessingTime() : null,
        ]);

        if ($response->hasErrors()) {
            if (strpos($response->body->error, 'Your worker does not listen') !== false) {
                throw new AgentNotListeningException($this->appId, $this->appEnv);
            }

            if (strpos($response->body->error, 'Unknown version') !== false) {
                throw new AgentUpdateRequiredException('>=0.6.0');
            }

            throw new AgentException($response->body->error);
        }
    }

    /**
     * Start a workflow instance.
     *
     * @param WorkflowInterface $flow Workflow to start
     */
    public function startWorkflow(WorkflowInterface $flow)
    {
        $canonical = null;
        // if $flow is a versionned workflow
        if ($flow instanceof VersionedWorkflow) {
            // store canonical name
            $canonical = get_class($flow);
            // replace by true current implementation
            $flow = $flow->getCurrentImplementation();
        }

        // custom id management
        $customId = null;
        if (method_exists($flow, 'getId')) {
            $customId = $flow->getId();
            if (!is_string($customId) && !is_int($customId)) {
                throw new InvalidArgumentException('Provided Id must be a string or an integer');
            }
            // at the end, it's a string
            $customId = (string) $customId;
            // should be not more than 256 bytes;
            if (strlen($customId) > self::MAX_ID_SIZE) {
                throw new InvalidArgumentException('Provided Id must not exceed '.self::MAX_ID_SIZE.' bytes');
            }
        }

        // start workflow
        $this->http->post($this->getInstanceWorkerUrl(), [
            self::ATTR_PROG => self::PROG,
            self::ATTR_CANONICAL => $canonical,
            self::ATTR_NAME => get_class($flow),
            self::ATTR_DATA => $this->serializer->encode($this->properties->getPropertiesFromObject($flow)),
            self::ATTR_ID => $customId,
        ]);
    }

    /**
     * Kill a workflow instance.
     *
     * @param string $workflowName Workflow class name
     * @param string $customId     Provided custom id
     */
    public function killWorkflow($workflowName, $customId)
    {
        $this->updateInstance($workflowName, $customId, self::WORKFLOW_KILL);
    }

    /**
     * Pause a workflow instance.
     *
     * @param string $workflowName Workflow class name
     * @param string $customId     Provided custom id
     */
    public function pauseWorkflow($workflowName, $customId)
    {
        $this->updateInstance($workflowName, $customId, self::WORKFLOW_PAUSE);
    }

    /**
     * Resume a workflow instance.
     *
     * @param string $workflowName Workflow class name
     * @param string $customId     Provided custom id
     */
    public function resumeWorkflow($workflowName, $customId)
    {
        $this->updateInstance($workflowName, $customId, self::WORKFLOW_RUN);
    }

    /**
     * Find a workflow instance.
     *
     * @param string $workflowName Workflow class name
     * @param string $customId     Provided custom id
     *
     * @return null|WorkflowInterface
     */
    public function findWorkflow($workflowName, $customId)
    {
        $params = [
            static::ATTR_ID => $customId,
            static::ATTR_NAME => $workflowName,
            static::ATTR_PROG => static::PROG,
        ];

        $response = $this->http->get($this->getInstanceWebsiteUrl($params));
        if ($response->code === 404) {
            return null;
        }

        if ($response->hasErrors()) {
            throw ApiException::unexpectedStatusCode($response->code);
        }

        return $this->properties->getObjectFromNameAndProperties($response->body->data->name, $this->serializer->decode($response->body->data->properties));
    }

    /**
     * Send an event to a workflow instance.
     *
     * @param string         $workflowName Workflow class name
     * @param string         $customId     Provided custom id
     * @param EventInterface $event        Event to send
     */
    public function sendEvent($workflowName, $customId, EventInterface $event)
    {
        $url = $this->getSendEventURL();

        $body = [
            self::ATTR_PROG => self::PROG,
            self::ATTR_NAME => $workflowName,
            self::ATTR_ID => $customId,
            self::EVENT_NAME => get_class($event),
            self::EVENT_INPUT => $this->serializer->encode($this->properties->getPropertiesFromObject($event)),
        ];

        $this->http->post($url, $body);
    }

    protected function updateInstance($workflowName, $customId, $mode)
    {
        $params = [
            self::ATTR_ID => $customId,
        ];

        return $this->http->put($this->getInstanceWorkerUrl($params), [
            self::ATTR_PROG => self::PROG,
            self::ATTR_NAME => $workflowName,
            self::ATTR_MODE => $mode,
        ]);
    }

    private function getWorkerUrlV2($resource = '', array $params = [])
    {
        $url = sprintf(
            '%s:%s/api/%s/%s',
            getenv('ZENATON_WORKER_URL') ?: static::ZENATON_WORKER_URL,
            getenv('ZENATON_WORKER_PORT') ?: static::DEFAULT_WORKER_PORT,
            static::WORKER_API_VERSION,
            $resource
        );

        return $this->addQueryParams($url, $params);
    }

    private function getWebsiteUrlV2($ressources = '', array $params = [])
    {
        $url = sprintf(
            '%s/%s',
            getenv('ZENATON_API_URL') ?: self::ZENATON_API_URL,
            $ressources
        );

        $params[static::API_TOKEN] = $this->apiToken;

        return $this->addQueryParams($url, $params);
    }

    protected function getInstanceWebsiteUrl($params)
    {
        return $this->getWebsiteUrl('instances', $params);
    }

    protected function getInstanceWorkerUrl($params = [])
    {
        return $this->getWorkerUrl('instances', $params);
    }

    protected function getTaskWorkerUrl($params = [])
    {
        return $this->getWorkerUrl('tasks', $params);
    }

    protected function getSendEventURL()
    {
        return $this->getWorkerUrl('events');
    }

    /**
     * @param string $url
     * @param string $params
     *
     * @return string
     *
     * @deprecated 0.3.0 Does not properly encode url parameters.
     */
    protected function addAppEnv($url, $params = '')
    {
        static $triggeredDeprecation = false;
        if (!$triggeredDeprecation) {
            trigger_error('You are running a Zenaton agent version <= 0.4.5 which is using deprecated code. Please consider upgrading your agent.', E_USER_DEPRECATED);
        }
        $triggeredDeprecation = true;

        // when called from worker, APP_ENV and APP_ID is not defined
        return $url
            .($this->appEnv ? self::APP_ENV.'='.$this->appEnv.'&' : '')
            .($this->appId ? self::APP_ID.'='.$this->appId.'&' : '')
            .($params ? $params.'&' : '');
    }

    protected function addQueryParams($url, array $params = [])
    {
        // When called from worker, APP_ENV and APP_ID are not defined
        if ($this->appId) {
            $params[static::APP_ID] = $this->appId;
        }
        if ($this->appEnv) {
            $params[static::APP_ENV] = $this->appEnv;
        }

        return sprintf('%s?%s', $url, http_build_query($params));
    }
}


<?php

namespace Zenaton\Traits;

use Cake\Chronos\Chronos;
use Zenaton\Exceptions\InternalZenatonException;

trait WithDuration
{
    protected static $_timezone;
    protected $_buffer;

    /**
     * Define timezone used when setting date / time.
     *
     * @param string $timezone A PHP timezone identifier (@see http://php.net/manual/en/timezones.php)
     */
    public static function timezone($timezone)
    {
        if (!in_array($timezone, DateTimeZone::listIdentifiers())) {
            throw new ExternalZenatonException('Unknown timezone');
        }

        self::$_timezone = $timezone;
    }

    public function _getDuration()
    {
        if (null === $this->_buffer) {
            return;
        }

        list($now, $then) = $this->_initNowThen();

        foreach ($this->_buffer as $call) {
            $then = $this->_applyDuration($call[0], $call[1], $then);
        }

        return $now->diffInSeconds($then);
    }

    public function seconds($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function minutes($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function hours($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function days($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function weeks($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function months($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function years($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    protected function _initNowThen()
    {
        // get setted or current time zone
        $tz = self::$_timezone ?: date_default_timezone_get();

        $now = Chronos::now($tz);
        $then = $now->copy();

        return [$now, $then];
    }

    protected function _push($data)
    {
        if (null === $this->_buffer) {
            $this->_buffer = [];
        }

        $this->_buffer[] = $data;
    }

    protected function _applyDuration($method, $value, $then)
    {
        switch ($method) {
            case 'seconds':
                return $then->addSeconds($value);
            case 'minutes':
                return $then->addMinutes($value);
            case 'hours':
                return $then->addHours($value);
            case 'days':
                return $then->addDays($value);
            case 'weeks':
                return $then->addWeeks($value);
            case 'months':
                return $then->addMonths($value);
            case 'years':
                return $then->addYears($value);
            default:
                throw new InternalZenatonException('Unknown methods '.$method);
        }
    }
}


<?php

namespace Zenaton\Traits;

use Cake\Chronos\ChronosInterface;
use Zenaton\Exceptions\ExternalZenatonException;
use Zenaton\Exceptions\InternalZenatonException;

trait WithTimestamp
{
    use WithDuration;

    public static $_MODE_AT = 'AT';
    public static $_MODE_WEEK_DAY = 'WEEK_DAY';
    public static $_MODE_MONTH_DAY = 'MONTH_DAY';
    public static $_MODE_TIMESTAMP = 'TIMESTAMP';

    protected $_mode;

    /**
     * Return Wait timestamp or duration depending on methods used.
     *
     * @return array [null, duration] or [timestamp, null] or [null, null]
     */
    public function _getTimestampOrDuration()
    {
        if (null === $this->_buffer) {
            return [null, null];
        }

        list($now, $then) = $this->_initNowThen();

        $this->_mode = null;
        // apply buffered methods
        foreach ($this->_buffer as $call) {
            $then = $this->_apply($call[0], $call[1], $now, $then);
        }
        // has user used a method by timestamp?
        $isTimestamp = null !== $this->_mode;
        //return
        if ($isTimestamp) {
            return [$then->timestamp, null];
        }

        return [null, $now->diffInSeconds($then)];
    }

    /**
     * Defined by timestamp (timezone independant).
     *
     * @param int $value
     *
     * @return self
     */
    public function timestamp($value)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function at($value)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function dayOfMonth($value)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function monday($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function tuesday($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function wednesday($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function thursday($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function friday($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function saturday($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    public function sunday($value = 1)
    {
        $this->_push([__FUNCTION__, $value]);

        return $this;
    }

    protected function _timestamp($timestamp, $then)
    {
        $this->_setMode(self::$_MODE_TIMESTAMP);

        return $then->timestamp($timestamp);
    }

    protected function _at($time, $now, $then)
    {
        $this->_setMode(self::$_MODE_AT);

        $then = $then->setTimeFromTimeString($time);

        // if time is past, target next day/week/month
        if ($now->gt($then)) {
            switch ($this->_mode) {
                case self::$_MODE_AT:
                    $then = $then->addDay();
                    break;
                case self::$_MODE_WEEK_DAY:
                    $then = $then->addWeek();
                    break;
                case self::$_MODE_MONTH_DAY:
                    $then = $then->addMonth();
                    break;
                default:
                    throw new InternalZenatonException('Unknown mode: '.$this->_mode);
            }
        }

        return $then;
    }

    protected function _dayOfMonth($day, $now, $then)
    {
        $this->_setMode(self::$_MODE_MONTH_DAY);

        $then = $then->day($day);

        if ($now->gte($then) && !$this->sameMonthDayLater($now, $day)) {
            $then = $then->addMonth();
        }

        return $then;
    }

    protected function _weekDay($n, $day, $now, $then)
    {
        $this->_setMode(self::$_MODE_WEEK_DAY);

        if ($this->sameWeekDayLater($now, $day)) {
            --$n;
        }

        list($h, $m, $s) = [$then->hour, $then->minute, $then->second];

        for ($i = 0; $i < $n; ++$i) {
            $then = $then->next($day);
        }

        return $then->setTime($h, $m, $s);
    }

    protected function _apply($method, $value, $now, $then)
    {
        switch ($method) {
            case 'timestamp':
                return $this->_timestamp($value, $then);
            case 'at':
                return $this->_at($value, $now, $then);
            case 'dayOfMonth':
                return $this->_dayOfMonth($value, $now, $then);
            case 'monday':
                return $this->_weekDay($value, ChronosInterface::MONDAY, $now, $then);
            case 'tuesday':
                return $this->_weekDay($value, ChronosInterface::TUESDAY, $now, $then);
            case 'wednesday':
                return $this->_weekDay($value, ChronosInterface::WEDNESDAY, $now, $then);
            case 'thursday':
                return $this->_weekDay($value, ChronosInterface::THURSDAY, $now, $then);
            case 'friday':
                return $this->_weekDay($value, ChronosInterface::FRIDAY, $now, $then);
            case 'saturday':
                return $this->_weekDay($value, ChronosInterface::SATURDAY, $now, $then);
            case 'sunday':
                return $this->_weekDay($value, ChronosInterface::SUNDAY, $now, $then);
            default:
                return $this->_applyDuration($method, $value, $then);
        }
    }

    protected function _setMode($mode)
    {
        // can not apply twice the same method
        if ($mode === $this->_mode) {
            throw new ExternalZenatonException('Incompatible definition in Wait methods');
        }
        // timestamp can only be used alone
        if ((null !== $this->_mode && self::$_MODE_TIMESTAMP === $mode) || (self::$_MODE_TIMESTAMP === $this->_mode)) {
            throw new ExternalZenatonException('Incompatible definition in Wait methods');
        }
        // other mode takes precedence to MODE_AT
        if (null === $this->_mode || self::$_MODE_AT === $this->_mode) {
            $this->_mode = $mode;
        }
    }

    /**
     * @param \Cake\Chronos\Chronos $now
     * @param int                   $day
     *
     * @return bool
     */
    protected function sameMonthDayLater($now, $day)
    {
        return $this->sameMonthDay($now, $day) && $this->later($now);
    }

    /**
     * @param \Cake\Chronos\Chronos $now
     * @param int                   $day
     *
     * @return bool
     */
    protected function sameWeekDayLater($now, $day)
    {
        return $this->sameWeekDay($now, $day) && $this->later($now);
    }

    /**
     * @param \Cake\Chronos\Chronos $now
     * @param int                   $day
     *
     * @return bool
     */
    protected function sameMonthDay($now, $day)
    {
        return $now->day === $day;
    }

    /**
     * @param \Cake\Chronos\Chronos $now
     * @param int                   $day
     *
     * @return bool
     */
    protected function sameWeekDay($now, $day)
    {
        return $now->dayOfWeek === $day;
    }

    /**
     * @param \Cake\Chronos\Chronos $now
     *
     * @return bool
     */
    protected function later($now)
    {
        // Find in the buffer if the method `at` was used to set the time
        $time = array_reduce($this->_buffer, function ($carry, $item) {
            if ($item[0] === 'at') {
                return $item[1];
            }

            return $carry;
        }, null);

        if ($time === null) {
            return false;
        }

        $other = $now->setTimeFromTimeString($time);

        return $now->diffInHours($other, false) > 0;
    }
}


<?php

namespace Zenaton\Services;

use ReflectionClass;
use UnexpectedValueException;

class Properties
{
    public function getNewInstanceWithoutProperties($name)
    {
        return (new ReflectionClass($name))->newInstanceWithoutConstructor();
    }

    public function getObjectFromNameAndProperties($name, $properties, $class = null)
    {
        $o = $this->getNewInstanceWithoutProperties($name);

        // object must be of $class type
        $this->checkClass($o, $class);

        // fill empty object with properties
        return $this->setPropertiesToObject($o, $properties);
    }

    public function getPropertiesFromObject($o)
    {
        // why cloning ? https://divinglaravel.com/queue-system/preparing-jobs-for-queue
        $reflectionObject = new \ReflectionObject($o);
        $clone = $reflectionObject->isCloneable() ? clone $o : $o;

        // apply __sleep before serialization - should return an array of properties to be serialized
        if (method_exists($clone, '__sleep')) {
            $valid = $clone->__sleep() ?: [];
        }

        $properties = [];

        // declared variables
        foreach ($this->getClassProperties($clone) as $property) {
            // the PHP serialize method doesn't take static variables so we respect this philosophy
            if (!$property->isStatic() && (!isset($valid) || in_array($property->getName(), $valid))) {
                $property->setAccessible(true);
                $value = $property->getValue($clone);
                $properties[$property->getName()] = $value;
            }
        }

        // non-declared public variables. Only if object does not implement \Traversable, because otherwise it can iterate things that are not properties
        if (!($clone instanceof \Traversable)) {
            foreach ($clone as $key => $value) {
                if (!isset($properties[$key]) && (!isset($valid) || in_array($key, $valid))) {
                    $properties[$key] = $value;
                }
            }
        }

        return $properties;
    }

    public function setPropertiesToObject($o, $properties)
    {
        // declared variables
        $keys = [];
        foreach ($this->getClassProperties($o) as $property) {
            // the PHP serialize method doesn't take static variables so we respect this philosophy
            if (!$property->isStatic()) {
                $property->setAccessible(true);
                $key = $property->getName();
                // check if $key exist in properties
                // eg. keys not in _sleep() (if any) won't be here
                if (isset($properties[$key])) {
                    $property->setValue($o, $properties[$key]);
                }
                $keys[] = $key;
            }
        }

        // non-declared variables
        foreach ($properties as $key => $value) {
            if (!in_array($key, $keys)) {
                $o->{$key} = $value;
            }
        }

        // we now have the complete object, time to wake up
        if (method_exists($o, '__wakeup')) {
            $o->__wakeup();
        }

        return $o;
    }

    protected function checkClass($o, $class)
    {
        // object must be of $class type
        if (!is_null($class) && (!is_object($o) || !$o instanceof $class)) {
            throw new UnexpectedValueException('Error - '.get_class($o).' should be an instance of '.$class);
        }
    }

    /**
     * Return properties of a class and all its inheritance hierarchy.
     *
     * @param object|string $argument Object or class name to get properties from
     * @param null|int      $filter   Optional filter, for filtering desired property types. It's configured using the `\ReflectionProperty` constants, and defaults to all property types.
     *
     * @throws \ReflectionException if the class to reflect does not exist
     *
     * @return \ReflectionProperty[]
     */
    private function getClassProperties($argument, $filter = null)
    {
        if (null === $filter) {
            $filter = \ReflectionProperty::IS_STATIC | \ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED | \ReflectionProperty::IS_PRIVATE;
        }

        $reflectionClass = new \ReflectionClass($argument);

        if ($parentClass = $reflectionClass->getParentClass()) {
            return array_merge($this->getClassProperties($parentClass->getName(), $filter), $reflectionClass->getProperties($filter));
        }

        return $reflectionClass->getProperties($filter);
    }
}