<?php
/**
 * @link https://www.yiiframework.com/
 * @copyright Copyright (c) 2008 Yii Software LLC
 * @license https://www.yiiframework.com/license/
 */

namespace yii\gii;

use Yii;
use ReflectionClass;
use yii\base\InvalidConfigException;
use yii\base\Model;
use yii\helpers\VarDumper;
use yii\web\View;

/**
 * This is the base class for all generator classes.
 *
 * A generator instance is responsible for taking user inputs, validating them,
 * and using them to generate the corresponding code based on a set of code template files.
 *
 * A generator class typically needs to implement the following methods:
 *
 * - [[getName()]]: returns the name of the generator
 * - [[getDescription()]]: returns the detailed description of the generator
 * - [[requiredTemplates()]] returns the required template files
 * - [[generate()]]: generates the code based on the current user input and the specified code template files.
 *   This is the place where main code generation code resides.
 *
 * @property-read string $name The name of the generator.
 * @property-read string $description The detailed description of the generator.
 * @property-read string $stickyDataFile The file path that stores the sticky attribute values.
 * @property-read string $templatePath The root path of the template files that are currently being used.
 *
 * @author Qiang Xue <qiang.xue@gmail.com>
 * @since 2.0
 */
abstract class Generator extends Model
{
    /**
     * @var array[] a list of available code templates. The array keys are the template names,
     * the array values are the corresponding template paths or path aliases.
     */
    public $templates = [];
    /**
     * @var string the name of the code template that the user has selected.
     * The value of this property is internally managed by this class.
     */
    public $template = 'default';
    /**
     * @var bool whether the strings will be generated using `Yii::t()` or PHP strings.
     */
    public $enableI18N = false;
    /**
     * @var string the message category used by `Yii::t()` when `$enableI18N` is `true`, defaults to `app`.
     */
    public $messageCategory = 'app';


    /**
     * Returns the name of the code generator.
     *
     * @return string
     */
    abstract public function getName();

    /**
     * Generates the code based on the current user input and the specified code template files.
     * Please refer to [[\yii\gii\generators\controller\Generator::generate()]] as an example on
     * how to implement this method.
     *
     * @return CodeFile[] a list of code files to be created.
     */
    abstract public function generate();

    /**
     * @inheritdoc
     */
    public function init()
    {
        parent::init();
        if (!isset($this->templates['default'])) {
            $this->templates['default'] = $this->defaultTemplate();
        }
        foreach ($this->templates as $i => $template) {
            $this->templates[$i] = Yii::getAlias($template);
        }
    }

    /**
     * @inheritdoc
     */
    public function attributeLabels()
    {
        return [
            'template' => 'Template/view group',
            'enableI18N' => 'Enable I18N',
            'messageCategory' => 'Message Category',
        ];
    }

    /**
     * Returns a list of code template files that are required.
     * Derived classes usually should override this method if they require the existence of
     * certain template files.
     * @return array list of code template files that are required. They should be file paths
     * relative to [[templatePath]].
     */
    public function requiredTemplates()
    {
        return [];
    }

    /**
     * Returns the list of sticky attributes.
     * A sticky attribute will remember its value and will initialize the attribute with this value
     * when the generator is restarted.
     * @return array list of sticky attributes
     */
    public function stickyAttributes()
    {
        return ['template', 'enableI18N', 'messageCategory'];
    }

    /**
     * Returns the list of hint messages.
     * The array keys are the attribute names, and the array values are the corresponding hint messages.
     * Hint messages will be displayed to end users when they are filling the form for the generator.
     * @return array the list of hint messages
     */
    public function hints()
    {
        return [
            'enableI18N' => 'This indicates whether the generator should generate strings using <code>Yii::t()</code> method.
                Set this to <code>true</code> if you are planning to make your application translatable.',
            'messageCategory' => 'This is the category used by <code>Yii::t()</code> in case you enable I18N.',
        ];
    }

    /**
     * Returns the list of auto complete values.
     * The array keys are the attribute names, and the array values are the corresponding auto complete values.
     * Auto complete values can also be callable typed in order one want to make postponed data generation.
     * @return array[] the list of auto complete values
     */
    public function autoCompleteData()
    {
        return [];
    }

    /**
     * Returns the message to be displayed when the newly generated code is saved successfully.
     * Child classes may override this method to customize the message.
     * @return string the message to be displayed when the newly generated code is saved successfully.
     */
    public function successMessage()
    {
        return 'The code has been generated successfully.';
    }

    /**
     * Returns the view file for the input form of the generator.
     * The default implementation will return the "form.php" file under the directory
     * that contains the generator class file.
     * @return string the view file for the input form of the generator.
     */
    public function formView()
    {
        $class = new ReflectionClass($this);

        return dirname($class->getFileName()) . '/form.php';
    }

