<?php

namespace Mautic\CoreBundle\Doctrine\Mapping;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder as OrmClassMetadataBuilder;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\LeadBundle\Entity\Lead;

/**
 * Override Doctrine's builder classes to add support to orphanRemoval until the fix is incorporated into Doctrine release
 * See @see https://github.com/doctrine/doctrine2/pull/1326/.
 */
class ClassMetadataBuilder extends OrmClassMetadataBuilder
{
    /**
     * Max length of indexed VARCHAR fields for UTF8MB4 encoding.
     */
    public const MAX_VARCHAR_INDEXED_LENGTH = 191;

    public function __construct(ClassMetadataInfo $cm)
    {
        parent::__construct($cm);

        // Default all Mautic entities to explicit
        $this->setChangeTrackingPolicyDeferredExplicit();
    }

    /**
     * Creates a ManyToOne Association Builder.
     *
     * Note: This method does not add the association, you have to call build() on the AssociationBuilder.
     *
     * @param string $name
     * @param string $targetEntity
     *
     * @return AssociationBuilder
     */
    public function createManyToOne($name, $targetEntity)
    {
        return new AssociationBuilder(
            $this,
            [
                'fieldName'    => $name,
                'targetEntity' => $targetEntity,
            ],
            ClassMetadata::MANY_TO_ONE
        );
    }

    /**
     * Creates a OneToOne Association Builder.
     *
     * @param string $name
     * @param string $targetEntity
     *
     * @return AssociationBuilder
     */
    public function createOneToOne($name, $targetEntity)
    {
        return new AssociationBuilder(
            $this,
            [
                'fieldName'    => $name,
                'targetEntity' => $targetEntity,
            ],
            ClassMetadata::ONE_TO_ONE
        );
    }

    /**
     * Creates a ManyToMany Association Builder.
     *
     * @param string $name
     * @param string $targetEntity
     *
     * @return ManyToManyAssociationBuilder
     */
    public function createManyToMany($name, $targetEntity)
    {
        return new ManyToManyAssociationBuilder(
            $this,
            [
                'fieldName'    => $name,
                'targetEntity' => $targetEntity,
            ],
            ClassMetadata::MANY_TO_MANY
        );
    }

    /**
     * Creates a one to many association builder.
     *
     * @param string $name
     * @param string $targetEntity
     *
     * @return OneToManyAssociationBuilder
     */
    public function createOneToMany($name, $targetEntity)
    {
        return new OneToManyAssociationBuilder(
            $this,
            [
                'fieldName'    => $name,
                'targetEntity' => $targetEntity,
            ],
            ClassMetadata::ONE_TO_MANY
        );
    }

    /**
     * Add Id column.
     *
     * @return $this
     */
    public function addId()
    {
        $this->createField('id', Types::INTEGER)
            ->makePrimaryKey()
            ->generatedValue()
            ->option('unsigned', true)
            ->build();

        return $this;
    }

    /**
     * Adds autogenerated ID field type of BIGINT UNSIGNED.
     *
     * @param string $columnName
     * @param bool   $isPrimary
     * @param bool   $isNullable
     *
     * @return ClassMetadataBuilder
     */
    public function addBigIntIdField($fieldName = 'id', $columnName = 'id', $isPrimary = true, $isNullable = false)
    {
        $cm = $this->getClassMetadata();
        $cm->mapField(
            [
                'fieldName'  => $fieldName,
                'columnName' => $columnName,
                'id'         => $isPrimary,
                'nullable'   => $isNullable,
                'type'       => Types::BIGINT,
                'options'    => [
                    'unsigned' => true,
                ],
            ]
        );

        if ($isPrimary) {
            $cm->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO);
        }

