<?php

/**
 * MIT License. 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.
 */

namespace Propel\Generator\Behavior\Delegate;

use InvalidArgumentException;
use Propel\Generator\Builder\Om\ObjectBuilder;
use Propel\Generator\Builder\Om\QueryBuilder;
use Propel\Generator\Model\Behavior;
use Propel\Generator\Model\Column;
use Propel\Generator\Model\ForeignKey;
use Propel\Generator\Model\NameGeneratorInterface;
use Propel\Generator\Model\Table;
use Propel\Generator\Util\PhpParser;
use RuntimeException;

/**
 * Gives a model class the ability to delegate methods to a relationship.
 *
 * @author François Zaninotto
 */
class DelegateBehavior extends Behavior
{
    /**
     * @var int
     */
    public const ONE_TO_ONE = 1;

    /**
     * @var int
     */
    public const MANY_TO_ONE = 2;

    /**
     * Default parameters value
     *
     * @var array<string, mixed>
     */
    protected $parameters = [
        'to' => '',
    ];

    /**
     * @var array<int>
     */
    protected $delegates = [];

    /**
     * @var array|null
     */
    protected $doubleDefined;

    /**
     * Lists the delegates and checks that the behavior can use them,
     * And adds a fk from the delegate to the main table if not already set
     *
     * @throws \InvalidArgumentException
     *
     * @return void
     */
    #[\Override]
    public function modifyTable(): void
    {
        $table = $this->getTable();
        $database = $table->getDatabase();
        $delegates = explode(',', $this->parameters['to']);
        foreach ($delegates as $delegate) {
            $delegate = $database->getTablePrefix() . trim($delegate);
            if (!$database->hasTable($delegate)) {
                throw new InvalidArgumentException(sprintf(
                    'No delegate table "%s" found for table "%s"',
                    $delegate,
                    $table->getName(),
                ));
            }
            if (in_array($delegate, $table->getForeignTableNames(), true)) {
                // existing many-to-one relationship
                $type = self::MANY_TO_ONE;
            } else {
                // one_to_one relationship
                $delegateTable = $this->getDelegateTable($delegate);
                if (in_array($table->getName(), $delegateTable->getForeignTableNames(), true)) {
                    // existing one-to-one relationship
                    $fks = $delegateTable->getForeignKeysReferencingTable($this->getTable()->getName());
                    $fk = $fks[0];
                    if (!$fk->isLocalPrimaryKey()) {
                        throw new InvalidArgumentException(sprintf(
                            'Delegate table "%s" has a relationship with table "%s", but it\'s a one-to-many relationship. The `delegate` behavior only supports one-to-one relationships in this case.',
                            $delegate,
                            $table->getName(),
                        ));
                    }
                } else {
                    // no relationship yet: must be created
                    $this->relateDelegateToMainTable($this->getDelegateTable($delegate), $table);
                }
                $type = self::ONE_TO_ONE;
            }
            $this->delegates[$delegate] = $type;
        }
    }

    /**
     * @param \Propel\Generator\Model\Table $delegateTable
     * @param \Propel\Generator\Model\Table $mainTable
     *
     * @return void
     */
    protected function relateDelegateToMainTable(Table $delegateTable, Table $mainTable): void
    {
        $pks = $mainTable->getPrimaryKey();
        foreach ($pks as $column) {
            $mainColumnName = $column->getName();
            if (!$delegateTable->hasColumn($mainColumnName)) {
                $column = clone $column;
                $column->setAutoIncrement(false);
                $delegateTable->addColumn($column);
            }
        }
        // Add a one-to-one fk
        $fk = new ForeignKey();
        $fk->setForeignTableCommonName($mainTable->getCommonName());
        $fk->setForeignSchemaName($mainTable->getSchema());
        $fk->setDefaultJoin('LEFT JOIN');
        $fk->setOnDelete(ForeignKey::CASCADE);
        $fk->setOnUpdate(ForeignKey::NONE);
        foreach ($pks as $column) {
            $fk->addReference($column->getName(), $column->getName());
        }
        $delegateTable->addForeignKey($fk);
    }

