<?php
/*
 * Copyright (c) 2013, webvariants GbR, http://www.webvariants.de
 *
 * This file is released under the terms of the MIT license. You can find the
 * complete text in the attached LICENSE file or online at:
 *
 * http://www.opensource.org/licenses/mit-license.php
 */

class WV_SQL implements Iterator {
	protected $numRows    = -2;      ///< int           Anzahl der Zeilen in den einzelnen Results (-2 = unbekannt, -1 = Fehler)
	protected $numCols    = 0;       ///< int           Anzahl der Spalten in den einzelnen Results
	protected $currentRow = null;    ///< array         für den Iterator
	protected $pdo        = null;    ///< PDO           die Verbindung zur Datenbank
	protected $stmt       = null;    ///< PDOStatement  das aktuelle PDOStatement

	private static $instance   = null; ///< WV_SQL
	private static $connection = null; ///< sly_DB_PDO_Connection

	public function __construct() {
		$this->pdo = self::getConnection()->getPDO();
	}

	/**
	 * @return WV_SQL
	 */
	public static function getInstance() {
		if (!self::$instance) self::$instance = new self();
		return self::$instance;
	}

	public static function getConnection() {
		if (!self::$connection) {
			$data = sly_Core::config()->get('DATABASE');
			self::$connection = sly_DB_PDO_Connection::getInstance($data['DRIVER'], $data['HOST'], $data['LOGIN'], $data['PASSWORD'], $data['NAME']);
		}

		return self::$connection;
	}

	/**
	 * Tabellen-Präfix ermitteln
	 *
	 * @return string  das Tabellen-Präfix von Sally
	 */
	public static function getPrefix() {
		static $prefix = null;

		if ($prefix === null) {
			$prefix = sly_Core::getTablePrefix();
		}

		return $prefix;
	}

	public static function getMarkers($array, $marker = '?') {
		$count = is_array($array) ? count($array) : (int) $array;
		return implode(',', array_fill(0, $count, $marker));
	}

	/**
	 * Hilfsmethode für genau eine Zeile
	 *
	 * Diese Methode dient dazu, genau eine Zeile zu holen. Sie gibt im
	 * Erfolgsfall kein true, sondern die geholte Zeile zurück, wodurch ein
	 * Aufruf von row() entfällt. Sollte das Ergebnis nur eine Spalte haben, so
	 * wird direkt dieser Wert zurückgeliefert (und kein Array mit einem
	 * Element).
	 *
	 * @param  string $what   Spalten, die geholt werden sollen
	 * @param  string $from   Tabelle, aus der gelesen werden soll
	 * @param  string $where  WHERE-Kriterium
	 * @param  array  $data   die zu verwendenden Daten
	 * @return mixed          false im Falle eines Fehlers, sonst mixed oder ein Array (je nach Spaltenanzahl)
	 */
	public function fetch($what, $from, $where = '1', $data = null) {
		$tables = explode(',', $from);
		$from   = array();
		$prefix = self::getPrefix();

		foreach ($tables as $table) {
			$from[] = $prefix.trim($table);
		}

		$from = implode(', ', $from);

		// Query ausführen

		$data  = sly_makeArray($data);
		$query = sprintf('SELECT %s FROM %s WHERE %s LIMIT 1', $what, $from, $where);
		$stmt  = $this->pdo->prepare($query, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));

		$this->executeStmt($stmt, $data);

		// Achtung: Da wir nicht die Anzahl der abgerufenen Zeilen direkt
		// ermitteln können (dies ginge nur mit UPDATE/DELETE-Statements),
		// können wir sie hier nicht prüfen.

		$result = $stmt->fetch(PDO::FETCH_ASSOC);
		$stmt->closeCursor();

		if ($result === false) {
			return false;
		}

		if (count($result) === 1) {
			$ret = array_values($result);
			return $ret[0];
		}