    /**
     * Returns the root path to the default code template files.
     * The default implementation will return the "templates" subdirectory of the
     * directory containing the generator class file.
     * @return string the root path to the default code template files.
     */
    public function defaultTemplate()
    {
        $class = new ReflectionClass($this);

        return dirname($class->getFileName()) . '/default';
    }

    /**
     * Returns the detailed description of the generator.
     *
     * @return string
     */
    public function getDescription()
    {
        return '';
    }

    /**
     * {@inheritdoc}
     *
     * Child classes should override this method like the following so that the parent
     * rules are included:
     *
     * ~~~php
     * return array_merge(parent::rules(), [
     *     ...rules for the child class...
     * ]);
     * ~~~
     */
    public function rules()
    {
        return [
            [['template'], 'required', 'message' => 'A code template must be selected.'],
            [['template'], 'validateTemplate'],
            [['enableI18N'], 'boolean'],
            [['messageCategory'], 'string'],
        ];
    }

    /**
     * Loads sticky attributes from an internal file and populates them into the generator.
     * @internal
     */
    public function loadStickyAttributes()
    {
        $stickyAttributes = $this->stickyAttributes();
        $path = $this->getStickyDataFile();
        if (is_file($path)) {
            $result = json_decode(file_get_contents($path), true);
            if (is_array($result)) {
                foreach ($stickyAttributes as $name) {
                    if (isset($result[$name])) {
                        $this->$name = $result[$name];
                    }
                }
            }
        }
    }

    /**
     * Saves sticky attributes into an internal file.
     * @internal
     */
    public function saveStickyAttributes()
    {
        $stickyAttributes = $this->stickyAttributes();
        $stickyAttributes[] = 'template';
        $values = [];
        foreach ($stickyAttributes as $name) {
            $values[$name] = $this->$name;
        }
        $path = $this->getStickyDataFile();
        @mkdir(dirname($path), 0755, true);
        file_put_contents($path, json_encode($values));
    }

    /**
     * Returns the file path that stores the sticky attribute values.
     *
     * @return string
     */
    public function getStickyDataFile()
    {
        return Yii::$app->getRuntimePath() . '/gii-' . Yii::getVersion() . '/' . str_replace('\\', '-', get_class($this)) . '.json';
    }

    /**
     * Saves the generated code into files.
     * @param CodeFile[] $files the code files to be saved
     * @param array $answers
     * @param string $results this parameter receives a value from this method indicating the log messages
     * generated while saving the code files.
     * @return bool whether files are successfully saved without any error.
     */
    public function save($files, $answers, &$results)
    {
        $lines = ['Generating code using template "' . $this->getTemplatePath() . '"...'];
        $hasError = false;
        foreach ($files as $file) {
            $relativePath = $file->getRelativePath();
            if (isset($answers[$file->id]) && !empty($answers[$file->id]) && $file->operation !== CodeFile::OP_SKIP) {
                $error = $file->save();
                if (is_string($error)) {
                    $hasError = true;
                    $lines[] = "generating $relativePath\n<span class=\"error\">$error</span>";
                } else {
                    $lines[] = $file->operation === CodeFile::OP_CREATE ? " generated $relativePath" : " overwrote $relativePath";
                }
            } else {
                $lines[] = "   skipped $relativePath";
            }
        }
        $lines[] = "done!\n";
        $results = implode("\n", $lines);

        return !$hasError;
    }

    /**
     * @return string the root path of the template files that are currently being used.
     * @throws InvalidConfigException if [[template]] is invalid
     */
    public function getTemplatePath()
    {
        if (isset($this->templates[$this->template])) {
            return $this->templates[$this->template];
        }

        throw new InvalidConfigException("Unknown template: {$this->template}");
    }

    /**
     * Generates code using the specified code template and parameters.
     * Note that the code template will be used as a PHP file.
     * @param string $template the code template file. This must be specified as a file path
     * relative to [[templatePath]].
     * @param array $params list of parameters to be passed to the template file.
     * @return string the generated code
     */
    public function render($template, $params = [])
    {
        $view = new View();
        $params['generator'] = $this;

        return $view->renderFile($this->getTemplatePath() . '/' . $template, $params, $this);
    }

    /**
     * Validates the template selection.
     * This method validates whether the user selects an existing template
     * and the template contains all required template files as specified in [[requiredTemplates()]].
     */
    public function validateTemplate()
    {
        $templates = $this->templates;
        if (!isset($templates[$this->template])) {
            $this->addError('template', 'Invalid template selection.');
        } else {
            $templatePath = $this->templates[$this->template];
            foreach ($this->requiredTemplates() as $template) {
                if (!is_file(Yii::getAlias($templatePath . '/' . $template))) {
                    $this->addError('template', "Unable to find the required code template file '$template'.");
                }
            }
        }
    }

