<?php

/**
 * Ai1ec_Database class
 *
 * Class responsible for generic database operations
 *
 * @author     Time.ly Network Inc.
 * @since      2.0
 *
 * @package    AI1EC
 * @subpackage AI1EC.Database
 */
class Ai1ec_Database_Helper {

	/**
	 * @var array Map of tables and their parsed definitions
	 */
	protected $_schema_delta = array();

	/**
	 * @var array List of valid table prefixes
	 */
	protected $_prefixes     = array();

	/**
	 * @var wpdb Localized instance of wpdb object
	 */
	protected $_db = NULL;

	/**
	 * @var bool If set to true - no operations will be performed
	 */
	protected $_dry_run  = false;

	/**
	 * Constructor
	 *
	 * @param Ai1ec_Registry_Object $registry
	 *
	 * @return void
	 */
	public function __construct( Ai1ec_Registry_Object $registry ) {
		$this->_db       = $registry->get( 'dbi.dbi' );
		$this->_prefixes = array(
			$this->_db->get_table_name( 'ai1ec_' ),
			$this->_db->get_table_name(),
			'',
		);
	}

	/**
	 * Check if dry run is enabled
	 *
	 * @param bool $dry Change dryness [optional=NULL]
	 *
	 * @return bool Dryness of run or previous value
	 */
	public function is_dry( $dry = NULL ) {
		if ( NULL !== $dry ) {
			$previous = $this->_dry_run;
			$this->_dry_run = (bool)$dry;
			return $previous;
		}
		return $this->_dry_run;
	}

	/**
	 * Get fully-qualified table name given it's abbreviated form
	 *
	 * @param string $name         Name (abbreviation) of table to check
	 * @param bool   $ignore_check Return longest name if no table exist [false]
	 *
	 * @return string Fully-qualified table name
	 *
	 * @throws Ai1ec_Database_Schema_Exception If no table matches
	 */
	public function table( $name, $ignore_check = false ) {
		$existing  = $this->get_all_tables();
		$table     = NULL;
		$candidate = NULL;
		foreach ( $this->_prefixes as $prefix ) {
			$candidate = $prefix . $name;
			$index     = strtolower( $candidate );
			if ( isset( $existing[$index] ) ) {
				$table = $existing[$index];
				break;
			}
		}
		if ( NULL === $table ) {
			if ( true === $ignore_check ) {
				return $candidate;
			}
			throw new Ai1ec_Database_Schema_Exception(
				'Table \'' . $name . '\' does not exist'
			);
		}
		return $table;
	}

	/**
	 * Drop given indices from table
	 *
	 * @param string       $table   Name of table to modify
	 * @param string|array $indices List, or single, of indices to remove
	 *
	 * @return bool Success
	 *
	 * @throws Ai1ec_Database_Schema_Exception If table is not found
	 */
	public function drop_indices( $table, $indices ) {
		if ( ! is_array( $indices ) ) {
			$indices = array( (string)$indices );
		}
		$table    = $this->table( $table );
		$existing = $this->get_indices( $table );
		$removed  = 0;
		foreach ( $indices as $index ) {
			if (
				! isset( $existing[$index] ) ||
				$this->_dry_query(
					'ALTER TABLE ' . $table . ' DROP INDEX ' . $index
				)
			) {
				++$removed;
			}
		}
		return ( count( $indices ) === $removed );
	}

