<?php

/*
 *  $Id: AbstractPropelDataModelTask.php 536 2007-01-10 14:30:38Z heltem $
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * This software consists of voluntary contributions made by many individuals
 * and is licensed under the LGPL. For more information please see
 * <http://propel.phpdb.org>.
 */

//include_once 'phing/tasks/ext/CapsuleTask.php';
require_once 'phing/Task.php';
include_once 'propel/engine/database/model/AppData.php';
include_once 'propel/engine/database/model/Database.php';
include_once 'propel/engine/database/transform/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.phing
 */
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;

	/**
	 * Hashtable containing the names of all the databases
	 * in our collection of schemas.
	 */
	protected $databaseNames; // doesn't seem to be used anywhere

	/**
	 * 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;

	/**
	 * Path where Capsule looks for templates.
	 * @var        PhingFile
	 */
	protected $templatePath;

	/**
	 * 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;

	/**
	 * 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 path where Capsule will look
	 * for templates using the file template
	 * loader.
	 * @return     void
	 * @throws     Exception
	 */
	public function setTemplatePath($templatePath) {
		$resolvedPath = "";
		$tok = strtok($templatePath, ",");
		while ( $tok ) {
			// resolve relative path from basedir and leave
			// absolute path untouched.
			$fullPath = $this->project->resolveFile($tok);
			$cpath = $fullPath->getCanonicalPath();
			if ($cpath === false) {
				$this->log("Template directory does not exist: " . $fullPath->getAbsolutePath());
			} else {
				$resolvedPath .= $cpath;
			}
			$tok = strtok(",");
			if ( $tok ) {
				$resolvedPath .= ",";
			}
		}
		$this->templatePath = $resolvedPath;
	 }

	/**
	 * Get the path where Velocity will look
	 * for templates using the file template
	 * loader.
	 * @return     string
	 */
	public function getTemplatePath() {
		return $this->templatePath;
	}

	/**
	 * [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;
	}

	/**
	 * 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;
	}

	/**
	 * Get the Platform class based on the target database type.
	 * @return     Platform Class that implements the Platform interface.
	 */
	protected function getPlatformForTargetDatabase()
	{

		$classpath = $this->getPropelProperty("platformClass");
		if (empty($classpath)) {
			throw new BuildException("Unable to find class path for '$propname' property.");
		}

		// This is a slight hack to workaround camel case inconsistencies for the DDL classes.
		// Basically, we want to turn ?.?.?.sqliteDDLBuilder into ?.?.?.SqliteDDLBuilder
		$lastdotpos = strrpos($classpath, '.');
		if ($lastdotpos) $classpath{$lastdotpos+1} = strtoupper($classpath{$lastdotpos+1});
		else ucfirst($classpath);

		if (empty($classpath)) {
			throw new BuildException("Unable to find class path for '$propname' property.");
		}

		$clazz = Phing::import($classpath);
		return new $clazz();
	}

	/**
	 * 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->getPlatformForTargetDatabase();

			// 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());

				// normalize (or transform) the XML document using XSLT
				if ($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

						$xsl = new XsltProcessor();
						$xsl->importStyleSheet(DomDocument::load($this->xslFile->getAbsolutePath()));
						$transformed = $xsl->transformToDoc($dom);
						$newXmlFilename = substr($xmlFile->getName(), 0, strrpos($xmlFile->getName(), '.')) . '-transformed.xml';

						// now overwrite previous vars to point to newly transformed file
						$xmlFile = new PhingFile($srcDir, $newXmlFilename);
						$transformed->save($xmlFile->getAbsolutePath());
						$this->log("\t- Using new (post-transformation) XML file: " . $xmlFile->getPath(), PROJECT_MSG_VERBOSE);

						$dom = new DomDocument('1.0', 'UTF-8');
						$dom->load($xmlFile->getAbsolutePath());
					}
				}

				// 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 BuildException("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).");		throw new EngineException("XML schema does not validate (using schema file $xsdFile).  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->parseFile($xmlFile->getAbsolutePath());
				$ad->setName($dmFilename); // <-- Important: use the original name, not the -transformed name.
				$ads[] = $ad;
			}
		}

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

		if (!$this->packageObjectModel) {

			$this->dataModels = $ads;
			$this->databaseNames = array(); // doesn't seem to be used anywhere
			$this->dataModelDbMap = array();

			// Different datamodels may state the same database
			// names, we just want the unique names of databases.
			foreach($this->dataModels as $dm) {
				$database = $dm->getDatabase();
				$this->dataModelDbMap[$dm->getName()] = $database->getName();
				$this->databaseNames[$database->getName()] = $database->getName(); // making list of *unique* dbnames.
			}
		} else {

			$this->joinDatamodels($ads);
			$this->dataModels[0]->getDatabases(); // calls doFinalInitialization()
		}

		$this->dataModelsLoaded = true;
	}

	/**
	 * Joins the datamodels collected from schema.xml files into one big datamodel
	 *
	 * This applies only when the the packageObjectModel option is set. We need to
	 * join the datamodels in this case to allow for foreign keys that point to
	 * tables in different packages.
	 *
	 * @param      array $ads The datamodels to join
	 */
	protected function joinDatamodels($ads) {

		foreach($ads as $ad) {
			$db = $ad->getDatabase(null, false);
			$this->dataModelDbMap[$ad->getName()] = $db->getName();
		}

		foreach ($ads as $addAd) {

			$ad = &$this->dataModels[0];
			if (!isset($ad)) {
				$addAd->setName('JoinedDataModel');
				$ad = $addAd;
				continue;
			}
			foreach ($addAd->getDatabases(false) as $addDb) {
				$addDbName = $addDb->getName();
				if (!$package = $addDb->getPackage()) {
					throw new BuildException('No package found for database "' . $addDbName . '" in ' . $addAd->getName() . '. The propel.packageObjectModel property requires the package attribute to be set for each database.');
				}
				$db = $ad->getDatabase($addDbName, false);
				if (!$db) {
					$ad->addDatabase($addDb);
					continue;
				}
				foreach ($addDb->getTables() as $addTable) {
					$table = $db->getTable($addTable->getName());
					if ($table) {
						throw new BuildException('Duplicate table found: ' . $addDbName . '.');
					}
					$db->addTable($addTable);
				}
			}
		}
	}

	/**
	 * Creates a new Capsule context with some basic properties set.
	 * (Capsule is a simple PHP encapsulation system -- aka a php "template" class.)
	 * @return     Capsule
	 */
	protected function createContext() {

		$context = new Capsule();

		// Make sure the output directory exists, if it doesn't
		// then create it.
		$outputDir = new PhingFile($this->outputDirectory);
		if (!$outputDir->exists()) {
			$this->log("Output directory does not exist, creating: " . $outputDir->getAbsolutePath());
			$outputDir->mkdirs();
		}

		// Place our set of data models into the context along
		// with the names of the databases as a convenience for now.
		$context->put("targetDatabase", $this->targetDatabase);
		$context->put("targetPackage", $this->targetPackage);
		$context->put("now", strftime("%c"));

		$this->log("Target database type: " . $this->targetDatabase);
		$this->log("Target package: " . $this->targetPackage);
		$this->log("Using template path: " . $this->templatePath);
		$this->log("Output directory: " . $this->outputDirectory);

		$context->setTemplatePath($this->templatePath);
		$context->setOutputDirectory($this->outputDirectory);

		$this->populateContextProperties($context);

		return $context;
	}

	/**
	 * Fetches the propel.xxx properties from project, renaming the propel.xxx properties to just xxx.
	 *
	 * Also, renames any xxx.yyy properties to xxxYyy as PHP doesn't like the xxx.yyy syntax.
	 *
	 * @return     array Assoc array of properties.
	 */
	protected function getPropelProperties()
	{
		$allProps = $this->getProject()->getProperties();
		$renamedPropelProps = array();
		foreach ($allProps as $key => $propValue) {
			if (strpos($key, "propel.") === 0) {
				$newKey = substr($key, strlen("propel."));
				$j = strpos($newKey, '.');
				while ($j !== false) {
					$newKey =  substr($newKey, 0, $j) . ucfirst(substr($newKey, $j + 1));
					$j = strpos($newKey, '.');
				}
				$renamedPropelProps[$newKey] = $propValue;
			}
		}
		return $renamedPropelProps;
	}

	/**
	 * Fetches a single propel.xxx property from project, using "converted" property names.
	 * @see        getPropelProperties()
	 * @param      string $name Name of property to fetch (in converted CamelCase)
	 * @return     string The value of the property (or NULL if not set)
	 */
	protected function getPropelProperty($name)
	{
		$props = $this->getPropelProperties();
		if (isset($props[$name])) {
			return $props[$name];
		}
		return null; // just to be explicit
	}

	/**
	 * Adds the propel.xxx properties to the passed Capsule context, changing names to just xxx.
	 *
	 * Also, move xxx.yyy properties to xxxYyy as PHP doesn't like the xxx.yyy syntax.
	 *
	 * @param      Capsule $context
	 * @see        getPropelProperties()
	 */
	public function populateContextProperties(Capsule $context)
	{
		foreach ($this->getPropelProperties() as $key => $propValue) {
			$this->log('Adding property ${' . $key . '} to context', PROJECT_MSG_DEBUG);
			$context->put($key, $propValue);
		}
	}

  /**
   * 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());
			}
		}

	}

}