    /**
     * @param string $delegateTableName
     *
     * @return \Propel\Generator\Model\Table|null
     */
    protected function getDelegateTable(string $delegateTableName): ?Table
    {
        return $this->getTable()->getDatabase()->getTable($delegateTableName);
    }

    /**
     * @param \Propel\Generator\Builder\Om\ObjectBuilder $builder
     *
     * @return string
     */
    public function objectCall(ObjectBuilder $builder): string
    {
        $script = '';
        foreach ($this->delegates as $delegate => $type) {
            $delegateTable = $this->getDelegateTable($delegate);
            if ($type == self::ONE_TO_ONE) {
                $fks = $delegateTable->getForeignKeysReferencingTable($this->getTable()->getName());
                $fk = $fks[0];
                $ARClassName = $builder->getClassNameFromBuilder($builder->getNewStubObjectBuilder($fk->getTable()));
                $ARFQCN = $builder->getNewStubObjectBuilder($fk->getTable())->getFullyQualifiedClassName();
                $relationName = $builder->getRefFKPhpNameAffix($fk);
            } else {
                $fks = $this->getTable()->getForeignKeysReferencingTable($delegate);
                $fk = $fks[0];
                $ARClassName = $builder->getClassNameFromBuilder($builder->getNewStubObjectBuilder($delegateTable));
                $ARFQCN = $builder->getNewStubObjectBuilder($delegateTable)->getFullyQualifiedClassName();
                $relationName = $builder->getFKPhpNameAffix($fk);
            }
                $script .= "
if (method_exists({$ARFQCN}::class, \$name)) {
    \$delegate = \$this->get$relationName();
    if (!\$delegate) {
        \$delegate = new $ARClassName();
        \$this->set$relationName(\$delegate);
    }

    return \$delegate->\$name(...\$params);
}\n";
        }

        return $script;
    }

    /**
     * @param string $script
     *
     * @throws \RuntimeException
     *
     * @return void
     */
    public function objectFilter(string &$script): void
    {
        $parser = new PhpParser($script, true);
        $text = (string)$parser->findMethod('toArray');
        $matches = [];
        preg_match('/(\$result = \[([^;]+)\];)/U', $text, $matches);
        if (!$matches) {
            throw new RuntimeException('Cannot find toArray() method in code snippet: ' . $script);
        }

        $values = rtrim($matches[2]) . "\n";
        $newResult = '';
        $indent = '        ';

        foreach ($this->delegates as $key => $value) {
            $delegateTable = $this->getDelegateTable($key);

            $tn = ($delegateTable->getSchema() ? $delegateTable->getSchema() . NameGeneratorInterface::STD_SEPARATOR_CHAR : '') . $delegateTable->getCommonName();
            $ns = $delegateTable->getNamespace() ? '\\' . $delegateTable->getNamespace() : '';
            $newResult .= "{$indent}\$keys_{$tn} = {$ns}\\Map\\{$delegateTable->getPhpName()}TableMap::getFieldNames(\$keyType);\n";
            $i = 0;
            foreach ($delegateTable->getColumns() as $column) {
                if (!$this->isColumnForeignKeyOrDuplicated($column)) {
                    $values .= "{$indent}    \$keys_{$tn}[{$i}] => \$this->get{$column->getPhpName()}(),\n";
                }
                $i++;
            }
        }

        $newResult .= "{$indent}\$result = [{$values}\n{$indent}];";
        $text = str_replace($matches[1], ltrim($newResult), $text);
        $parser->replaceMethod('toArray', $text);
        $script = $parser->getCode();
    }

