<?php
/**
 * Part of the Joomla Framework Database Package
 *
 * @copyright  Copyright (C) 2005 - 2021 Open Source Matters, Inc. All rights reserved.
 * @license    GNU General Public License version 2 or later; see LICENSE
 */

namespace Joomla\Database\Pgsql;

use Joomla\Database\DatabaseImporter;

/**
 * PDO PostgreSQL Database Importer.
 *
 * @since  1.5.0
 */
class PgsqlImporter extends DatabaseImporter
{
	/**
	 * Checks if all data and options are in order prior to exporting.
	 *
	 * @return  $this
	 *
	 * @since   1.5.0
	 * @throws  \RuntimeException if an error is encountered.
	 */
	public function check()
	{
		// Check if the db connector has been set.
		if (!($this->db instanceof PgsqlDriver))
		{
			throw new \RuntimeException('Database connection wrong type.');
		}

		// Check if the tables have been specified.
		if (empty($this->from))
		{
			throw new \RuntimeException('ERROR: No Tables Specified');
		}

		return $this;
	}

	/**
	 * Get the SQL syntax to add an index.
	 *
	 * @param   \SimpleXMLElement  $field  The XML index definition.
	 *
	 * @return  string
	 *
	 * @since   1.0
	 */
	protected function getAddIndexSql(\SimpleXMLElement $field)
	{
		return (string) $field['Query'];
	}

	/**
	 * Get alters for table if there is a difference.
	 *
	 * @param   \SimpleXMLElement  $structure  The XML structure of the table.
	 *
	 * @return  array
	 *
	 * @since   1.0
	 */
	protected function getAlterTableSql(\SimpleXMLElement $structure)
	{
		$table       = $this->getRealTableName($structure['name']);
		$oldFields   = $this->db->getTableColumns($table);
		$oldKeys     = $this->db->getTableKeys($table);
		$oldSequence = $this->db->getTableSequences($table);
		$alters      = [];

		// Get the fields and keys from the XML that we are aiming for.
		$newFields   = $structure->xpath('field');
		$newKeys     = $structure->xpath('key');
		$newSequence = $structure->xpath('sequence');

		/*
		 * Sequence section
		 */

		$oldSeq          = $this->getSeqLookup($oldSequence);
		$newSequenceLook = $this->getSeqLookup($newSequence);

		foreach ($newSequenceLook as $kSeqName => $vSeq)
		{
			if (isset($oldSeq[$kSeqName]))
			{
				// The field exists, check it's the same.
				$column = $oldSeq[$kSeqName][0];

				// Test whether there is a change.
				$change = ((string) $vSeq[0]['Type'] !== $column->Type)
					|| ((string) $vSeq[0]['Start_Value'] !== $column->Start_Value)
					|| ((string) $vSeq[0]['Min_Value'] !== $column->Min_Value)
					|| ((string) $vSeq[0]['Max_Value'] !== $column->Max_Value)
					|| ((string) $vSeq[0]['Increment'] !== $column->Increment)
					|| ((string) $vSeq[0]['Cycle_option'] !== $column->Cycle_option)
					|| ((string) $vSeq[0]['Table'] !== $column->Table)
					|| ((string) $vSeq[0]['Column'] !== $column->Column)
					|| ((string) $vSeq[0]['Schema'] !== $column->Schema)
					|| ((string) $vSeq[0]['Name'] !== $column->Name);

				if ($change)
				{
					$alters[] = $this->getChangeSequenceSql($kSeqName, $vSeq);
					$alters[] = $this->getSetvalSequenceSql($kSeqName, $vSeq);
				}

				// Unset this field so that what we have left are fields that need to be removed.
				unset($oldSeq[$kSeqName]);
			}
			else
			{
				// The sequence is new
				$alters[] = $this->getAddSequenceSql($newSequenceLook[$kSeqName][0]);
				$alters[] = $this->getSetvalSequenceSql($newSequenceLook[$kSeqName][0]);
			}
		}

		// Any sequences left are orphans
		foreach ($oldSeq as $name => $column)
		{
			// Delete the sequence.
			$alters[] = $this->getDropSequenceSql($name);
		}

		/*
		 * Field section
		 */

		// Loop through each field in the new structure.
		foreach ($newFields as $field)
		{
			$fName = (string) $field['Field'];

			if (isset($oldFields[$fName]))
			{
				// The field exists, check it's the same.
				$column = $oldFields[$fName];

				// Test whether there is a change.
				$change = ((string) $field['Type'] !== $column->Type) || ((string) $field['Null'] !== $column->Null)
					|| ((string) $field['Default'] !== $column->Default);

				if ($change)
				{
					$alters[] = $this->getChangeColumnSql($table, $field);
				}

				// Unset this field so that what we have left are fields that need to be removed.
				unset($oldFields[$fName]);
			}
			else
			{
				// The field is new.
				$alters[] = $this->getAddColumnSql($table, $field);
			}
		}

		// Any columns left are orphans
		foreach ($oldFields as $name => $column)
		{
			// Delete the column.
			$alters[] = $this->getDropColumnSql($table, $name);
		}

		/*
		 * Index section
		 */

		// Get the lookups for the old and new keys
		$oldLookup = $this->getKeyLookup($oldKeys);
		$newLookup = $this->getKeyLookup($newKeys);

		// Loop through each key in the new structure.
		foreach ($newLookup as $name => $keys)
		{
			// Check if there are keys on this field in the existing table.
			if (isset($oldLookup[$name]))
			{
				$same     = true;
				$newCount = \count($newLookup[$name]);
				$oldCount = \count($oldLookup[$name]);

				// There is a key on this field in the old and new tables. Are they the same?
				if ($newCount === $oldCount)
				{
					for ($i = 0; $i < $newCount; $i++)
					{
						// Check only query field -> different query means different index
						$same = ((string) $newLookup[$name][$i]['Query'] === $oldLookup[$name][$i]->Query);

						if (!$same)
						{
							// Break out of the loop. No need to check further.
							break;
						}
					}
				}
				else
				{
					// Count is different, just drop and add.
					$same = false;
				}

				if (!$same)
				{
					$alters[] = $this->getDropIndexSql($name);
					$alters[] = (string) $newLookup[$name][0]['Query'];
				}

				// Unset this field so that what we have left are fields that need to be removed.
				unset($oldLookup[$name]);
			}
			else
			{
				// This is a new key.
				$alters[] = (string) $newLookup[$name][0]['Query'];
			}
		}

		// Any keys left are orphans.
		foreach ($oldLookup as $name => $keys)
		{
			if ($oldLookup[$name][0]->is_primary === 'TRUE')
			{
				$alters[] = $this->getDropPrimaryKeySql($table, $oldLookup[$name][0]->Index);
			}
			else
			{
				$alters[] = $this->getDropIndexSql($name);
			}
		}

		return $alters;
	}

