<?php

/** Base query builder
 */
abstract class BaseQuery implements IteratorAggregate
{

    /** @var FluentPDO */
    private $fpdo;

    /** @var PDOStatement */
    private $result;

    /** @var float */
    private $time;

    /** @var bool */
    private $object = false;

    /** @var array - definition clauses */
    protected $clauses = array();
    /** @var array */
    protected $statements = array();
    /** @var array */
    protected $parameters = array();

    /**
     * BaseQuery constructor.
     *
     * @param FluentPDO $fpdo
     * @param           $clauses
     */
    protected function __construct(FluentPDO $fpdo, $clauses) {
        $this->fpdo    = $fpdo;
        $this->clauses = $clauses;
        $this->initClauses();
    }

    /**
     * Initialize statement and parameter clauses.
     */
    private function initClauses() {
        foreach ($this->clauses as $clause => $value) {
            if ($value) {
                $this->statements[$clause] = array();
                $this->parameters[$clause] = array();
            } else {
                $this->statements[$clause] = null;
                $this->parameters[$clause] = null;
            }
        }
    }

    /**
     * Add statement for all kind of clauses
     *
     * @param       $clause
     * @param       $statement
     * @param array $parameters
     *
     * @return $this
     */
    protected function addStatement($clause, $statement, $parameters = array()) {
        if ($statement === null) {
            return $this->resetClause($clause);
        }
        // $statement !== null
        if ($this->clauses[$clause]) {
            if (is_array($statement)) {
                $this->statements[$clause] = array_merge($this->statements[$clause], $statement);
            } else {
                $this->statements[$clause][] = $statement;
            }
            $this->parameters[$clause] = array_merge($this->parameters[$clause], $parameters);
        } else {
            $this->statements[$clause] = $statement;
            $this->parameters[$clause] = $parameters;
        }

        return $this;
    }

    /**
     * Remove all prev defined statements
     *
     * @param $clause
     *
     * @return $this
     */
    protected function resetClause($clause) {
        $this->statements[$clause] = null;
        $this->parameters[$clause] = array();
        if (isset($this->clauses[$clause]) && $this->clauses[$clause]) {
            $this->statements[$clause] = array();
        }

        return $this;
    }

    /**
     * Implements method from IteratorAggregate
     *
     * @return \PDOStatement
     */
    public function getIterator() {
        return $this->execute();
    }

    /**
     * Execute query with earlier added parameters
     *
     * @return \PDOStatement
     */
    public function execute() {
        $query      = $this->buildQuery();
        $parameters = $this->buildParameters();

        $result = $this->fpdo->getPdo()->prepare($query);

        // At this point, $result is a PDOStatement instance, or false.
        // PDO::prepare() does not reliably return errors. Some database drivers
        // do not support prepared statements, and PHP emulates them. Postgresql
        // does support prepared statements, but PHP does not call Postgresql's
        // prepare function until we call PDOStatement::execute() below.
        // If PDO::prepare() worked properly, this is where we would check
        // for prepare errors, such as invalid SQL.

        if ($this->object !== false) {
            if (class_exists($this->object)) {
                $result->setFetchMode(PDO::FETCH_CLASS, $this->object);
            } else {
                $result->setFetchMode(PDO::FETCH_OBJ);
            }
        } elseif ($this->fpdo->getPdo()->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE) == PDO::FETCH_BOTH) {
            $result->setFetchMode(PDO::FETCH_ASSOC);
        }

        $time = microtime(true);
        if ($result && $result->execute($parameters)) {
            $this->time = microtime(true) - $time;
        } else {
            $result = false;
        }

        $this->result = $result;
        $this->debugger();

