<?php
/**
 * Copyright (c) 2016 Serhii Borodai <clarifying@gmail.com>.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 *  all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 *
 *
 */

namespace App\Model;


use Monolog\Logger;
use Zend\Db\Metadata\Source\Factory;
use Zend\Db\ResultSet\HydratingResultSet;
use Zend\Db\Sql\Expression;
use Zend\Db\Sql\Predicate\Between;
use Zend\Db\Sql\Predicate\In;
use Zend\Db\Sql\Predicate\IsNotNull;
use Zend\Db\Sql\Predicate\IsNull;
use Zend\Db\Sql\Predicate\Like;
use Zend\Db\Sql\Predicate\NotBetween;
use Zend\Db\Sql\Predicate\NotIn;
use Zend\Db\Sql\Predicate\NotLike;
use Zend\Db\Sql\Predicate\Operator;
use Zend\Db\Sql\Predicate\Predicate;
use Zend\Db\Sql\Select;
use Zend\Db\Sql\Where;
use Zend\Db\TableGateway\TableGateway;
use Zend\Hydrator\DelegatingHydrator;

/**
 * Class Common
 *
 *
 * to handle complex nested models use propnameModel e.g
 * protected $propNameModel
 * on save if entity contains array or \Traversable property then model will search
 * property with name = {key + Model} and instanceof Models\Common
 * if found - then iterate over propertyValue using key as ID and value as Entity
 * to call model->save($item);
 * if value is not instanceof App\Common\Entity exception will thrown
 *
 * important: if not scalar property item count diff from DB, then absent items will be deleted from DB;
 *
 *
 *
 * @package App\Model
 */
class Common
{
    const PLACEHOLDER_OFFSET = 0;
    const PLACEHOLDER_LIMIT = 1;

    protected static $transactionLevel = 0;

    /**
     * @var TableGateway
     */
    protected $tableGateway;

    /**
     * @var DelegatingHydrator
     */
    protected $delegatingHydrator;

    /**
     * @var Logger
     */
    protected $log;

    /**
     * @var Select
     */
    private $lastQuery;

    /**
     * Common constructor.
     * @param TableGateway $tableGateway
     * @param DelegatingHydrator $delegatingHydrator
     * @param Logger $logger
     */
    public function __construct(TableGateway $tableGateway, DelegatingHydrator $delegatingHydrator, Logger $logger)
    {
        $this->tableGateway = $tableGateway;
        $this->delegatingHydrator = $delegatingHydrator;
        $this->log = $logger;

        /** @noinspection PhpParamsInspection */
        $this->metadata = Factory::createSourceFromAdapter($this->tableGateway->getAdapter());
    }

    /**
     * @param array|Closure|string|\Zend\Db\Sql\Where $where
     *
     * @return \App\Entity\Common|object
     * @throws \Exception
     */
    public function findOne($where = null)
    {
        return $this->findBy($where)->current();
    }

    /**
     * @param array $limit [Common::PLACEHOLDER_OFFSET => $offset, Common::PLACEHOLDER_LIMIT => $limit]
     * @param null $where
     * @param null $order
     * @return HydratingResultSet
     */
    public function findLimit($limit, $where = null, $order = null)
    {
        return $this->findBy($where, $limit, $order);
    }

    /**
     * @param $id
     *
     * @return \App\Entity\Common|object|null
     */
    public function findById($id)
    {
        return $this->findBy(['id' => $id])->current() ?? null;
    }

    /**
     * @param $id
     * @return null|object
     */
    public function findByIdWithHidden($id)
    {
        $result = $this->findBy([HideableInterface::COLUMN_NAME_HIDDEN => HideableInterface::ANY, 'id' => $id]);
        if (is_array($result)) {
            $result = current($result);
        } else {
            $result = $result->current() ?? null;
        }
        return $result;
    }

    /**
     * @param null $where
     * @return HydratingResultSet
     */
    public function findAll($where = null, $order = null) {
        return $this->findBy($where, null, $order);
    }