	/**
	 * Get the SQL syntax to drop a sequence.
	 *
	 * @param   string  $name  The name of the sequence to drop.
	 *
	 * @return  string
	 *
	 * @since   1.0
	 */
	protected function getDropSequenceSql($name)
	{
		return 'DROP SEQUENCE ' . $this->db->quoteName($name);
	}

	/**
	 * Get the syntax to add a sequence.
	 *
	 * @param   \SimpleXMLElement  $field  The XML definition for the sequence.
	 *
	 * @return  string
	 *
	 * @since   1.0
	 */
	protected function getAddSequenceSql(\SimpleXMLElement $field)
	{
		$sql = 'CREATE SEQUENCE IF NOT EXISTS ' . (string) $field['Name']
			. ' INCREMENT BY ' . (string) $field['Increment'] . ' MINVALUE ' . $field['Min_Value']
			. ' MAXVALUE ' . (string) $field['Max_Value'] . ' START ' . (string) $field['Start_Value']
			. (((string) $field['Cycle_option'] === 'NO') ? ' NO' : '') . ' CYCLE'
			. ' OWNED BY ' . $this->db->quoteName((string) $field['Schema'] . '.' . (string) $field['Table'] . '.' . (string) $field['Column']);

		return $sql;
	}

	/**
	 * Get the syntax to alter a sequence.
	 *
	 * @param   \SimpleXMLElement  $field  The XML definition for the sequence.
	 *
	 * @return  string
	 *
	 * @since   1.0
	 */
	protected function getChangeSequenceSql(\SimpleXMLElement $field)
	{
		$sql = 'ALTER SEQUENCE ' . (string) $field['Name']
			. ' INCREMENT BY ' . (string) $field['Increment'] . ' MINVALUE ' . (string) $field['Min_Value']
			. ' MAXVALUE ' . (string) $field['Max_Value'] . ' START ' . (string) $field['Start_Value']
			. ' OWNED BY ' . $this->db->quoteName((string) $field['Schema'] . '.' . (string) $field['Table'] . '.' . (string) $field['Column']);

		return $sql;
	}