    /**
     * @param \Propel\Generator\Model\Column $column
     *
     * @return bool
     */
    protected function isColumnForeignKeyOrDuplicated(Column $column): bool
    {
        $delegateTable = $column->getTable();
        $table = $this->getTable();
        $fks = [];

        if ($this->doubleDefined === null) {
            $this->doubleDefined = [];

            foreach ($this->delegates + [$table->getName() => 1] as $key => $value) {
                $delegateTable = $this->getDelegateTable($key);
                foreach ($delegateTable->getColumns() as $columnDelegated) {
                    if (isset($this->doubleDefined[$columnDelegated->getName()])) {
                        $this->doubleDefined[$columnDelegated->getName()]++;
                    } else {
                        $this->doubleDefined[$columnDelegated->getName()] = 1;
                    }
                }
            }
        }

        if (1 < $this->doubleDefined[$column->getName()]) {
            return true;
        }

        foreach ($delegateTable->getForeignKeysReferencingTable($table->getName()) as $fk) {
            $fks[] = $fk->getForeignColumnName();
        }

        foreach ($table->getForeignKeysReferencingTable($delegateTable->getName()) as $fk) {
            $fks[] = $fk->getForeignColumnName();
        }

        if (in_array($column->getName(), $fks, true) || $table->hasColumn($column->getName())) {
            return true;
        }

        return false;
    }

    /**
     * @return string
     */
    public function queryAttributes(): string
    {
        $script = '';
        $collations = '';

        foreach ($this->delegates as $delegate => $type) {
            $delegateTable = $this->getDelegateTable($delegate);

            foreach ($delegateTable->getColumns() as $column) {
                if (!$this->isColumnForeignKeyOrDuplicated($column)) {
                    $collations .= "    '{$column->getPhpName()}' => '{$delegateTable->getPhpName()}',\n";
                }
            }
        }

        if ($collations) {
            $collations = substr($collations, 0, -1);
            $script .= "
protected \$delegatedFields = [
{$collations}
];

";
        }

        return $script;
    }

    /**
     * @param \Propel\Generator\Builder\Om\QueryBuilder $builder
     *
     * @return string
     */
    public function queryMethods(QueryBuilder $builder): string
    {
        $script = '';

        foreach ($this->delegates as $delegate => $type) {
            $delegateTable = $this->getDelegateTable($delegate);

            foreach ($delegateTable->getColumns() as $column) {
                if (!$this->isColumnForeignKeyOrDuplicated($column)) {
                    $phpName = $column->getPhpName();
                    $fieldName = $column->getName();
                    $tablePhpName = $delegateTable->getPhpName();
                    $childClassName = 'Child' . $builder->getUnprefixedClassName();

                    $script .= $this->renderTemplate('queryMethodsTemplate', compact('tablePhpName', 'phpName', 'childClassName', 'fieldName'));
                }
            }
        }

        if ($this->delegates) {
            $script .= "
/**
 * Adds a condition on a column based on a column phpName and a value
 * Uses introspection to translate the column phpName into a fully qualified name
 * Warning: recognizes only the phpNames of the main Model (not joined tables)
 * <code>
 * \$c->filterBy('Title', 'foo');
 * </code>
 *
 * @see Criteria::add()
 *
 * @param string \$column A string representing the column phpName, e.g. 'AuthorId'
 * @param mixed \$value A value for the condition
 * @param string \$comparison What to use for the column comparison, defaults to Criteria::EQUAL and Criteria::IN for queries
 *
 * @return \$this
 */
public function filterBy(string \$column, \$value, ?string \$comparison = null)
{
    if (isset(\$this->delegatedFields[\$column])) {
        \$methodUse = \"use{\$this->delegatedFields[\$column]}Query\";

        \$this->{\$methodUse}()->filterBy(\$column, \$value, \$comparison)->endUse();
    } else {
        \$this->add(\$this->getRealColumnName(\$column), \$value, \$comparison);
    }

    return \$this;
}
";
        }

        return $script;
    }
}