    /**
     * @param null $where
     * @param null $limit
     * @param null $order
     * @return HydratingResultSet
     * @throws \Exception
     */
    protected function findByLike($where = null, $limit = null, $order = null)
    {
        $this->hideable($where);

        try {
            $resultSet = $this->tableGateway->select(/**
             * @param Select $select
             */
                function (Select $select) use ($where, $limit, $order) {
                if ($where) {
                    if ($where['category'])
                    {
                        $identifier = 'category';
                        $like = '%"'. $where[$identifier] .'"%';
                        $select->where->like($identifier, $like);
                        unset($where[$identifier]);
                    }
                    $select->where($where);
                }
                if (is_array($limit)) {
                    $select
                        ->quantifier(new Expression('SQL_CALC_FOUND_ROWS'))
                        ->limit($limit[self::PLACEHOLDER_LIMIT])
                        ->offset($limit[self::PLACEHOLDER_OFFSET]);
                }
                if ($order) {
                    $select->order($order);
                }
                $this->lastQuery = $select;
            });
            /** @var HydratingResultSet $resultSet */
        } catch (\Exception $e) {
            $this->log->error($e->getMessage(), [
                'sql' => $this->lastQuery->getSqlString(),
                'trace' => $e->getTraceAsString()]);
        }
        if ($resultSet) {
            if (!$resultSet instanceof HydratingResultSet) {
                throw new \Exception('Correct HydratingResultSet required. Passed "' .
                    get_class($resultSet). '". Provide correct config for `' . $this->tableGateway->getTable() . '`');
            }
        }
        return $resultSet;
    }
    
    protected function findBy($where = null, $limit = null, $order = null)
    {
        $this->hideable($where);

        try {
            $resultSet = $this->tableGateway->select(/**
             * @param Select $select
             */
                function (Select $select) use ($where, $limit, $order) {
                if ($where) {
                    $select->where($where);
                }
                if (is_array($limit)) {
                    $select
                        ->quantifier(new Expression('SQL_CALC_FOUND_ROWS'))
                        ->limit($limit[self::PLACEHOLDER_LIMIT])
                        ->offset($limit[self::PLACEHOLDER_OFFSET]);
                }
                if ($order) {
                    $select->order($order);
                }
                $this->lastQuery = $select;
            });
            /** @var HydratingResultSet $resultSet */
        } catch (\Exception $e) {
            $this->log->error($e->getMessage(), [
                'sql' => $this->lastQuery->getSqlString(),
                'trace' => $e->getTraceAsString()]);
        }
        if ($resultSet) {
            if (!$resultSet instanceof HydratingResultSet) {
                throw new \Exception('Correct HydratingResultSet required. Passed "' .
                    get_class($resultSet). '". Provide correct config for `' . $this->tableGateway->getTable() . '`');
            }
        }
        return $resultSet;
    }


    /**
     * Returns number of found rows for previous call of select with limit
     *
     * @return int
     * @throws \Exception
     */
    public function foundRows()
    {

        $select = new Select();
        $select->columns([
            'total' => new Expression('FOUND_ROWS()')
        ]);
        $foundRows = $this->tableGateway->getAdapter()->driver->getConnection()->execute($select->getSqlString())->current();

        if ($foundRows) {
            $result = (int) $foundRows['total'];
        } else {
            throw new \Exception('previous select call with SQL_CALC_FOUND_ROWS required to use FOUND_ROWS()');
        }
        return $result;
    }

    /**
     * ensure hideable models don't show hidden element until we directly want than
     * WARNING: works only with where as array, null, Zend\Db\Where with limited nesting level
     * and enumerated predicates below
     *
     * @see \Zend\Db\Sql\Predicate\Like
     * @see \Zend\Db\Sql\Predicate\NotLike
     * @see \Zend\Db\Sql\Predicate\Between
     * @see \Zend\Db\Sql\Predicate\NotBetween
     * @see \Zend\Db\Sql\Predicate\In
     * @see \Zend\Db\Sql\Predicate\NotIn
     * @see \Zend\Db\Sql\Predicate\IsNull
     * @see \Zend\Db\Sql\Predicate\IsNotNull
     * @see \Zend\Db\Sql\Predicate\Operator
     * @see \Zend\Db\Sql\Predicate\Predicate
     *
     * @param $where
     */
    protected function hideable(&$where)
    {
        if ($this instanceof HideableInterface && !$where) {
            $where = [HideableInterface::COLUMN_NAME_HIDDEN => HideableInterface::IS_NOT_HIDDEN];
        } elseif ($this instanceof HideableInterface && is_array($where)) {
            if (!array_key_exists(HideableInterface::COLUMN_NAME_HIDDEN, $where)) {
                $where[HideableInterface::COLUMN_NAME_HIDDEN] = HideableInterface::IS_NOT_HIDDEN;
            }
        } elseif ($this instanceof HideableInterface && $where instanceof Where) {
            if (!$this->recursiveCheckPredicatesForIdentifier($where->getPredicates(), HideableInterface::COLUMN_NAME_HIDDEN)) {
                $where->and->equalTo(HideableInterface::COLUMN_NAME_HIDDEN, HideableInterface::IS_NOT_HIDDEN);
            }
        }
    }

