<?php

namespace Doctrine\DBAL\Schema;

use BadMethodCallException;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types;
use Doctrine\Deprecations\Deprecation;

use function array_intersect_key;
use function array_key_exists;
use function array_keys;
use function array_map;
use function array_merge;
use function array_unique;
use function assert;
use function count;
use function get_class;
use function sprintf;
use function strtolower;

/**
 * Compares two Schemas and return an instance of SchemaDiff.
 *
 * @method SchemaDiff compareSchemas(Schema $fromSchema, Schema $toSchema)
 */
class Comparator
{
    private ?AbstractPlatform $platform;

    /** @internal The comparator can be only instantiated by a schema manager. */
    public function __construct(?AbstractPlatform $platform = null)
    {
        if ($platform === null) {
            Deprecation::triggerIfCalledFromOutside(
                'doctrine/dbal',
                'https://github.com/doctrine/dbal/pull/4746',
                'Not passing a $platform to %s is deprecated.'
                    . ' Use AbstractSchemaManager::createComparator() to instantiate the comparator.',
                __METHOD__,
            );
        }

        $this->platform = $platform;
    }

    /** @param list<mixed> $args */
    public function __call(string $method, array $args): SchemaDiff
    {
        if ($method !== 'compareSchemas') {
            throw new BadMethodCallException(sprintf('Unknown method "%s"', $method));
        }

        return $this->doCompareSchemas(...$args);
    }

    /** @param list<mixed> $args */
    public static function __callStatic(string $method, array $args): SchemaDiff
    {
        if ($method !== 'compareSchemas') {
            throw new BadMethodCallException(sprintf('Unknown method "%s"', $method));
        }

        Deprecation::trigger(
            'doctrine/dbal',
            'https://github.com/doctrine/dbal/pull/4707',
            'Calling %s::%s() statically is deprecated.',
            self::class,
            $method,
        );

        $comparator = new self();

        return $comparator->doCompareSchemas(...$args);
    }