    /**
     * An inline validator that checks if the attribute value refers to an existing class name.
     * If the `extends` option is specified, it will also check if the class is a child class
     * of the class represented by the `extends` option.
     * @param string $attribute the attribute being validated
     * @param array $params the validation options
     */
    public function validateClass($attribute, $params)
    {
        $class = $this->$attribute;
        try {
            if (class_exists($class)) {
                if (isset($params['extends'])) {
                    if (ltrim($class, '\\') !== ltrim($params['extends'], '\\') && !is_subclass_of($class, $params['extends'])) {
                        $this->addError($attribute, "'$class' must extend from {$params['extends']} or its child class.");
                    }
                }
            } else {
                $this->addError($attribute, "Class '$class' does not exist or has syntax error.");
            }
        } catch (\Exception $e) {
            $this->addError($attribute, "Class '$class' does not exist or has syntax error.");
        }
    }

    /**
     * An inline validator that checks if the attribute value refers to a valid namespaced class name.
     * The validator will check if the directory containing the new class file exist or not.
     * @param string $attribute the attribute being validated
     * @param array $params the validation options
     */
    public function validateNewClass($attribute, $params)
    {
        $class = ltrim($this->$attribute, '\\');
        if (($pos = strrpos($class, '\\')) === false) {
            $this->addError($attribute, "The class name must contain fully qualified namespace name.");
        } else {
            $ns = substr($class, 0, $pos);
            $path = Yii::getAlias('@' . str_replace('\\', '/', $ns), false);
            if ($path === false) {
                $this->addError($attribute, "The class namespace is invalid: $ns");
            } elseif (!is_dir($path)) {
                $this->addError($attribute, "Please make sure the directory containing this class exists: $path");
            }
        }
    }

    /**
     * Checks if message category is not empty when I18N is enabled.
     */
    public function validateMessageCategory()
    {
        if ($this->enableI18N) {
            if (empty($this->messageCategory)) {
                $this->addError('messageCategory', 'Message Category cannot be blank.');
            } elseif (!preg_match('~^[\w./-]+$~', $this->messageCategory)) {
                $this->addError('messageCategory', 'Message Category is not valid. It should contain only alphanumeric characters, ".", "-", "/", and "_".');
            }
        }
    }

    /**
     * @param string $value the attribute to be validated
     * @return bool whether the value is a reserved PHP keyword.
     */
    public function isReservedKeyword($value)
    {
        static $keywords = [
            '__class__',
            '__dir__',
            '__file__',
            '__function__',
            '__line__',
            '__method__',
            '__namespace__',
            '__trait__',
            'abstract',
            'and',
            'array',
            'as',
            'break',
            'case',
            'catch',
            'callable',
            'cfunction',
            'class',
            'clone',
            'const',
            'continue',
            'declare',
            'default',
            'die',
            'do',
            'echo',
            'else',
            'elseif',
            'empty',
            'enddeclare',
            'endfor',
            'endforeach',
            'endif',
            'endswitch',
            'endwhile',
            'eval',
            'exception',
            'exit',
            'extends',
            'final',
            'finally',
            'for',
            'foreach',
            'function',
            'fn',
            'global',
            'goto',
            'if',
            'implements',
            'include',
            'include_once',
            'instanceof',
            'insteadof',
            'interface',
            'isset',
            'list',
            'namespace',
            'new',
            'old_function',
            'or',
            'parent',
            'php_user_filter',
            'print',
            'private',
            'protected',
            'public',
            'require',
            'require_once',
            'return',
            'static',
            'switch',
            'this',
            'throw',
            'trait',
            'try',
            'unset',
            'use',
            'var',
            'while',
            'xor',
            'yield'
        ];

        return in_array(strtolower($value), $keywords, true);
    }

    /**
     * Generates a string depending on the `$enableI18N` property
     *
     * @param string $string the text be generated
     * @param array $placeholders the placeholders to use by `Yii::t()`
     * @return string
     */
    public function generateString($string = '', $placeholders = [])
    {
        $string = addslashes($string);
        if ($this->enableI18N) {
            // If there are placeholders, use them
            if (!empty($placeholders)) {
                $ph = ', ' . VarDumper::export($placeholders);
            } else {
                $ph = '';
            }
            $str = "Yii::t('" . $this->messageCategory . "', '" . $string . "'" . $ph . ")";
        } else {
            // No I18N, replace placeholders by real words, if any
            if (!empty($placeholders)) {
                $phKeys = array_map(function($word) {
                    return '{' . $word . '}';
                }, array_keys($placeholders));
                $phValues = array_values($placeholders);
                $str = "'" . str_replace($phKeys, $phValues, $string) . "'";
            } else {
                // No placeholders, just the given string
                $str = "'" . $string . "'";
            }
        }
        return $str;
    }
}
