<?php

/**
 * This file is part of the Propel package.
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @license    MIT License
 */

//include_once 'phing/tasks/ext/CapsuleTask.php';
require_once 'phing/Task.php';
include_once 'config/GeneratorConfig.php';
include_once 'model/AppData.php';
include_once 'model/Database.php';
include_once 'builder/util/XmlToAppData.php';

/**
 * An abstract base Propel task to perform work related to the XML schema file.
 *
 * The subclasses invoke templates to do the actual writing of the resulting files.
 *
 * @author     Hans Lellelid <hans@xmpl.org> (Propel)
 * @author     Jason van Zyl <jvanzyl@zenplex.com> (Torque)
 * @author     Daniel Rall <dlr@finemaltcoding.com> (Torque)
 * @package    propel.generator.task
 */
abstract class AbstractPropelDataModelTask extends Task
{

	/**
	 * Fileset of XML schemas which represent our data models.
	 * @var        array Fileset[]
	 */
	protected $schemaFilesets = array();

	/**
	 * Data models that we collect. One from each XML schema file.
	 */
	protected $dataModels = array();

	/**
	 * Have datamodels been initialized?
	 * @var        boolean
	 */
	private $dataModelsLoaded = false;

	/**
	 * Map of data model name to database name.
	 * Should probably stick to the convention
	 * of them being the same but I know right now
	 * in a lot of cases they won't be.
	 */
	protected $dataModelDbMap;

	/**
	 * The target database(s) we are generating SQL
	 * for. Right now we can only deal with a single
	 * target, but we will support multiple targets
	 * soon.
	 */
	protected $targetDatabase;

	/**
	 * DB encoding to use for XmlToAppData object
	 */
	protected $dbEncoding = 'iso-8859-1';

	/**
	 * Target PHP package to place the generated files in.
	 */
	protected $targetPackage;

	/**
	 * @var        Mapper
	 */
	protected $mapperElement;

	/**
	 * Destination directory for results of template scripts.
	 * @var        PhingFile
	 */
	protected $outputDirectory;

	/**
	 * Whether to package the datamodels or not
	 * @var        PhingFile
	 */
	protected $packageObjectModel;

	/**
	 * Whether to perform validation (XSD) on the schema.xml file(s).
	 * @var        boolean
	 */
	protected $validate;

	/**
	 * The XSD schema file to use for validation.
	 * @var        PhingFile
	 */
	protected $xsdFile;

	/**
	 * XSL file to use to normalize (or otherwise transform) schema before validation.
	 * @var        PhingFile
	 */
	protected $xslFile;

	/**
	 * Optional database connection url.
	 * @var        string
	 */
	private $url = null;

	/**
	 * Optional database connection user name.
	 * @var        string
	 */
	private $userId = null;

	/**
	 * Optional database connection password.
	 * @var        string
	 */
	private $password = null;

	/**
	 * PDO Connection.
	 * @var        PDO
	 */
	private $conn = false;

	/**
	 * An initialized GeneratorConfig object containing the converted Phing props.
	 *
	 * @var        GeneratorConfig
	 */
	private $generatorConfig;

	/**
	 * Return the data models that have been
	 * processed.
	 *
	 * @return     List data models
	 */
	public function getDataModels()
	{
		if (!$this->dataModelsLoaded) {
			$this->loadDataModels();
		}
		return $this->dataModels;
	}

	/**
	 * Return the data model to database name map.
	 *
	 * @return     Hashtable data model name to database name map.
	 */
	public function getDataModelDbMap()
	{
		if (!$this->dataModelsLoaded) {
			$this->loadDataModels();
		}
		return $this->dataModelDbMap;
	}

	/**
	 * Adds a set of xml schema files (nested fileset attribute).
	 *
	 * @param      set a Set of xml schema files
	 */
	public function addSchemaFileset(Fileset $set)
	{
		$this->schemaFilesets[] = $set;
	}

	/**
	 * Get the current target database.
	 *
	 * @return     String target database(s)
	 */
	public function getTargetDatabase()
	{
		return $this->targetDatabase;
	}

	/**
	 * Set the current target database. (e.g. mysql, oracle, ..)
	 *
	 * @param      v target database(s)
	 */
	public function setTargetDatabase($v)
	{
		$this->targetDatabase = $v;
	}