	/**
	 * Get the syntax to setval a sequence.
	 *
	 * @param   \SimpleXMLElement  $field  The XML definition for the sequence.
	 *
	 * @return  string
	 *
	 * @since   2.0.0
	 */
	protected function getSetvalSequenceSql($field)
	{
		$is_called = $field['Is_called'] == 't' || $field['Is_called'] == '1' ? 'TRUE' : 'FALSE';

		return 'SELECT setval(\'' . (string) $field['Name'] . '\', ' . (string) $field['Last_Value'] . ', ' . $is_called . ')';
	}

	/**
	 * Get the syntax to alter a column.
	 *
	 * @param   string             $table  The name of the database table to alter.
	 * @param   \SimpleXMLElement  $field  The XML definition for the field.
	 *
	 * @return  string
	 *
	 * @since   1.0
	 */
	protected function getChangeColumnSql($table, \SimpleXMLElement $field)
	{
		return 'ALTER TABLE ' . $this->db->quoteName($table) . ' ALTER COLUMN ' . $this->db->quoteName((string) $field['Field']) . ' '
			. $this->getAlterColumnSql($table, $field);
	}

	/**
	 * Get the SQL syntax for a single column that would be included in a table create statement.
	 *
	 * @param   string             $table  The name of the database table to alter.
	 * @param   \SimpleXMLElement  $field  The XML field definition.
	 *
	 * @return  string
	 *
	 * @since   1.0
	 */
	protected function getAlterColumnSql($table, \SimpleXMLElement $field)
	{
		// TODO Incorporate into parent class and use $this.
		$blobs = ['text', 'smalltext', 'mediumtext', 'largetext'];

		$fName = (string) $field['Field'];
		$fType = (string) $field['Type'];
		$fNull = (string) $field['Null'];

		$fDefault = (isset($field['Default']) && $field['Default'] != 'NULL') ?
			preg_match('/^[0-9]$/', $field['Default']) ? $field['Default'] : $this->db->quote((string) $field['Default'])
			: null;

		$sql = ' TYPE ' . $fType;

		if ($fNull === 'NO')
		{
			if ($fDefault === null || \in_array($fType, $blobs, true))
			{
				$sql .= ",\nALTER COLUMN " . $this->db->quoteName($fName) . ' SET NOT NULL'
					. ",\nALTER COLUMN " . $this->db->quoteName($fName) . ' DROP DEFAULT';
			}
			else
			{
				$sql .= ",\nALTER COLUMN " . $this->db->quoteName($fName) . ' SET NOT NULL'
					. ",\nALTER COLUMN " . $this->db->quoteName($fName) . ' SET DEFAULT ' . $fDefault;
			}
		}
		else
		{
			if ($fDefault !== null)
			{
				$sql .= ",\nALTER COLUMN " . $this->db->quoteName($fName) . ' DROP NOT NULL'
					. ",\nALTER COLUMN " . $this->db->quoteName($fName) . ' SET DEFAULT ' . $fDefault;
			}
		}

		// Sequence was created in other function, here is associated a default value but not yet owner
		if (strpos($fDefault, 'nextval') !== false)
		{
			$sequence = $table . '_' . $fName . '_seq';
			$owner    = $table . '.' . $fName;

			$sql .= ";\nALTER SEQUENCE " . $this->db->quoteName($sequence) . ' OWNED BY ' . $this->db->quoteName($owner);
		}

		return $sql;
	}

	/**
	 * Get the SQL syntax for a single column that would be included in a table create statement.
	 *
	 * @param   \SimpleXMLElement  $field  The XML field definition.
	 *
	 * @return  string
	 *
	 * @since   1.0
	 */
	protected function getColumnSql(\SimpleXMLElement $field)
	{
		$fName = (string) $field['Field'];
		$fType = (string) $field['Type'];
		$fNull = (string) $field['Null'];

		if (strpos($field['Default'], '::') != false)
		{
			$fDefault = strstr($field['Default'], '::', true);
		}
		else
		{
			$fDefault = isset($field['Default']) && strlen($field['Default']) > 0
				? preg_match('/^[0-9]$/', $field['Default']) ? $field['Default'] : $this->db->quote((string) $field['Default'])
				: null;
		}

		// Note, nextval() as default value means that type field is serial.
		if (strpos($fDefault, 'nextval') !== false)
		{
			$sql = $this->db->quoteName($fName) . ' SERIAL';
		}
		else
		{
			$sql = $this->db->quoteName($fName) . ' ' . $fType;

			if ($fNull == 'NO')
			{
				if ($fDefault === null)
				{
					$sql .= ' NOT NULL';
				}
				else
				{
					$sql .= ' NOT NULL DEFAULT ' . $fDefault;
				}
			}
			else
			{
				if ($fDefault !== null)
				{
					$sql .= ' DEFAULT ' . $fDefault;
				}
			}
		}

		return $sql;
	}