    /**
     * Returns a SchemaDiff object containing the differences between the schemas $fromSchema and $toSchema.
     *
     * This method should be called non-statically since it will be declared as non-static in the next major release.
     *
     * @return SchemaDiff
     *
     * @throws SchemaException
     */
    private function doCompareSchemas(
        Schema $fromSchema,
        Schema $toSchema
    ) {
        $createdSchemas   = [];
        $droppedSchemas   = [];
        $createdTables    = [];
        $alteredTables    = [];
        $droppedTables    = [];
        $createdSequences = [];
        $alteredSequences = [];
        $droppedSequences = [];

        $orphanedForeignKeys = [];

        $foreignKeysToTable = [];

        foreach ($toSchema->getNamespaces() as $namespace) {
            if ($fromSchema->hasNamespace($namespace)) {
                continue;
            }

            $createdSchemas[$namespace] = $namespace;
        }

        foreach ($fromSchema->getNamespaces() as $namespace) {
            if ($toSchema->hasNamespace($namespace)) {
                continue;
            }

            $droppedSchemas[$namespace] = $namespace;
        }

        foreach ($toSchema->getTables() as $table) {
            $tableName = $table->getShortestName($toSchema->getName());
            if (! $fromSchema->hasTable($tableName)) {
                $createdTables[$tableName] = $toSchema->getTable($tableName);
            } else {
                $tableDifferences = $this->diffTable(
                    $fromSchema->getTable($tableName),
                    $toSchema->getTable($tableName),
                );

                if ($tableDifferences !== false) {
                    $alteredTables[$tableName] = $tableDifferences;
                }
            }
        }

        /* Check if there are tables removed */
        foreach ($fromSchema->getTables() as $table) {
            $tableName = $table->getShortestName($fromSchema->getName());

            $table = $fromSchema->getTable($tableName);
            if (! $toSchema->hasTable($tableName)) {
                $droppedTables[$tableName] = $table;
            }

            // also remember all foreign keys that point to a specific table
            foreach ($table->getForeignKeys() as $foreignKey) {
                $foreignTable = strtolower($foreignKey->getForeignTableName());
                if (! isset($foreignKeysToTable[$foreignTable])) {
                    $foreignKeysToTable[$foreignTable] = [];
                }

                $foreignKeysToTable[$foreignTable][] = $foreignKey;
            }
        }

        foreach ($droppedTables as $tableName => $table) {
            if (! isset($foreignKeysToTable[$tableName])) {
                continue;
            }

            foreach ($foreignKeysToTable[$tableName] as $foreignKey) {
                if (isset($droppedTables[strtolower($foreignKey->getLocalTableName())])) {
                    continue;
                }

                $orphanedForeignKeys[] = $foreignKey;
            }

            // deleting duplicated foreign keys present on both on the orphanedForeignKey
            // and the removedForeignKeys from changedTables
            foreach ($foreignKeysToTable[$tableName] as $foreignKey) {
                // strtolower the table name to make if compatible with getShortestName
                $localTableName = strtolower($foreignKey->getLocalTableName());
                if (! isset($alteredTables[$localTableName])) {
                    continue;
                }

                foreach ($alteredTables[$localTableName]->getDroppedForeignKeys() as $droppedForeignKey) {
                    assert($droppedForeignKey instanceof ForeignKeyConstraint);

                    // We check if the key is from the removed table if not we skip.
                    if ($tableName !== strtolower($droppedForeignKey->getForeignTableName())) {
                        continue;
                    }

                    $alteredTables[$localTableName]->unsetDroppedForeignKey($droppedForeignKey);
                }
            }
        }

        foreach ($toSchema->getSequences() as $sequence) {
            $sequenceName = $sequence->getShortestName($toSchema->getName());
            if (! $fromSchema->hasSequence($sequenceName)) {
                if (! $this->isAutoIncrementSequenceInSchema($fromSchema, $sequence)) {
                    $createdSequences[] = $sequence;
                }
            } else {
                if ($this->diffSequence($sequence, $fromSchema->getSequence($sequenceName))) {
                    $alteredSequences[] = $toSchema->getSequence($sequenceName);
                }
            }
        }

        foreach ($fromSchema->getSequences() as $sequence) {
            if ($this->isAutoIncrementSequenceInSchema($toSchema, $sequence)) {
                continue;
            }

            $sequenceName = $sequence->getShortestName($fromSchema->getName());

            if ($toSchema->hasSequence($sequenceName)) {
                continue;
            }

            $droppedSequences[] = $sequence;
        }

        $diff = new SchemaDiff(
            $createdTables,
            $alteredTables,
            $droppedTables,
            $fromSchema,
            $createdSchemas,
            $droppedSchemas,
            $createdSequences,
            $alteredSequences,
            $droppedSequences,
        );

        $diff->orphanedForeignKeys = $orphanedForeignKeys;

        return $diff;
    }

    /**
     * @deprecated Use non-static call to {@see compareSchemas()} instead.
     *
     * @return SchemaDiff
     *
     * @throws SchemaException
     */
    public function compare(Schema $fromSchema, Schema $toSchema)
    {
        Deprecation::trigger(
            'doctrine/dbal',
            'https://github.com/doctrine/dbal/pull/4707',
            'Method compare() is deprecated. Use a non-static call to compareSchemas() instead.',
        );

        return $this->compareSchemas($fromSchema, $toSchema);
    }

    /**
     * @param Schema   $schema
     * @param Sequence $sequence
     */
    private function isAutoIncrementSequenceInSchema($schema, $sequence): bool
    {
        foreach ($schema->getTables() as $table) {
            if ($sequence->isAutoIncrementsFor($table)) {
                return true;
            }
        }

        return false;
    }

    /** @return bool */
    public function diffSequence(Sequence $sequence1, Sequence $sequence2)
    {
        if ($sequence1->getAllocationSize() !== $sequence2->getAllocationSize()) {
            return true;
        }

        return $sequence1->getInitialValue() !== $sequence2->getInitialValue();
    }

    /**
     * Returns the difference between the tables $fromTable and $toTable.
     *
     * If there are no differences this method returns the boolean false.
     *
     * @deprecated Use {@see compareTables()} and, optionally, {@see TableDiff::isEmpty()} instead.
     *
     * @return TableDiff|false
     *
     * @throws Exception
     */
    public function diffTable(Table $fromTable, Table $toTable)
    {
        Deprecation::triggerIfCalledFromOutside(
            'doctrine/dbal',
            'https://github.com/doctrine/dbal/pull/5770',
            '%s is deprecated. Use compareTables() instead.',
            __METHOD__,
        );

        $diff = $this->compareTables($fromTable, $toTable);

        if ($diff->isEmpty()) {
            return false;
        }

        return $diff;
    }

