<?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 'task/AbstractPropelTask.php';
include_once 'config/GeneratorConfig.php';
include_once 'model/AppData.php';
include_once 'model/Database.php';
include_once 'builder/util/XmlToAppData.php';
include_once 'util/PropelSchemaValidator.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 AbstractPropelTask
{

    /**
     * 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 boolean $v The build.property packageObjectModel
     */
    public function setPackageObjectModel($v)
    {
        $this->packageObjectModel = (boolean) $v;
    }

    /**
     * Set whether to perform validation on the datamodel schema.xml file(s).
     *
     * @param boolean $v
     */
    public function setValidate($v)
    {
        $this->validate = (boolean) $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 BuildException
     */
    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 Output 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 BuildException - 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
     * @throws EngineException
     * @throws BuildException
     */
    protected function loadDataModels()
    {
        $ads = array();
        $totalNbTables = 0;
        $this->log('Loading XML schema files...');
        // Get all matched files from schemaFilesets
        foreach ($this->schemaFilesets as $fs) {
            $ds = $fs->getDirectoryScanner($this->project);
            $srcDir = $fs->getDir($this->project);

            $dataModelFiles = $ds->getIncludedFiles();
            sort($dataModelFiles);

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

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

                $this->log("Processing: " . $dmFilename, Project::MSG_VERBOSE);
                $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 " . $dmFilename . " 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 using schema " . $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($defaultPlatform, $this->getTargetPackage(), $this->dbEncoding);
                $xmlParser->setGeneratorConfig($this->getGeneratorConfig());
                $ad = $xmlParser->parseString($dom->saveXML(), $xmlFile->getAbsolutePath());
                $nbTables = $ad->getDatabase(null, false)->countTables();
                $totalNbTables += $nbTables;
                $this->log(sprintf('  %d tables processed successfully', $nbTables), Project::MSG_VERBOSE);

                $ad->setName($dmFilename);
                $ads[] = $ad;
            }
            $this->log(sprintf('%d tables found in %d schema files.', $totalNbTables, count($dataModelFiles)));
        }

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

        if ($this->validate) {
            foreach ($this->dataModels as $dataModel) {
                $validator = new PropelSchemaValidator($dataModel);
                if (!$validator->validate()) {
                    throw new EngineException(sprintf("The database schema contains errors:\n - %s", join("\n - ", $validator->getErrors())));
                }
            }
        }

        $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, Project::MSG_VERBOSE);
            $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, database 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 = array_shift($ads);
        $mainAppData->joinAppDatas($ads);

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