	/**
	 * Get the current target package.
	 *
	 * @return     string target PHP package.
	 */
	public function getTargetPackage()
	{
		return $this->targetPackage;
	}

	/**
	 * Set the current target package. This is where generated PHP classes will
	 * live.
	 *
	 * @param      string $v target PHP package.
	 */
	public function setTargetPackage($v)
	{
		$this->targetPackage = $v;
	}

	/**
	 * Set the packageObjectModel switch on/off
	 *
	 * @param      string $v The build.property packageObjectModel
	 */
	public function setPackageObjectModel($v)
	{
		$this->packageObjectModel = ($v === '1' ? true : false);
	}

	/**
	 * Set whether to perform validation on the datamodel schema.xml file(s).
	 * @param      boolean $v
	 */
	public function setValidate($v)
	{
		$this->validate = $v;
	}

	/**
	 * Set the XSD schema to use for validation of any datamodel schema.xml file(s).
	 * @param      $v PhingFile
	 */
	public function setXsd(PhingFile $v)
	{
		$this->xsdFile = $v;
	}

	/**
	 * Set the normalization XSLT to use to transform datamodel schema.xml file(s) before validation and parsing.
	 * @param      $v PhingFile
	 */
	public function setXsl(PhingFile $v)
	{
		$this->xslFile = $v;
	}

	/**
	 * [REQUIRED] Set the output directory. It will be
	 * created if it doesn't exist.
	 * @param      PhingFile $outputDirectory
	 * @return     void
	 * @throws     Exception
	 */
	public function setOutputDirectory(PhingFile $outputDirectory) {
		try {
			if (!$outputDirectory->exists()) {
				$this->log("Output directory does not exist, creating: " . $outputDirectory->getPath(),Project::MSG_VERBOSE);
				if (!$outputDirectory->mkdirs()) {
					throw new IOException("Unable to create Ouptut directory: " . $outputDirectory->getAbsolutePath());
				}
			}
			$this->outputDirectory = $outputDirectory->getCanonicalPath();
		} catch (IOException $ioe) {
			throw new BuildException($ioe);
		}
	}

	/**
	 * Set the current target database encoding.
	 *
	 * @param      v target database encoding
	 */
	public function setDbEncoding($v)
	{
		$this->dbEncoding = $v;
	}

	/**
	 * Set the DB connection url.
	 *
	 * @param      string $url connection url
	 */
	public function setUrl($url)
	{
		$this->url = $url;
	}

	/**
	 * Set the user name for the DB connection.
	 *
	 * @param      string $userId database user
	 */
	public function setUserid($userId)
	{
		$this->userId = $userId;
	}

	/**
	 * Set the password for the DB connection.
	 *
	 * @param      string $password database password
	 */
	public function setPassword($password)
	{
		$this->password = $password;
	}

	/**
	 * Get the output directory.
	 * @return     string
	 */
	public function getOutputDirectory() {
		return $this->outputDirectory;
	}

	/**
	 * Nested creator, creates one Mapper for this task.
	 *
	 * @return     Mapper  The created Mapper type object.
	 * @throws     BuildException
	 */
	public function createMapper() {
		if ($this->mapperElement !== null) {
			throw new BuildException("Cannot define more than one mapper.", $this->location);
		}
		$this->mapperElement = new Mapper($this->project);
		return $this->mapperElement;
	}

	/**
	 * Maps the passed in name to a new filename & returns resolved File object.
	 * @param      string $from
	 * @return     PhingFile Resolved File object.
	 * @throws     BuilException    - if no Mapper element se
	 *                          - if unable to map new filename.
	 */
	protected function getMappedFile($from)
	{
		if (!$this->mapperElement) {
			throw new BuildException("This task requires you to use a <mapper/> element to describe how filename changes should be handled.");
		}

		$mapper = $this->mapperElement->getImplementation();
		$mapped = $mapper->main($from);
		if (!$mapped) {
			throw new BuildException("Cannot create new filename based on: " . $from);
		}
		// Mappers always return arrays since it's possible for some mappers to map to multiple names.
		$outFilename = array_shift($mapped);
		$outFile = new PhingFile($this->getOutputDirectory(), $outFilename);
		return $outFile;
	}