    /**
     * Compares the tables and returns the difference between them.
     *
     * @throws Exception
     */
    public function compareTables(Table $fromTable, Table $toTable): TableDiff
    {
        $addedColumns        = [];
        $modifiedColumns     = [];
        $droppedColumns      = [];
        $addedIndexes        = [];
        $modifiedIndexes     = [];
        $droppedIndexes      = [];
        $addedForeignKeys    = [];
        $modifiedForeignKeys = [];
        $droppedForeignKeys  = [];

        $fromTableColumns = $fromTable->getColumns();
        $toTableColumns   = $toTable->getColumns();

        /* See if all the columns in "from" table exist in "to" table */
        foreach ($toTableColumns as $columnName => $column) {
            if ($fromTable->hasColumn($columnName)) {
                continue;
            }

            $addedColumns[$columnName] = $column;
        }

        /* See if there are any removed columns in "to" table */
        foreach ($fromTableColumns as $columnName => $column) {
            // See if column is removed in "to" table.
            if (! $toTable->hasColumn($columnName)) {
                $droppedColumns[$columnName] = $column;

                continue;
            }

            $toColumn = $toTable->getColumn($columnName);

            // See if column has changed properties in "to" table.
            $changedProperties = $this->diffColumn($column, $toColumn);

            if ($this->platform !== null) {
                if ($this->columnsEqual($column, $toColumn)) {
                    continue;
                }
            } elseif (count($changedProperties) === 0) {
                continue;
            }

            $modifiedColumns[$column->getName()] = new ColumnDiff(
                $column->getName(),
                $toColumn,
                $changedProperties,
                $column,
            );
        }

        $renamedColumns = $this->detectRenamedColumns($addedColumns, $droppedColumns);

        $fromTableIndexes = $fromTable->getIndexes();
        $toTableIndexes   = $toTable->getIndexes();

        /* See if all the indexes in "from" table exist in "to" table */
        foreach ($toTableIndexes as $indexName => $index) {
            if (($index->isPrimary() && $fromTable->getPrimaryKey() !== null) || $fromTable->hasIndex($indexName)) {
                continue;
            }

            $addedIndexes[$indexName] = $index;
        }

        /* See if there are any removed indexes in "to" table */
        foreach ($fromTableIndexes as $indexName => $index) {
            // See if index is removed in "to" table.
            if (
                ($index->isPrimary() && $toTable->getPrimaryKey() === null) ||
                ! $index->isPrimary() && ! $toTable->hasIndex($indexName)
            ) {
                $droppedIndexes[$indexName] = $index;

                continue;
            }

            // See if index has changed in "to" table.
            $toTableIndex = $index->isPrimary() ? $toTable->getPrimaryKey() : $toTable->getIndex($indexName);
            assert($toTableIndex instanceof Index);

            if (! $this->diffIndex($index, $toTableIndex)) {
                continue;
            }

            $modifiedIndexes[$indexName] = $toTableIndex;
        }

        $renamedIndexes = $this->detectRenamedIndexes($addedIndexes, $droppedIndexes);

        $fromForeignKeys = $fromTable->getForeignKeys();
        $toForeignKeys   = $toTable->getForeignKeys();

        foreach ($fromForeignKeys as $fromKey => $fromConstraint) {
            foreach ($toForeignKeys as $toKey => $toConstraint) {
                if ($this->diffForeignKey($fromConstraint, $toConstraint) === false) {
                    unset($fromForeignKeys[$fromKey], $toForeignKeys[$toKey]);
                } else {
                    if (strtolower($fromConstraint->getName()) === strtolower($toConstraint->getName())) {
                        $modifiedForeignKeys[] = $toConstraint;

                        unset($fromForeignKeys[$fromKey], $toForeignKeys[$toKey]);
                    }
                }
            }
        }

        foreach ($fromForeignKeys as $fromConstraint) {
            $droppedForeignKeys[] = $fromConstraint;
        }

        foreach ($toForeignKeys as $toConstraint) {
            $addedForeignKeys[] = $toConstraint;
        }

        return new TableDiff(
            $toTable->getName(),
            $addedColumns,
            $modifiedColumns,
            $droppedColumns,
            $addedIndexes,
            $modifiedIndexes,
            $droppedIndexes,
            $fromTable,
            $addedForeignKeys,
            $modifiedForeignKeys,
            $droppedForeignKeys,
            $renamedColumns,
            $renamedIndexes,
        );
    }