	/**
	 * Create indices for given table
	 *
	 * Input ({@see $indices}) must be the same, as output of
	 * method {@see self::get_indices()}.
	 *
	 * @param string $table   Name of table to create indices for
	 * @param array  $indices Indices representation to be created
	 *
	 * @return bool Success
	 *
	 * @throws Ai1ec_Database_Schema_Exception If table is not found
	 */
	public function create_indices( $table, array $indices ) {
		$table = $this->table( $table );
		foreach ( $indices as $name => $definition ) {
			$query = 'ALTER TABLE ' . $table . ' ADD ';
			if ( $definition['unique'] ) {
				$query .= 'UNIQUE ';
			}
			$query .= 'KEY ' . $name . ' (' .
				implode( ', ', $definition['columns'] ) .
				')';
			if ( ! $this->_dry_query( $query ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * get_indices method
	 *
	 * Get map of indices defined for table.
	 *
	 * @NOTICE: no optimization will be performed here, and response will not
	 * be cached, to allow checking result of DDL statements.
	 *
	 * Returned array structure (example):
	 * array(
	 *     'index_name' => array(
	 *         'name'    => 'index_name',
	 *         'columns' => array(
	 *             'column1',
	 *             'column2',
	 *             'column3',
	 *         ),
	 *         'unique'  => true,
	 *     ),
	 * )
	 *
	 * @param string $table Name of table to retrieve index names for
	 *
	 * @return array Map of index names and their representation
	 *
	 * @throws Ai1ec_Database_Schema_Exception If table is not found
	 */
	public function get_indices( $table ) {
		$sql_query = 'SHOW INDEXES FROM ' . $this->table( $table );
		$result    = $this->_db->get_results( $sql_query );
		$indices   = array();
		foreach ( $result as $index ) {
			$name = $index->Key_name;
			if ( ! isset( $indices[$name] ) ) {
				$indices[$name] = array(
					'name'    => $name,
					'columns' => array(),
					'unique'  => ! (bool)intval( $index->Non_unique ),
				);
			}
			$indices[$name]['columns'][$index->Column_name] = $index->Sub_part;
		}
		return $indices;
	}

	/**
	 * Perform query, unless `dry_run` is selected. In later case just output
	 * the final query and return true.
	 *
	 * @param string $query SQL Query to execute
	 *
	 * @return mixed Query state, or true in dry run mode
	 */
	public function _dry_query( $query ) {
		if ( $this->is_dry() ) {
			pr( $query );
			return true;
		}
		$result = $this->_db->query( $query );
		if ( AI1EC_DEBUG ) {
			echo '<h4>', $query, '</h4><pre>', var_export( $result, true ), '</pre>';
		}
		return $result;
	}

	/**
	 * Check if given table exists
	 *
	 * @param string $table Name of table to check
	 *
	 * @return bool Existance
	 */
	public function table_exists( $table ) {
		$map = $this->get_all_tables();
		return isset( $map[strtolower( $table )] );
	}

	/**
	 * Return a list of all tables currently present
	 *
	 * @return array Map of tables present
	 */
	public function get_all_tables() {
		/**
		 * @TODO: refactor using dbi.dbi::get_tables
		 */
		$sql_query = 'SHOW TABLES LIKE \'' .
			$this->_db->get_table_name() .
			'%\'';
		$result    = $this->_db->get_col( $sql_query );
		$tables    = array();
		foreach ( $result as $table ) {
			$tables[strtolower( $table )] = $table;
		}
		return $tables;
	}

	/**
	 * apply_delta method
	 *
	 * Attempt to parse and apply given database tables definition, as a delta.
	 * Some validation is made prior to calling DB, and fields/indexes are also
	 * checked for consistency after sending queries to DB.
	 *
	 * NOTICE: only "CREATE TABLE" statements are handled. Others will, likely,
	 * be ignored, if passed through this method.
	 *
	 * @param string|array $query Single or multiple queries to perform on DB
	 *
	 * @return bool Success
	 *
	 * @throws Ai1ec_Database_Error In case of any error
	 */
	public function apply_delta( $query ) {
		if ( ! function_exists( 'dbDelta' ) ) {
			require_once ABSPATH . 'wp-admin' . DIRECTORY_SEPARATOR .
				'includes' . DIRECTORY_SEPARATOR . 'upgrade.php';
		}
		$success = false;
		$this->_schema_delta = array();
		$queries = $this->_prepare_delta( $query );
		$result  = dbDelta( $queries );
		$success = $this->_check_delta();
		return $success;
	}

	/**
	 * get_notices_helper method
	 *
	 * DIP implementing method, to give access to Ai1ec_Deferred_Rendering_Helper.
	 *
	 * @param Ai1ec_Deferred_Rendering_Helper $replacement Notices implementor
	 *
	 * @return Ai1ec_Deferred_Rendering_Helper Instance of notices implementor
	 */
	public function get_notices_helper(
		Ai1ec_Deferred_Rendering_Helper $replacement = NULL
	) {
		static $helper = NULL;
		if ( NULL !== $replacement ) {
			$helper = $replacement;
		}
		if ( NULL === $helper ) {
			$helper = Ai1ec_Deferred_Rendering_Helper::get_instance();
		}
		return $helper;
	}

	/**
	 * _prepare_delta method
	 *
	 * Prepare statements for execution.
	 * Attempt to parse various SQL definitions and compose the one, that is
	 * most likely to be accepted by delta engine.
	 *
	 * @param string|array $queries Single or multiple queries to perform on DB
	 *
	 * @return bool Success
	 *
	 * @throws Ai1ec_Database_Error In case of any error
	 */
	protected function _prepare_delta( $queries ) {
		if ( ! is_array( $queries ) ) {
			$queries = explode( ';', $queries );
			$queries = array_filter( $queries );
		}
		$current_table = NULL;
		$ctable_regexp = '#
			\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([^ ]+)`?\s*
			\((.+)\)
			([^()]*)
			#six';
		foreach ( $queries as $query ) {
			if ( preg_match( $ctable_regexp, $query, $matches ) ) {
				$this->_schema_delta[$matches[1]] = array(
					'tblname' => $matches[1],
					'cryptic'  => NULL,
					'creator'  => '',
					'columns' => array(),
					'indexes' => array(),
					'content' => preg_replace( '#`#', '', $matches[2] ),
					'clauses' => $matches[3],
				);
			}
		}
		$this->_parse_delta();
		$sane_queries = array();
		foreach ( $this->_schema_delta as $table => $definition ) {
			$create = 'CREATE TABLE ' . $table . " (\n";
			foreach ( $definition['columns'] as $column ) {
				$create .= '    ' . $column['create'] . ",\n";
			}
			foreach ( $definition['indexes'] as $index ) {
				$create .= '    ' . $index['create'] . ",\n";
			}
			$create = substr( $create, 0, -2 ) . "\n";
			$create .= ')' . $definition['clauses'];
			$this->_schema_delta[$table]['creator'] = $create;
			$this->_schema_delta[$table]['cryptic'] = md5( $create );
			$sane_queries[] = $create;
		}
		return $sane_queries;
	}

	/**
	 * _parse_delta method
	 *
	 * Parse table application (creation) statements into atomical particles.
	 * Here "atomical particles" stands for either columns, or indexes.
	 *
	 * @return void Method does not return
	 *
	 * @throws Ai1ec_Database_Error In case of any error
	 */
	protected function _parse_delta() {
		foreach ( $this->_schema_delta as $table => $definitions ) {
			$listing = explode( "\n", $definitions['content'] );
			$listing = array_filter( $listing, array( $this, '_is_not_empty_line' ) );
			$lines   = count( $listing );
			$lineno  = 0;
			foreach ( $listing as $line ) {
				++$lineno;
				$line = trim( preg_replace( '#\s+#', ' ', $line ) );
				$line_new = rtrim( $line, ',' );
				if (
					$lineno < $lines && $line === $line_new ||
					$lineno == $lines && $line !== $line_new
				) {
					throw new Ai1ec_Database_Error(
						'Missing comma in line \'' . $line . '\''
					);
				}
				$line = $line_new;
				unset( $line_new );
				$type = 'indexes';
				if ( false === ( $record = $this->_parse_index( $line ) ) ) {
					$type   = 'columns';
					$record = $this->_parse_column( $line );
				}
				if ( isset(
						$this->_schema_delta[$table][$type][$record['name']]
				) ) {
					throw new Ai1ec_Database_Error(
						'For table `' . $table . '` entry ' . $type .
						' named `' . $record['name'] . '` was declared twice' .
						' in ' . $definitions
					);
				}
				$this->_schema_delta[$table][$type][$record['name']] = $record;
			}
		}
	}

	/**
	 * _parse_index method
	 *
	 * Given string attempts to detect, if it is an index, and if yes - parse
	 * it to more navigable index definition for future validations.
	 * Creates modified index create line, for delta application.
	 *
	 * @param string $description Single "line" of CREATE TABLE statement body
	 *
	 * @return array|bool Index definition, or false if input does not look like index
	 *
	 * @throws Ai1ec_Database_Error In case of any error
	 */
	protected function _parse_index( $description ) {
		$description = preg_replace(
			'#^CONSTRAINT(\s+`?[^ ]+`?)?\s+#six',
			'',
			$description
		);
		$details     = explode( ' ', $description );
		$index       = array(
			'name'    => NULL,
			'content' => array(),
			'create'  => '',
		);
		$details[0]  = strtoupper( $details[0] );
		switch ( $details[0] ) {
			case 'PRIMARY':
				$index['name']   = 'PRIMARY';
				$index['create'] = 'PRIMARY KEY ';
				break;

			case 'UNIQUE':
				$name = $details[1];
				if (
					0 === strcasecmp( 'KEY',   $name ) ||
					0 === strcasecmp( 'INDEX', $name )
				) {
					$name = $details[2];
				}
				$index['name']   = $name;
				$index['create'] = 'UNIQUE KEY ' . $name;
				break;

			case 'KEY':
			case 'INDEX':
				$index['name']   = $details[1];
				$index['create'] = 'KEY ' . $index['name'];
				break;

			default:
				return false;
		}
		$index['content'] = $this->_parse_index_content( $description );
		$index['create'] .= ' (';
		foreach ( $index['content'] as $column => $length ) {
			$index['create'] .= $column;
			if ( NULL !== $length ) {
				$index['create'] .= '(' . $length . ')';
			}
			$index['create'] .= ',';
		}
		$index['create'] = substr( $index['create'], 0, -1 );
		$index['create'] .= ')';
		return $index;
	}

	/**
	 * _parse_column method
	 *
	 * Parse column to parseable definition.
	 * Some valid definitions may still be not recognizes (namely SET and ENUM)
	 * thus one shall beware, when attempting to create such.
	 * Create alternative create table entry line for delta application.
	 *
	 * @param string $description Single "line" of CREATE TABLE statement body
	 *
	 * @return array Column definition
	 *
	 * @throws Ai1ec_Database_Error In case of any error
	 */
	protected function _parse_column( $description ) {
		$column_regexp = '#^
			([a-z][a-z_]+)\s+
			(
				[A-Z]+
				(?:\s*\(\s*\d+(?:\s*,\s*\d+\s*)?\s*\))?
				(?:\s+UNSIGNED)?
				(?:\s+ZEROFILL)?
				(?:\s+BINARY)?
				(?:
					\s+CHARACTER\s+SET\s+[a-z][a-z_]+
					(?:\s+COLLATE\s+[a-z][a-z0-9_]+)?
				)?
			)
			(
				\s+(?:NOT\s+)?NULL
			)?
			(
				\s+DEFAULT\s+[^\s]+
			)?
			(\s+ON\s+UPDATE\s+CURRENT_(?:TIMESTAMP|DATE))?
			(\s+AUTO_INCREMENT)?
			\s*,?\s*
		$#six';
		if ( ! preg_match( $column_regexp, $description, $matches ) ) {
			throw new Ai1ec_Database_Error(
				'Invalid column description ' . $description
			);
		}
		$column = array(
			'name'    => $matches[1],
			'content' => array(),
			'create'  => '',
		);
		if ( 0 === strcasecmp( 'boolean', $matches[2] ) ) {
			$matches[2] = 'tinyint(1)';
		}
		$column['content']['type'] = $matches[2];
		$column['content']['null'] = (
			! isset( $matches[3] ) ||
			0 !== strcasecmp( 'NOT NULL', trim( $matches[3] ) )
		);
		$column['create'] = $column['name'] . ' ' . $column['content']['type'];
		if ( isset( $matches[3] ) ) {
			$column['create'] .= ' ' .
				implode(
					' ',
					array_map(
						'trim',
						array_slice( $matches, 3 )
					)
				);
		}
		return $column;
	}

	/**
	 * _parse_index_content method
	 *
	 * Parse index content, to a map of columns and their length.
	 * All index (content) cases shall be covered, although it is only tested.
	 *
	 * @param string Single line of CREATE TABLE statement, containing index definition
	 *
	 * @return array Map of columns and their length, as per index definition
	 *
	 * @throws Ai1ec_Database_Error In case of any error
	 */
	protected function _parse_index_content( $description ) {
		if ( ! preg_match( '#^[^(]+\((.+)\)$#', $description, $matches ) ) {
			throw new Ai1ec_Database_Error(
				'Invalid index description ' . $description
			);
		}
		$columns = array();
		$textual = explode( ',', $matches[1] );
		$column_regexp = '#\s*([^(]+)(?:\s*\(\s*(\d+)\s*\))?\s*#sx';
		foreach ( $textual as $column ) {
			if (
				! preg_match( $column_regexp, $column, $matches ) || (
					  isset( $matches[2] ) &&
					  (string)$matches[2] !== (string)intval( $matches[2] )
				)
			) {
				throw new Ai1ec_Database_Error(
					'Invalid index (columns) description ' . $description .
					' as per \'' . $column . '\''
				);
			}
			$matches[1] = trim( $matches[1] );
			$columns[$matches[1]] = NULL;
			if ( isset( $matches[2] ) ) {
				$columns[$matches[1]] = (int)$matches[2];
			}
		}
		return $columns;
	}

	/**
	 * _check_delta method
	 *
	 * Given parsed schema definitions (in {@see self::$_schema_delta} map) this
	 * method performs checks, to ensure that table exists, columns are of
	 * expected type, and indexes match their definition in original query.
	 *
	 * @return bool Success
	 *
	 * @throws Ai1ec_Database_Error In case of any error
	 */
	protected function _check_delta() {
		if ( empty( $this->_schema_delta ) ) {
			return true;
		}
		foreach ( $this->_schema_delta as $table => $description ) {

			$columns = $this->_db->get_results( 'SHOW FULL COLUMNS FROM ' . $table );
			if ( empty( $columns ) ) {
				throw new Ai1ec_Database_Error(
					'Required table `' . $table . '` was not created'
				);
			}
			$db_column_names = array();
			foreach ( $columns as $column ) {
				if ( ! isset( $description['columns'][$column->Field] ) ) {
					if ( $this->_db->query(
						'ALTER TABLE `' . $table .
						'` DROP COLUMN `' . $column->Field . '`'
					 ) ) {
						continue;
					}
					continue; // ignore so far
					//throw new Ai1ec_Database_Error(
					//	'Unknown column `' . $column->Field .
					//	'` is present in table `' . $table . '`'
					//);
				}
				$db_column_names[$column->Field] = $column->Field;
				$type_db = $column->Type;
				$collation = '';
				if ( $column->Collation ) {
					$collation = ' CHARACTER SET ' .
						substr(
							$column->Collation,
							0,
							strpos( $column->Collation, '_' )
						) . ' COLLATE ' . $column->Collation;
				}
				$type_req = $description['columns'][$column->Field]
					['content']['type'];
				if (
					false !== stripos(
						$type_req,
						' COLLATE '
					)
				) {
					// suspend collation checking
					//$type_db .= $collation;
					$type_req = preg_replace(
						'#^
							(.+)
							\s+CHARACTER\s+SET\s+[a-z0-9_]+
							\s+COLLATE\s+[a-z0-9_]+
							(.+)?\s*
						$#six',
						'$1$2',
						$type_req
					);
				}
				$type_db  = strtolower(
					preg_replace( '#\s+#', '', $type_db )
				);
				$type_req = strtolower(
					preg_replace( '#\s+#', '', $type_req )
				);
				if ( 0 !== strcmp( $type_db, $type_req ) ) {
					throw new Ai1ec_Database_Error(
						'Field `' . $table . '`.`' . $column->Field .
						'` is of incompatible type'
					);
				}
				if (
					'YES' === $column->Null &&
					false === $description['columns'][$column->Field]
						['content']['null'] ||
					'NO' === $column->Null &&
					true === $description['columns'][$column->Field]
						['content']['null']
				) {
					throw new Ai1ec_Database_Error(
						'Field `' . $table . '`.`' . $column->Field .
						'` NULLability is flipped'
					);
				}
			}
			if (
				$missing = array_diff(
					array_keys( $description['columns'] ),
					$db_column_names
				)
			) {
					throw new Ai1ec_Database_Error(
						'In table `' . $table . '` fields are missing: ' .
						implode( ', ', $missing )
					);
			}

			$indexes = $this->get_indices( $table );

			foreach ( $indexes as $name => $definition ) {
				if ( ! isset( $description['indexes'][$name] ) ) {
					continue; // ignore so far
					//throw new Ai1ec_Database_Error(
					//	'Unknown index `' . $name .
					//	'` is defined for table `' . $table . '`'
					//);
				}
				if (
					$missed = array_diff_assoc(
						$description['indexes'][$name]['content'],
						$definition['columns']
					)
				) {
					throw new Ai1ec_Database_Error(
						'Index `' . $name .
						'` definition for table `' . $table . '` has invalid ' .
						' fields: ' . implode( ', ', array_keys( $missed ) )
					);
				}
			}

			if (
				$missing = array_diff(
					array_keys( $description['indexes'] ),
					array_keys( $indexes )
				)
			) {
					throw new Ai1ec_Database_Error(
						'In table `' . $table . '` indexes are missing: ' .
						implode( ', ', $missing )
					);
			}

		}
		return true;
	}

	/**
	 * _is_not_empty_line method
	 *
	 * Helper method, to check that any given line is not empty.
	 * Aids array_filter in detecting empty SQL query lines.
	 *
	 * @param string $line Single line of DB query statement
	 *
	 * @return bool True if line is not empty, false otherwise
	 */
	protected function _is_not_empty_line( $line ) {
		$line = trim( $line );
		return ! empty( $line );
	}

}