	/**
	 * Gets the PDO connection, if URL specified.
	 * @return     PDO Connection to use (for quoting, Platform class, etc.) or NULL if no connection params were specified.
	 */
	public function getConnection()
	{
		if ($this->conn === false) {
			$this->conn = null;
			if ($this->url) {
				$buf = "Using database settings:\n"
					. " URL: " . $this->url . "\n"
					. ($this->userId ? " user: " . $this->userId . "\n" : "")
				. ($this->password ? " password: " . $this->password . "\n" : "");

				$this->log($buf, Project::MSG_VERBOSE);

				// Set user + password to null if they are empty strings
				if (!$this->userId) { $this->userId = null; }
				if (!$this->password) { $this->password = null; }
				try {
					$this->conn = new PDO($this->url, $this->userId, $this->password);
					$this->conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
				} catch (PDOException $x) {
					$this->log("Unable to create a PDO connection: " . $x->getMessage(), Project::MSG_WARN);
				}
			}
		}
		return $this->conn;
	}

	/**
	 * Gets all matching XML schema files and loads them into data models for class.
	 * @return     void
	 */
	protected function loadDataModels()
	{
		$ads = array();

		// Get all matched files from schemaFilesets
		foreach ($this->schemaFilesets as $fs) {
			$ds = $fs->getDirectoryScanner($this->project);
			$srcDir = $fs->getDir($this->project);

			$dataModelFiles = $ds->getIncludedFiles();

			$platform = $this->getGeneratorConfig()->getConfiguredPlatform();

			// Make a transaction for each file
			foreach ($dataModelFiles as $dmFilename) {

				$this->log("Processing: ".$dmFilename);
				$xmlFile = new PhingFile($srcDir, $dmFilename);

				$dom = new DomDocument('1.0', 'UTF-8');
				$dom->load($xmlFile->getAbsolutePath());
				
				// modify schema to include any external schemas (and remove the external-schema nodes)
				$this->includeExternalSchemas($dom, $srcDir);
					
				// normalize (or transform) the XML document using XSLT
				if ($this->getGeneratorConfig()->getBuildProperty('schemaTransform') && $this->xslFile) {
					$this->log("Transforming " . $xmlFile->getPath() . " using stylesheet " . $this->xslFile->getPath(), Project::MSG_VERBOSE);
					if (!class_exists('XSLTProcessor')) {
						$this->log("Could not perform XLST transformation. Make sure PHP has been compiled/configured to support XSLT.", Project::MSG_ERR);
					} else {
						// normalize the document using normalizer stylesheet
						$xslDom = new DomDocument('1.0', 'UTF-8');
						$xslDom->load($this->xslFile->getAbsolutePath());
						$xsl = new XsltProcessor();
						$xsl->importStyleSheet($xslDom);
						$dom = $xsl->transformToDoc($dom);
					}
				}

				// validate the XML document using XSD schema
				if ($this->validate && $this->xsdFile) {
					$this->log("Validating XML doc (".$xmlFile->getPath().") using schema file " . $this->xsdFile->getPath(), Project::MSG_VERBOSE);
					if (!$dom->schemaValidate($this->xsdFile->getAbsolutePath())) {
						throw new EngineException("XML schema file (".$xmlFile->getPath().") does not validate. See warnings above for reasons validation failed (make sure error_reporting is set to show E_WARNING if you don't see any).", $this->getLocation());
					}
				}
				
				$xmlParser = new XmlToAppData($platform, $this->getTargetPackage(), $this->dbEncoding);
				$ad = $xmlParser->parseString($dom->saveXML(), $xmlFile->getAbsolutePath());
		
				$ad->setName($dmFilename);
				$ads[] = $ad;
			}
		}

		if (empty($ads)) {
			throw new BuildException("No schema files were found (matching your schema fileset definition).");
		}

		foreach ($ads as $ad) {
			// map schema filename with database name
			$this->dataModelDbMap[$ad->getName()] = $ad->getDatabase(null, false)->getName();
		}
		
		if (count($ads)>1 && $this->packageObjectModel) {
			$ad = $this->joinDataModels($ads);
			$this->dataModels = array($ad);
		} else {
			$this->dataModels = $ads;
		}
		
		foreach ($this->dataModels as &$ad) {
			$ad->doFinalInitialization();
		}
			
		$this->dataModelsLoaded = true;
	}