    /**
     * Try to find columns that only changed their name, rename operations maybe cheaper than add/drop
     * however ambiguities between different possibilities should not lead to renaming at all.
     *
     * @param array<string,Column> $addedColumns
     * @param array<string,Column> $removedColumns
     *
     * @return array<string,Column>
     *
     * @throws Exception
     */
    private function detectRenamedColumns(array &$addedColumns, array &$removedColumns): array
    {
        $candidatesByName = [];

        foreach ($addedColumns as $addedColumnName => $addedColumn) {
            foreach ($removedColumns as $removedColumn) {
                if (! $this->columnsEqual($addedColumn, $removedColumn)) {
                    continue;
                }

                $candidatesByName[$addedColumn->getName()][] = [$removedColumn, $addedColumn, $addedColumnName];
            }
        }

        $renamedColumns = [];

        foreach ($candidatesByName as $candidates) {
            if (count($candidates) !== 1) {
                continue;
            }

            [$removedColumn, $addedColumn] = $candidates[0];
            $removedColumnName             = $removedColumn->getName();
            $addedColumnName               = strtolower($addedColumn->getName());

            if (isset($renamedColumns[$removedColumnName])) {
                continue;
            }

            $renamedColumns[$removedColumnName] = $addedColumn;
            unset(
                $addedColumns[$addedColumnName],
                $removedColumns[strtolower($removedColumnName)],
            );
        }

        return $renamedColumns;
    }

    /**
     * Try to find indexes that only changed their name, rename operations maybe cheaper than add/drop
     * however ambiguities between different possibilities should not lead to renaming at all.
     *
     * @param array<string,Index> $addedIndexes
     * @param array<string,Index> $removedIndexes
     *
     * @return array<string,Index>
     */
    private function detectRenamedIndexes(array &$addedIndexes, array &$removedIndexes): array
    {
        $candidatesByName = [];

        // Gather possible rename candidates by comparing each added and removed index based on semantics.
        foreach ($addedIndexes as $addedIndexName => $addedIndex) {
            foreach ($removedIndexes as $removedIndex) {
                if ($this->diffIndex($addedIndex, $removedIndex)) {
                    continue;
                }

                $candidatesByName[$addedIndex->getName()][] = [$removedIndex, $addedIndex, $addedIndexName];
            }
        }

        $renamedIndexes = [];

        foreach ($candidatesByName as $candidates) {
            // If the current rename candidate contains exactly one semantically equal index,
            // we can safely rename it.
            // Otherwise, it is unclear if a rename action is really intended,
            // therefore we let those ambiguous indexes be added/dropped.
            if (count($candidates) !== 1) {
                continue;
            }

            [$removedIndex, $addedIndex] = $candidates[0];

            $removedIndexName = strtolower($removedIndex->getName());
            $addedIndexName   = strtolower($addedIndex->getName());

            if (isset($renamedIndexes[$removedIndexName])) {
                continue;
            }

            $renamedIndexes[$removedIndexName] = $addedIndex;
            unset(
                $addedIndexes[$addedIndexName],
                $removedIndexes[$removedIndexName],
            );
        }

        return $renamedIndexes;
    }

    /**
     * @internal The method should be only used from within the {@see Comparator} class hierarchy.
     *
     * @return bool
     */
    public function diffForeignKey(ForeignKeyConstraint $key1, ForeignKeyConstraint $key2)
    {
        if (
            array_map('strtolower', $key1->getUnquotedLocalColumns())
            !== array_map('strtolower', $key2->getUnquotedLocalColumns())
        ) {
            return true;
        }

        if (
            array_map('strtolower', $key1->getUnquotedForeignColumns())
            !== array_map('strtolower', $key2->getUnquotedForeignColumns())
        ) {
            return true;
        }

        if ($key1->getUnqualifiedForeignTableName() !== $key2->getUnqualifiedForeignTableName()) {
            return true;
        }

        if ($key1->onUpdate() !== $key2->onUpdate()) {
            return true;
        }

        return $key1->onDelete() !== $key2->onDelete();
    }