		return $result;
	}

	/**
	 * Abfrage ausführen
	 *
	 * Diese Methode führt genau eine Abfrage aus und liefert im Erfolgsfall
	 * true, sonst false zurück. Wird der Parameter $temp auf true gesetzt, so
	 * belegt die Abfrage nicht das aktuelle, interne Resultset, sondern legt
	 * eine neue Variable an und gibt auch diese nach erfolgter Abfrage zurück.
	 * fetch() nutzt diesen Mechanismus, um vom internen Resultset unabhängig zu
	 * sein.
	 *
	 * @param  mixed  $queryOrStmt    die auszuführende Abfrage (als String oder PDOStatement-Objekt)
	 * @param  array  $data           die zu verwendenden Daten
	 * @param  string $includePrefix  wenn nichtleer, wird in dem Query jedes Vorkommen durch TABLE_PREFIX ersetzt
	 */
	public function query($queryOrStmt, $data = null, $includePrefix = '') {
		// Daten vorbereiten

		$data = sly_makeArray($data);
		$stmt = $this->prepare($queryOrStmt, $includePrefix);

		$this->executeStmt($stmt, $data);

		$this->currentRow = null;
		$this->numRows    = -2; // -2 = unbekannt
		$this->stmt       = $stmt;
		$this->numCols    = $stmt->columnCount();
	}

	/**
	 * Liefert ein Array zurück
	 *
	 * Diese Methode läuft über ein Resultset und gibt ein normales Array mit dem
	 * Wert zurück. Es darf dazu nur ein einzelnes Feld selektiert werden. Werden
	 * zwei Felder selektiert, so ist das erste der Schlüssel und der zweite der
	 * Wert des Ergebnisarrays.
	 *
	 * @param  string $queryOrStmt    die auszuführende Abfrage
	 * @param  array  $data           die zu verwendenden Daten
	 * @param  string $includePrefix  wenn nichtleer, wird in dem Query jedes Vorkommen durch TABLE_PREFIX ersetzt
	 * @return array                  ein Array mit den Werten
	 */
	public function getArray($queryOrStmt, $data = null, $includePrefix = '') {
		$type = self::getQueryType($queryOrStmt);

		if (!in_array($type, array('SELECT', 'SHOW', 'EXPLAIN'))) {
			throw new PDOException('Cannot use query types other than SELECT/SHOW/EXPLAIN for getArray().');
		}

		$this->query($queryOrStmt, $data, $includePrefix);

		$result = array();

		foreach ($this as $row) {
			$cols = count($row);

			if ($cols == 1) {
				$result[] = reset($row);
			}
			elseif ($cols == 2) {
				$result[reset($row)] = next($row);
			}
			else {
				$columns = array_slice(array_keys($row), 1);
				$key     = reset($row);

				foreach ($columns as $col) {
					$result[$key][$col] = $row[$col];
				}
			}
		}

		return $result;
	}

	public function update($table, $newValues, $whereStatement = '1', $whereData = null) {
		$query = 'UPDATE '.self::getPrefix().$table.' SET ';
		$parts = array();

		foreach ($newValues as $key => $value) {
			$parts[] = $key.' = ?';
		}

		$query .= implode(', ', $parts);

		// Wenn das WHERE-Statement eine Konjunktion ist, kann sie auch als
		// Array übergeben werden. In diesem Fall wird $whereData nicht beachtet
		// und aus dem Array neu generiert.

		if (is_array($whereStatement)) {
			$parts = array();

			foreach ($whereStatement as $key => $value) {
				$parts[] = $key.' = ?';
			}

			$query    .= ' WHERE '.implode(' AND ', $parts);
			$whereData = array_values($whereStatement);
		}
		else {
			$query .= ' WHERE '.$whereStatement;
		}

		$data = array_merge(array_values($newValues), $whereData);
		return $this->query($query, $data);
	}

	/**
	 * Zeilen zählen
	 *
	 * Diese Methode liefert die Anzahl der Zeilen in einer Tabelle, die auf ein
	 * bestimmtes Kriterium passen.
	 *
	 * @param  string $table  die zu untersuchende Tabelle
	 * @param  string $where  WHERE-Kriterium
	 * @param  array  $data   die Daten
	 * @return int            die Anzahl der Zeilen
	 */
	public function count($table, $where = '1', $data = null) {
		return (int) $this->fetch('COUNT(*) AS count', $table, $where, $data);
	}

	public function prepare($queryOrStmt, $includePrefix = '') {
		if ($queryOrStmt instanceof PDOStatement) {
			return $queryOrStmt;
		}

		if (!empty($includePrefix)) {
			$queryOrStmt = str_replace($includePrefix, self::getPrefix(), $queryOrStmt);
		}

		return $this->pdo->prepare($queryOrStmt);
	}

	public function isTransactionActive() {
		return self::getConnection()->isTransRunning();
	}

	/**
	 * Transaktion starten
	 */
	public function beginTransaction() {
		$this->pdo->beginTransaction();
		self::getConnection()->setTransRunning(true);
	}

	/**
	 * Transaktion beenden
	 */
	public function commit() {
		$this->pdo->commit();
		self::getConnection()->setTransRunning(false);
	}

	/**
	 * Transaktion zurücknehmen
	 */
	public function rollBack() {
		$this->pdo->rollBack();
		self::getConnection()->setTransRunning(false);
	}

	public function quote($str) {
		return $this->pdo->quote($str);
	}

	/**
	 * Liefert eine Zeile
	 *
	 * Diese Methode liefert genaue eine Zeile aus dem Resultset, wird aber
	 * durch die Iterator-Fähigkeit eher selten benötigt.
	 *
	 * @return array  ein assoziatives Array mit den Daten
	 */
	public function row() {
		if ($this->stmt === null) {
			throw new PDOException('No active statement found.');
		}

		return $this->stmt->fetch(PDO::FETCH_ASSOC);
	}

	/**
	 * die letzte ID
	 *
	 * Gibt die zuletzt durch AUTO_INCREMENT eingefügte ID zurück.
	 *
	 * @return int  die letzte ID
	 */
	public function lastID() {
		$row = $this->getArray('SELECT LAST_INSERT_ID() as last_id');
		return (int) $row[0];
	}

	/**
	 * die betroffenen Zeilen
	 *
	 * Gibt die Anzahl der von der letzten Abfrage betroffenen Zeilen zurück.
	 *
	 * @return int  die betroffenen Zeilen
	 */
	public function affectedRows() {
		if ($this->stmt === null) {
			throw new PDOException('No active statement found.');
		}

		return $this->stmt->rowCount();
	}

	/**
	 * Anzahl der Zeilen
	 *
	 * Diese Methode gibt die Anzahl der mit der letzten Abfrage geholten Zeilen
	 * zurück. Dieser Wert ist nicht immer definiert (z.B. nicht für
	 * UPDATE-Queries).
	 *
	 * @return int  die Anzahl der Zeilen oder -1, falls ein Fehler auftrat
	 */
	public function rows() {
		if ($this->numRows == -2) {
			$row = $this->getArray('SELECT FOUND_ROWS()');
			$this->numRows = (int) $row[0];
		}

		return $this->numRows;
	}

	/**
	 * Anzahl der Spalten
	 *
	 * Diese Methode gibt die Anzahl der mit der letzten Abfrage geholten
	 * Spalten zurück. Dieser Wert ist nicht immer definiert (z.B. nicht für
	 * UPDATE-Queries).
	 *
	 * @return int  die Anzahl der Spalten
	 */
	public function cols() {
		return $this->numCols;
	}

	/**
	 * Gibt den letzten Fehlercode zurück.
	 *
	 * @return int  der letzte Fehlercode oder -1, falls ein Fehler auftrat
	 */
	public function getErrno() {
		return $this->stmt ? $this->stmt->errorCode() : $this->pdo->errorCode();
	}

	/**
	 * Gibt die letzte Fehlermeldung zurück.
	 *
	 * @return string  die letzte Fehlermeldung
	 */
	public function getError() {
		$info = $this->getErrorInfo();
		return $info[2]; // driver-specific error message
	}

	public function getErrorInfo() {
		return $this->stmt ? $this->stmt->errorInfo() : $this->pdo->errorInfo();
	}

	// =========================================================================
	// ITERATOR-METHODEN
	// =========================================================================

	///@cond INCLUDE_ITERATOR_METHODS

	public function current() {
		return $this->currentRow;
	}

	public function next() {
		$this->currentRow = $this->row();

		if ($this->currentRow === false) {
			$this->stmt->closeCursor();
			$this->stmt = null;
		}
	}

	public function key() {
		return null;
	}

	public function valid() {
		if ($this->stmt === null) {
			return false;
		}

		// Wurde noch gar keine Zeile geholt? Dann holen wir das hier nach.
		if ($this->currentRow === null) {
			$this->next();
		}

		return is_array($this->currentRow);
	}

	public function rewind() {
		if ($this->currentRow !== null) {
			throw new PDOException('Cannot iterate twice over the same result set!');
		}
	}

	///@endcond

	public static function getQueryType($query) {
		$query = trim($query instanceof PDOStatement ? $query->queryString : $query);

		if (empty($query)) {
			throw new PDOException('Empty query given.');
		}

		$parts = explode(' ', $query, 2);
		return strtoupper($parts[0]);
	}

	private function executeStmt(PDOStatement $stmt, array $data) {
		if (!$stmt->execute($data)) {
			$info = $this->getErrorInfo();

			if ($info[0] != 0 && isset($info[2])) {
				throw new PDOException($info[2], $info[0]);
			}
		}
	}
}