	/**
	 * Get the SQL syntax to drop an index.
	 *
	 * @param   string  $name  The name of the key to drop.
	 *
	 * @return  string
	 *
	 * @since   1.0
	 */
	protected function getDropIndexSql($name)
	{
		return 'DROP INDEX ' . $this->db->quoteName($name);
	}

	/**
	 * Get the SQL syntax to drop a key.
	 *
	 * @param   string  $table  The table name.
	 * @param   string  $name   The constraint name.
	 *
	 * @return  string
	 *
	 * @since   1.0
	 */
	protected function getDropPrimaryKeySql($table, $name)
	{
		return 'ALTER TABLE ONLY ' . $this->db->quoteName($table) . ' DROP CONSTRAINT ' . $this->db->quoteName($name);
	}

	/**
	 * Get the details list of keys for a table.
	 *
	 * @param   array  $keys  An array of objects that comprise the keys for the table.
	 *
	 * @return  array  The lookup array. array({key name} => array(object, ...))
	 *
	 * @since   1.2.0
	 */
	protected function getKeyLookup($keys)
	{
		// First pass, create a lookup of the keys.
		$lookup = [];

		foreach ($keys as $key)
		{
			if ($key instanceof \SimpleXMLElement)
			{
				$kName = (string) $key['Index'];
			}
			else
			{
				$kName = $key->Index;
			}

			if (empty($lookup[$kName]))
			{
				$lookup[$kName] = [];
			}

			$lookup[$kName][] = $key;
		}

		return $lookup;
	}

	/**
	 * Get the SQL syntax to add a unique constraint for a table key.
	 *
	 * @param   string  $table  The table name.
	 * @param   array   $key    The key.
	 *
	 * @return  string
	 *
	 * @since   2.0.0
	 */
	protected function getAddUniqueSql($table, $key)
	{
		if ($key instanceof \SimpleXMLElement)
		{
			$kName = (string) $key['Key_name'];
			$kIndex = (string) $key['Index'];
		}
		else
		{
			$kName = $key->Key_name;
			$kIndex = $key->Index;
		}

		$unique = $kIndex . ' UNIQUE (' . $kName . ')';

		return 'ALTER TABLE ' . $this->db->quoteName($table) . ' ADD CONSTRAINT ' . $unique;
	}

	/**
	 * Get the details list of sequences for a table.
	 *
	 * @param   array  $sequences  An array of objects that comprise the sequences for the table.
	 *
	 * @return  array  The lookup array. array({key name} => array(object, ...))
	 *
	 * @since   1.0
	 */
	protected function getSeqLookup($sequences)
	{
		// First pass, create a lookup of the keys.
		$lookup = [];

		foreach ($sequences as $seq)
		{
			if ($seq instanceof \SimpleXMLElement)
			{
				$sName = (string) $seq['Name'];
			}
			else
			{
				$sName = $seq->Name;
			}

			if (empty($lookup[$sName]))
			{
				$lookup[$sName] = [];
			}

			$lookup[$sName][] = $seq;
		}

		return $lookup;
	}

	/**
	 * Get the SQL syntax to add a table.
	 *
	 * @param   \SimpleXMLElement  $table  The table information.
	 *
	 * @return  string
	 *
	 * @since   2.0.0
	 * @throws  \RuntimeException
	 */
	protected function xmlToCreate(\SimpleXMLElement $table)
	{
		$existingTables = $this->db->getTableList();
		$tableName = (string) $table['name'];

		if (in_array($tableName, $existingTables))
		{
			throw new \RuntimeException('The table you are trying to create already exists');
		}

		$createTableStatement = 'CREATE TABLE ' . $this->db->quoteName($tableName) . ' (';

		foreach ($table->xpath('field') as $field)
		{
			$createTableStatement .= $this->getColumnSql($field) . ', ';
		}

		$createTableStatement = rtrim($createTableStatement, ', ');
		$createTableStatement .= ');';

		foreach ($table->xpath('sequence') as $seq)
		{
			$createTableStatement .= $this->getAddSequenceSql($seq) . ';';
			$createTableStatement .= $this->getSetvalSequenceSql($seq) . ';';
		}

		foreach ($table->xpath('key') as $key)
		{
			if ((($key['is_primary'] == 'f') || ($key['is_primary'] == '')) && (($key['is_unique'] == 't') || ($key['is_unique'] == '1')))
			{
				$createTableStatement .= $this->getAddUniqueSql($tableName, $key) . ';';
			}
			else
			{
				$createTableStatement .= $this->getAddIndexSql($key) . ';';
			}
		}

		return $createTableStatement;
	}
}