    /**
     * Compares the definitions of the given columns
     *
     * @internal The method should be only used from within the {@see Comparator} class hierarchy.
     *
     * @throws Exception
     */
    public function columnsEqual(Column $column1, Column $column2): bool
    {
        if ($this->platform === null) {
            return $this->diffColumn($column1, $column2) === [];
        }

        return $this->platform->columnsEqual($column1, $column2);
    }

    /**
     * Returns the difference between the columns
     *
     * If there are differences this method returns the changed properties as a
     * string array, otherwise an empty array gets returned.
     *
     * @deprecated Use {@see columnsEqual()} instead.
     *
     * @return string[]
     */
    public function diffColumn(Column $column1, Column $column2)
    {
        Deprecation::triggerIfCalledFromOutside(
            'doctrine/dbal',
            'https://github.com/doctrine/dbal/pull/5650',
            '%s is deprecated. Use diffTable() instead.',
            __METHOD__,
        );

        $properties1 = $column1->toArray();
        $properties2 = $column2->toArray();

        $changedProperties = [];

        if (get_class($properties1['type']) !== get_class($properties2['type'])) {
            $changedProperties[] = 'type';
        }

        foreach (['notnull', 'unsigned', 'autoincrement'] as $property) {
            if ($properties1[$property] === $properties2[$property]) {
                continue;
            }

            $changedProperties[] = $property;
        }

        // Null values need to be checked additionally as they tell whether to create or drop a default value.
        // null != 0, null != false, null != '' etc. This affects platform's table alteration SQL generation.
        if (
            ($properties1['default'] === null) !== ($properties2['default'] === null)
            || $properties1['default'] != $properties2['default']
        ) {
            $changedProperties[] = 'default';
        }

        if (
            ($properties1['type'] instanceof Types\StringType && ! $properties1['type'] instanceof Types\GuidType) ||
            $properties1['type'] instanceof Types\BinaryType
        ) {
            // check if value of length is set at all, default value assumed otherwise.
            $length1 = $properties1['length'] ?? 255;
            $length2 = $properties2['length'] ?? 255;
            if ($length1 !== $length2) {
                $changedProperties[] = 'length';
            }

            if ($properties1['fixed'] !== $properties2['fixed']) {
                $changedProperties[] = 'fixed';
            }
        } elseif ($properties1['type'] instanceof Types\DecimalType) {
            if (($properties1['precision'] ?? 10) !== ($properties2['precision'] ?? 10)) {
                $changedProperties[] = 'precision';
            }

            if ($properties1['scale'] !== $properties2['scale']) {
                $changedProperties[] = 'scale';
            }
        }

        // A null value and an empty string are actually equal for a comment so they should not trigger a change.
        if (
            $properties1['comment'] !== $properties2['comment'] &&
            ! ($properties1['comment'] === null && $properties2['comment'] === '') &&
            ! ($properties2['comment'] === null && $properties1['comment'] === '')
        ) {
            $changedProperties[] = 'comment';
        }

        $customOptions1 = $column1->getCustomSchemaOptions();
        $customOptions2 = $column2->getCustomSchemaOptions();

        foreach (array_merge(array_keys($customOptions1), array_keys($customOptions2)) as $key) {
            if (! array_key_exists($key, $properties1) || ! array_key_exists($key, $properties2)) {
                $changedProperties[] = $key;
            } elseif ($properties1[$key] !== $properties2[$key]) {
                $changedProperties[] = $key;
            }
        }

        $platformOptions1 = $column1->getPlatformOptions();
        $platformOptions2 = $column2->getPlatformOptions();

        foreach (array_keys(array_intersect_key($platformOptions1, $platformOptions2)) as $key) {
            if ($properties1[$key] === $properties2[$key]) {
                continue;
            }

            $changedProperties[] = $key;
        }

        return array_unique($changedProperties);
    }

    /**
     * Finds the difference between the indexes $index1 and $index2.
     *
     * Compares $index1 with $index2 and returns true if there are any
     * differences or false in case there are no differences.
     *
     * @internal The method should be only used from within the {@see Comparator} class hierarchy.
     *
     * @return bool
     */
    public function diffIndex(Index $index1, Index $index2)
    {
        return ! ($index1->isFulfilledBy($index2) && $index2->isFulfilledBy($index1));
    }
}