        return $this;
    }

    /**
     * Add UUID as Id.
     *
     * @return $this
     */
    public function addUuid()
    {
        $this->createField('id', 'guid')
            ->makePrimaryKey()
            ->build();

        return $this;
    }

    /**
     * Add id, name, and description columns.
     *
     * @param string $nameColumn
     * @param string $descriptionColumn
     *
     * @return $this
     */
    public function addIdColumns($nameColumn = 'name', $descriptionColumn = 'description')
    {
        $this->addId();

        if ($nameColumn) {
            $this->createField($nameColumn, Types::STRING)
                ->build();
        }

        if ($descriptionColumn) {
            $this->createField($descriptionColumn, Types::TEXT)
                ->nullable()
                ->build();
        }

        return $this;
    }

    /**
     * Add category to metadata.
     *
     * @return $this
     */
    public function addCategory()
    {
        $this->createManyToOne('category', Category::class)
            ->cascadeMerge()
            ->cascadeDetach()
            ->addJoinColumn('category_id', 'id', true, false, 'SET NULL')
            ->build();

        return $this;
    }

    /**
     * Add publish up and down dates to metadata.
     *
     * @return $this
     */
    public function addPublishDates()
    {
        $this->createField('publishUp', Types::DATETIME_MUTABLE)
            ->columnName('publish_up')
            ->nullable()
            ->build();

        $this->createField('publishDown', Types::DATETIME_MUTABLE)
            ->columnName('publish_down')
            ->nullable()
            ->build();

        return $this;
    }

    /**
     * Added dateAdded column.
     *
     * @param bool|false $nullable
     *
     * @return $this
     */
    public function addDateAdded($nullable = false)
    {
        $dateAdded = $this->createField('dateAdded', Types::DATETIME_MUTABLE)
            ->columnName('date_added');

        if ($nullable) {
            $dateAdded->nullable();
        }

        $dateAdded->build();

        return $this;
    }

    /**
     * Add a contact column.
     *
     * @param bool|false  $nullable
     * @param string      $onDelete
     * @param bool|false  $isPrimaryKey
     * @param string|null $inversedBy
     *
     * @return $this
     */
    public function addContact($nullable = false, $onDelete = 'CASCADE', $isPrimaryKey = false, $inversedBy = null)
    {
        $lead = $this->createManyToOne('contact', Lead::class);

        if ($isPrimaryKey) {
            $lead->makePrimaryKey();
        }

        if ($inversedBy) {
            $lead->inversedBy($inversedBy);
        }

        $lead
            ->addJoinColumn('contact_id', 'id', $nullable, false, $onDelete)
            ->build();

        return $this;
    }

    /**
     * Add a lead column.
     *
     * @param bool|false $nullable
     * @param string     $onDelete
     * @param bool|false $isPrimaryKey
     *
     * @deprecated Use addContact instead; existing implementations will need a migration to rename lead_id to contact_id
     *
     * @return $this
     */
    public function addLead($nullable = false, $onDelete = 'CASCADE', $isPrimaryKey = false, $inversedBy = null)
    {
        $lead = $this->createManyToOne('lead', Lead::class);

        if ($isPrimaryKey) {
            $lead->makePrimaryKey();
        }

        if ($inversedBy) {
            $lead->inversedBy($inversedBy);
        }

        $lead
            ->addJoinColumn('lead_id', 'id', $nullable, false, $onDelete)
            ->build();

        return $this;
    }

    /**
     * Adds IP address.
     *
     * @param bool $nullable
     *
     * @return $this
     */
    public function addIpAddress($nullable = false)
    {
        $this->createManyToOne('ipAddress', IpAddress::class)
            ->cascadePersist()
            ->cascadeMerge()
            ->cascadeDetach()
            ->addJoinColumn('ip_id', 'id', $nullable, false, 'SET NULL')
            ->build();

        return $this;
    }

    /**
     * Add a nullable field.
     *
     * @param string      $name
     * @param string      $type
     * @param string|null $columnName
     *
     * @return $this
     */
    public function addNullableField($name, $type = Types::STRING, $columnName = null)
    {
        $field = $this->createField($name, $type)
            ->nullable();

        if (null !== $columnName) {
            $field->columnName($columnName);
        }

        if ($this->isIndexedVarchar($columnName ?? $name, $type)) {
            $field->length(self::MAX_VARCHAR_INDEXED_LENGTH);
        }

        $field->build();

        return $this;
    }

    /**
     * Add a field with a custom column name.
     *
     * @param bool $nullable
     *
     * @return $this
     */
    public function addNamedField($name, $type, $columnName, $nullable = false)
    {
        $field = $this->createField($name, $type)
            ->columnName($columnName);

        if ($nullable) {
            $field->nullable();
        }

        if ($this->isIndexedVarchar($columnName ?? $name, $type)) {
            $field->length(self::MAX_VARCHAR_INDEXED_LENGTH);
        }

        $field->build();

        return $this;
    }

    /**
     * Adds Field. Overridden for IDE suggestions when stringing methods in entity class.
     *
     * @param string $name
     * @param string $type
     *
     * @return $this
     */
    public function addField($name, $type, array $mapping = [])
    {
        if ($this->isIndexedVarchar($name, $type)) {
            $mapping['length'] = self::MAX_VARCHAR_INDEXED_LENGTH;
        }

        return parent::addField($name, $type, $mapping);
    }

    public function createField($name, $type)
    {
        $mapping = [
            'fieldName' => $name,
            'type'      => $type,
        ];

        if ($this->isIndexedVarchar($name, $type)) {
            $mapping['length'] = self::MAX_VARCHAR_INDEXED_LENGTH;
        }

        return new FieldBuilder($this, $mapping);
    }

    /**
     * @param string  $name
     * @param mixed[] $flags
     * @param mixed[] $options
     */
    public function addIndex(array $columns, $name, array $flags = null, array $options = null): self
    {
        $cm = $this->getClassMetadata();

        if (!isset($cm->table['indexes'])) {
            $cm->table['indexes'] = [];
        }

        $definition = ['columns' => $columns];

        if (null !== $flags) {
            $definition['flags'] = $flags;
        }

        if (null !== $options) {
            $definition['options'] = $options;
        }

        $cm->table['indexes'][$name] = $definition;

        return $this;
    }

    /**
     * @deprecated this method will be removed as MySQL does not support partial indices whatsoever
     *
     * @param string $name
     * @param string $where
     */
    public function addPartialIndex(array $columns, $name, $where): ClassMetadataBuilder
    {
        return $this->addIndex($columns, $name, null, ['where' => $where]);
    }

    /**
     * @param mixed[] $columns
     */
    public function addFulltextIndex(array $columns, string $name): self
    {
        return $this->addIndex($columns, $name, ['fulltext']);
    }

    /**
     * UTF8MB4 encoding needs max length of 191 instead of 255 that UTF8 needed. Doctrine does not take care of it by itself.
     */
    public function isIndexedVarchar(string $name, string $type): bool
    {
        return Types::STRING === $type || isset($this->getClassMetadata()->table['indexes'][$name]);
    }

    /**
     * Adds Index with options.
     *
     * @param list<string>         $columns
     * @param array<string, mixed> $options
     */
    public function addIndexWithOptions(array $columns, string $name, array $options): ClassMetadataBuilder
    {
        $cm = $this->getClassMetadata();

        $cm->table['indexes'][$name] = [
            'columns' => $columns,
            'options' => $options,
        ];

        return $this;
    }
}