        return $result;
    }

    /**
     * Echo/pass a debug string
     */
    private function debugger() {
        if ($this->fpdo->debug) {
            if (!is_callable($this->fpdo->debug)) {
                $backtrace  = '';
                $query      = $this->getQuery();
                $parameters = $this->getParameters();
                $debug      = '';
                if ($parameters) {
                    $debug = '# parameters: ' . implode(', ', array_map(array($this, 'quote'), $parameters)) . "\n";
                }
                $debug .= $query;
                $pattern = '(^' . preg_quote(__DIR__) . '(\\.php$|[/\\\\]))'; // can be static
                foreach (debug_backtrace() as $backtrace) {
                    if (isset($backtrace['file']) && !preg_match($pattern, $backtrace['file'])) {
                        // stop on first file outside FluentPDO source codes
                        break;
                    }
                }
                $time = sprintf('%0.3f', $this->time * 1000) . ' ms';
                $rows = ($this->result) ? $this->result->rowCount() : 0;
                $finalString = "# $backtrace[file]:$backtrace[line] ($time; rows = $rows)\n$debug\n\n";
                if (is_resource(STDERR)) { // if STDERR is set, send there, otherwise just output the string
                    fwrite(STDERR, $finalString);
                }
                else {
                    echo $finalString;
                }
            } else {
                call_user_func($this->fpdo->debug, $this);
            }
        }
    }

    /**
     * @return \PDO
     */
    protected function getPDO() {
        return $this->fpdo->getPdo();
    }

    /**
     * @return \FluentStructure
     */
    protected function getStructure() {
        return $this->fpdo->getStructure();
    }

    /**
     * Get PDOStatement result
     *
     * @return \PDOStatement
     */
    public function getResult() {
        return $this->result;
    }

    /**
     * Get time of execution
     *
     * @return float
     */
    public function getTime() {
        return $this->time;
    }

    /**
     * Get query parameters
     *
     * @return array
     */
    public function getParameters() {
        return $this->buildParameters();
    }

    /**
     * Get query string
     *
     * @param bool $formatted - Return formatted query
     *
     * @return string
     */
    public function getQuery($formatted = true) {
        $query = $this->buildQuery();
        if ($formatted) {
            $query = FluentUtils::formatQuery($query);
        }

        return $query;
    }

    /**
     * Generate query
     *
     * @return string
     * @throws Exception
     */
    protected function buildQuery() {
        $query = '';
        foreach ($this->clauses as $clause => $separator) {
            if ($this->clauseNotEmpty($clause)) {
                if (is_string($separator)) {
                    $query .= " $clause " . implode($separator, $this->statements[$clause]);
                } elseif ($separator === null) {
                    $query .= " $clause " . $this->statements[$clause];
                } elseif (is_callable($separator)) {
                    $query .= call_user_func($separator);
                } else {
                    throw new Exception("Clause '$clause' is incorrectly set to '$separator'.");
                }
            }
        }

        return trim($query);
    }

    /**
     * @param $clause
     *
     * @return bool
     */
    private function clauseNotEmpty($clause) {
        if ($this->clauses[$clause]) {
            return (boolean)count($this->statements[$clause]);
        } else {
            return (boolean)$this->statements[$clause];
        }
    }

    /**
     * @return array
     */
    protected function buildParameters() {
        $parameters = array();
        foreach ($this->parameters as $clauses) {
            if (is_array($clauses)) {
                foreach ($clauses as $value) {
                    if (is_array($value) && is_string(key($value)) && substr(key($value), 0, 1) == ':') {
                        // this is named params e.g. (':name' => 'Mark')
                        $parameters = array_merge($parameters, $value);
                    } else {
                        $parameters[] = $value;
                    }
                }
            } else {
                if ($clauses) {
                    $parameters[] = $clauses;
                }
            }
        }

        return $parameters;
    }

    /**
     * @param $value
     *
     * @return string
     */
    protected function quote($value) {
        if (!isset($value)) {
            return "NULL";
        }
        if (is_array($value)) { // (a, b) IN ((1, 2), (3, 4))
            return "(" . implode(", ", array_map(array($this, 'quote'), $value)) . ")";
        }
        $value = $this->formatValue($value);
        if (is_float($value)) {
            return sprintf("%F", $value); // otherwise depends on setlocale()
        }
        if ($value === false) {
            return "0";
        }
        if (is_int($value) || $value instanceof FluentLiteral) { // number or SQL code - for example "NOW()"
            return (string)$value;
        }

        return $this->fpdo->getPdo()->quote($value);
    }

    /**
     * @param \DateTime $val
     *
     * @return string
     */
    private function formatValue($val) {
        if ($val instanceof DateTime) {
            return $val->format("Y-m-d H:i:s"); //! may be driver specific
        }

        return $val;
    }

    /**
     * Select an item as object
     *
     * @param  boolean|object $object If set to true, items are returned as stdClass, otherwise a class
     *                                name can be passed and a new instance of this class is return.
     *                                Can be set to false to return items as an associative array.
     *
     * @return \BaseQuery
     */
    public function asObject($object = true) {
        $this->object = $object;

        return $this;
    }

}