    /**
     * @param $predicates
     * @param $identifier
     * @return bool
     * @throws \Exception
     */
    private function recursiveCheckPredicatesForIdentifier($predicates, $identifier)
    {
        $hiddenFound = false;
        foreach ($predicates as $expression) {
            list($operator, $predicate) = $expression;

            switch (true) {
                case in_array(get_class($predicate),                 [
                    Like::class,
                    NotLike::class,
                    Between::class,
                    NotBetween::class,
                    In::class,
                    NotIn::class,
                    IsNull::class,
                    IsNotNull::class,
                ]):

                    /** @var Like|NotLike|Between|NotBetween|In|NotIn|IsNull|IsNotNull $predicate */
                    $hiddenFound = ($predicate->getIdentifier() == $identifier);
                    break;
                case ($predicate instanceof Predicate) :
                    $hiddenFound = $this->recursiveCheckPredicatesForIdentifier($predicate->getPredicates(), $identifier);
                    break;
                case ($predicate instanceof Operator):
                    /** @var Operator $predicate */
                    if ($predicate->getLeftType() == Operator::TYPE_IDENTIFIER) {
                        $hiddenFound = ($predicate->getLeft() == $identifier);
                    } else if ($predicate->getRightType() == Operator::TYPE_IDENTIFIER) {
                        $hiddenFound = ($predicate->getRight() == $identifier);
                    } else {
                        throw new \Exception('Can not check too complex where expression for HideableInterface model');
                    }
                    break;
                default:
                    throw new \Exception('Can not check too complex where expression for HideableInterface model. Got ' . get_class($predicate) . ' predicate');
                    break;
            }
            if ($hiddenFound) {
                break;
            }
        }
        return $hiddenFound;
    }

    /**
     * @param \App\Entity\Common $entity
     * @return int
     * @throws \Exception
     */
    public function save($entity)
    {
        $data = $this->delegatingHydrator->extract($entity);
        
        // accept data columns only available at table
        $data = array_intersect_key($data, array_fill_keys($this->tableGateway->getColumns(), 1));

        if (!$data['id']) {
            if (is_null($data['id'])) {
                unset($data['id']);
            }

            $query = function($data) {
                $this->tableGateway->insert($data);
                $insertId = $this->tableGateway->lastInsertValue;
                return $insertId;
            };
        } else {
            $query = function($data) {
                $id = $data['id'];
                unset($data['id']);
                $this->tableGateway->update($data, ['id' => $id]);
                return $id;
            };
        }
        try {
            $this->tableGateway->getAdapter()->getDriver()->getConnection()->beginTransaction();
            $id = $query($data);
            $this->tableGateway->getAdapter()->getDriver()->getConnection()->commit();
        } catch (\Exception $e) {
            $this->tableGateway->getAdapter()->getDriver()->getConnection()->rollback();
            throw $e;
        }
        return $id;
    }

    public function saveRetarg($entity)
    {
        $data_entity = $this->delegatingHydrator->extract($entity);
        
        // accept data columns only available at table
        //$data = array_intersect_key($data, array_fill_keys($this->tableGateway->getColumns(), 1));
        $table_fields = $this->tableGateway->getColumns();
        $data = [];
        foreach ($table_fields as $field) {
            if (isset($data_entity[mb_strtolower($field)])) {
                $data[$field] = $data_entity[mb_strtolower($field)];
            }
        }
        
        if (!$data['id']) {
            if (is_null($data['id'])) {
                unset($data['id']);
            }

            $query = function($data) {
                $this->tableGateway->insert($data);
                $insertId = $this->tableGateway->lastInsertValue;
                return $insertId;
            };
        } else {
            $query = function($data) {
                $id = $data['id'];
                unset($data['id']);
                $this->tableGateway->update($data, ['id' => $id]);
                return $id;
            };
        }
        try {
            $this->tableGateway->getAdapter()->getDriver()->getConnection()->beginTransaction();
            $id = $query($data);
            $this->tableGateway->getAdapter()->getDriver()->getConnection()->commit();
        } catch (\Exception $e) {
            $this->tableGateway->getAdapter()->getDriver()->getConnection()->rollback();
            throw $e;
        }
        return $id;
    }
    
    /**
     * @param $id
     * @return int|null
     * @throws \Exception
     */
    public function deleteById($id)
    {
        $result = false;
        $entity = $this->findById($id);
        if ($entity) {
            $result = $this->tableGateway->delete(['id' => $id]);
        } else {
            throw new \Exception("Record not found ID:" . $id);
        }

        return $result;
    }

}