	/**
	 * Replaces all external-schema nodes with the content of xml schema that node refers to
	 *
	 * Recurses to include any external schema referenced from in an included xml (and deeper)
	 * Note: this function very much assumes at least a reasonable XML schema, maybe it'll proof
	 * users don't have those and adding some more informative exceptions would be better
	 *
	 * @param      DomDocument $dom
	 * @param      string $srcDir
	 * @return     void (objects, DomDocument, are references by default in PHP 5, so returning it is useless)
	 **/
	protected function includeExternalSchemas(DomDocument $dom, $srcDir) {
		$databaseNode = $dom->getElementsByTagName("database")->item(0);
		$externalSchemaNodes = $dom->getElementsByTagName("external-schema");
		$fs = FileSystem::getFileSystem();
		$nbIncludedSchemas = 0;
		while ($externalSchema = $externalSchemaNodes->item(0)) {
			$include = $externalSchema->getAttribute("filename");
			$this->log("Processing external schema: ".$include);
			$externalSchema->parentNode->removeChild($externalSchema);
			if ($fs->prefixLength($include) != 0) {
				$externalSchemaFile = new PhingFile($include);
			} else {
				$externalSchemaFile = new PhingFile($srcDir, $include);
			}
			$externalSchemaDom = new DomDocument('1.0', 'UTF-8');
			$externalSchemaDom->load($externalSchemaFile->getAbsolutePath());
			// The external schema may have external schemas of its own ; recurse
			$this->includeExternalSchemas($externalSchemaDom, $srcDir);
			foreach ($externalSchemaDom->getElementsByTagName("table") as $tableNode) { // see xsd, datatase may only have table or external-schema, the latter was just deleted so this should cover everything
				$databaseNode->appendChild($dom->importNode($tableNode, true));
			}
			$nbIncludedSchemas++;
		}
		return $nbIncludedSchemas;
	}
	
	/**
	 * Joins the datamodels collected from schema.xml files into one big datamodel.
	 *  We need to join the datamodels in this case to allow for foreign keys 
	 * that point to tables in different packages.
	 *
	 * @param      array[AppData] $ads The datamodels to join
	 * @return     AppData        The single datamodel with all other datamodels joined in
	 */
	protected function joinDataModels($ads)
	{
		$mainAppData = null;
		foreach ($ads as $appData) {
			if (null === $mainAppData) {
				$mainAppData = $appData;
				$appData->setName('JoinedDataModel');
				continue;
			}
			// merge subsequent schemas to the first one
			foreach ($appData->getDatabases(false) as $addDb) {
				$addDbName = $addDb->getName();
				if ($mainAppData->hasDatabase($addDbName)) {
					$db = $mainAppData->getDatabase($addDbName, false);
					// join tables
					foreach ($addDb->getTables() as $addTable) {
						if ($db->getTable($addTable->getName())) {
							throw new BuildException('Duplicate table found: ' . $addDbName . '.');
						}
						$db->addTable($addTable);
					}
					// join database behaviors
					foreach ($addDb->getBehaviors() as $addBehavior) {
						if (!$db->hasBehavior($addBehavior->getName())) {
							$db->addBehavior($addBehavior);
						}
					}
				} else {
					$mainAppData->addDatabase($addDb);
				}
			}
		}
		return $mainAppData;
	}

	/**
	 * Gets the GeneratorConfig object for this task or creates it on-demand.
	 * @return     GeneratorConfig
	 */
	protected function getGeneratorConfig()
	{
		if ($this->generatorConfig === null) {
			$this->generatorConfig = new GeneratorConfig();
			$this->generatorConfig->setBuildProperties($this->getProject()->getProperties());
		}
		return $this->generatorConfig;
	}

	/**
	 * Checks this class against Basic requrements of any propel datamodel task.
	 *
	 * @throws     BuildException 	- if schema fileset was not defined
	 * 							- if no output directory was specified
	 */
	protected function validate()
	{
		if (empty($this->schemaFilesets)) {
			throw new BuildException("You must specify a fileset of XML schemas.", $this->getLocation());
		}

		// Make sure the output directory is set.
		if ($this->outputDirectory === null) {
			throw new BuildException("The output directory needs to be defined!", $this->getLocation());
		}

		if ($this->validate) {
			if (!$this->xsdFile) {
				throw new BuildException("'validate' set to TRUE, but no XSD specified (use 'xsd' attribute).", $this->getLocation());
			}
		}

	}

}