<?php

/*
 * This file is part of the Doctrine Behavioral Extensions package.
 * (c) Gediminas Morkevicius <gediminas.morkevicius@gmail.com> http://www.gediminasm.org
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Gedmo\Translatable;

use Doctrine\Common\EventArgs;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ORM\ORMInvalidArgumentException;
use Doctrine\Persistence\Event\LoadClassMetadataEventArgs;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\ObjectManager;
use Gedmo\Exception\InvalidArgumentException;
use Gedmo\Exception\RuntimeException;
use Gedmo\Mapping\MappedEventSubscriber;
use Gedmo\Tool\Wrapper\AbstractWrapper;
use Gedmo\Translatable\Mapping\Event\TranslatableAdapter;

/**
 * The translation listener handles the generation and
 * loading of translations for entities which implements
 * the Translatable interface.
 *
 * This behavior can impact the performance of your application
 * since it does an additional query for each field to translate.
 *
 * Nevertheless the annotation metadata is properly cached and
 * it is not a big overhead to lookup all entity annotations since
 * the caching is activated for metadata
 *
 * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
 *
 * @phpstan-type TranslatableConfiguration = array{
 *   fields?: string[],
 *   fallback?: array<string, bool>,
 *   locale?: string,
 *   translationClass?: class-string,
 *   useObjectClass?: class-string,
 * }
 *
 * @phpstan-method TranslatableConfiguration getConfiguration(ObjectManager $objectManager, $class)
 *
 * @method TranslatableAdapter getEventAdapter(EventArgs $args)
 *
 * @final since gedmo/doctrine-extensions 3.11
 */
class TranslatableListener extends MappedEventSubscriber
{
    /**
     * Query hint to override the fallback of translations
     * integer 1 for true, 0 false
     */
    public const HINT_FALLBACK = 'gedmo.translatable.fallback';

    /**
     * Query hint to override the fallback locale
     */
    public const HINT_TRANSLATABLE_LOCALE = 'gedmo.translatable.locale';

    /**
     * Query hint to use inner join strategy for translations
     */
    public const HINT_INNER_JOIN = 'gedmo.translatable.inner_join.translations';

    /**
     * Locale which is set on this listener.
     * If Entity being translated has locale defined it
     * will override this one
     *
     * @var string
     */
    protected $locale = 'en_US';

    /**
     * Default locale, this changes behavior
     * to not update the original record field if locale
     * which is used for updating is not default. This
     * will load the default translation in other locales
     * if record is not translated yet
     *
     * @var string
     */
    private $defaultLocale = 'en_US';

    /**
     * If this is set to false, when if entity does
     * not have a translation for requested locale
     * it will show a blank value
     *
     * @var bool
     */
    private $translationFallback = false;

    /**
     * List of translations which do not have the foreign
     * key generated yet - MySQL case. These translations
     * will be updated with new keys on postPersist event
     *
     * @var array<int, array<int, object|Translatable>>
     */
    private $pendingTranslationInserts = [];

    /**
     * Currently in case if there is TranslationQueryWalker
     * in charge. We need to skip issuing additional queries
     * on load
     *
     * @var bool
     */
    private $skipOnLoad = false;

    /**
     * Tracks locale the objects currently translated in
     *
     * @var array<int, string>
     */
    private $translatedInLocale = [];

    /**
     * Whether or not, to persist default locale
     * translation or keep it in original record
     *
     * @var bool
     */
    private $persistDefaultLocaleTranslation = false;

    /**
     * Tracks translation object for default locale
     *
     * @var array<int, array<string, object|Translatable>>
     */
    private $translationInDefaultLocale = [];

    /**
     * Default translation value upon missing translation
     *
     * @var string|null
     */
    private $defaultTranslationValue;

    /**
     * Specifies the list of events to listen
     *
     * @return string[]
     */
    public function getSubscribedEvents()
    {
        return [
            'postLoad',
            'postPersist',
            'preFlush',
            'onFlush',
            'loadClassMetadata',
        ];
    }

    /**
     * Set to skip or not onLoad event
     *
     * @param bool $bool
     *
     * @return static
     */
    public function setSkipOnLoad($bool)
    {
        $this->skipOnLoad = (bool) $bool;

        return $this;
    }

    /**
     * Whether or not, to persist default locale
     * translation or keep it in original record
     *
     * @param bool $bool
     *
     * @return static
     */
    public function setPersistDefaultLocaleTranslation($bool)
    {
        $this->persistDefaultLocaleTranslation = (bool) $bool;

        return $this;
    }

    /**
     * Check if should persist default locale
     * translation or keep it in original record
     *
     * @return bool
     */
    public function getPersistDefaultLocaleTranslation()
    {
        return (bool) $this->persistDefaultLocaleTranslation;
    }

    /**
     * Add additional $translation for pending $oid object
     * which is being inserted
     *
     * @param int    $oid
     * @param object $translation
     *
     * @return void
     */
    public function addPendingTranslationInsert($oid, $translation)
    {
        $this->pendingTranslationInserts[$oid][] = $translation;
    }

    /**
     * Maps additional metadata
     *
     * @param LoadClassMetadataEventArgs $eventArgs
     *
     * @return void
     */
    public function loadClassMetadata(EventArgs $eventArgs)
    {
        $this->loadMetadataForObjectClass($eventArgs->getObjectManager(), $eventArgs->getClassMetadata());
    }

    /**
     * Get the translation class to be used
     * for the object $class
     *
     * @param string $class
     * @phpstan-param class-string $class
     *
     * @return string
     * @phpstan-return class-string
     */
    public function getTranslationClass(TranslatableAdapter $ea, $class)
    {
        return self::$configurations[$this->name][$class]['translationClass'] ?? $ea->getDefaultTranslationClass()
        ;
    }

    /**
     * Enable or disable translation fallback
     * to original record value
     *
     * @param bool $bool
     *
     * @return static
     */
    public function setTranslationFallback($bool)
    {
        $this->translationFallback = (bool) $bool;

        return $this;
    }

    /**
     * Weather or not is using the translation
     * fallback to original record
     *
     * @return bool
     */
    public function getTranslationFallback()
    {
        return $this->translationFallback;
    }

    /**
     * Set the locale to use for translation listener
     *
     * @param string $locale
     *
     * @return static
     */
    public function setTranslatableLocale($locale)
    {
        $this->validateLocale($locale);
        $this->locale = $locale;

        return $this;
    }

    /**
     * Set the default translation value on missing translation
     *
     * @deprecated usage of a non nullable value for defaultTranslationValue is deprecated
     * and will be removed on the next major release which will rely on the expected types
     */
    public function setDefaultTranslationValue(?string $defaultTranslationValue): void
    {
        $this->defaultTranslationValue = $defaultTranslationValue;
    }

    /**
     * Sets the default locale, this changes behavior
     * to not update the original record field if locale
     * which is used for updating is not default
     *
     * @param string $locale
     *
     * @return static
     */
    public function setDefaultLocale($locale)
    {
        $this->validateLocale($locale);
        $this->defaultLocale = $locale;

        return $this;
    }

    /**
     * Gets the default locale
     *
     * @return string
     */
    public function getDefaultLocale()
    {
        return $this->defaultLocale;
    }

    /**
     * Get currently set global locale, used
     * extensively during query execution
     *
     * @return string
     */
    public function getListenerLocale()
    {
        return $this->locale;
    }

    /**
     * Gets the locale to use for translation. Loads object
     * defined locale first.
     *
     * @param object        $object
     * @param ClassMetadata $meta
     * @param object        $om
     *
     * @throws RuntimeException if language or locale property is not found in entity
     *
     * @return string
     */
    public function getTranslatableLocale($object, $meta, $om = null)
    {
        $locale = $this->locale;
        $configurationLocale = self::$configurations[$this->name][$meta->getName()]['locale'] ?? null;
        if (null !== $configurationLocale) {
            $class = $meta->getReflectionClass();
            if (!$class->hasProperty($configurationLocale)) {
                throw new RuntimeException("There is no locale or language property ({$configurationLocale}) found on object: {$meta->getName()}");
            }
            $reflectionProperty = $class->getProperty($configurationLocale);
            $reflectionProperty->setAccessible(true);
            $value = $reflectionProperty->getValue($object);
            if (is_object($value) && method_exists($value, '__toString')) {
                $value = $value->__toString();
            }
            if ($this->isValidLocale($value)) {
                $locale = $value;
            }
        } elseif ($om instanceof DocumentManager) {
            [$mapping, $parentObject] = $om->getUnitOfWork()->getParentAssociation($object);
            if (null !== $parentObject) {
                $parentMeta = $om->getClassMetadata(get_class($parentObject));
                $locale = $this->getTranslatableLocale($parentObject, $parentMeta, $om);
            }
        }

        return $locale;
    }

    /**
     * Handle translation changes in default locale
     *
     * This has to be done in the preFlush because, when an entity has been loaded
     * in a different locale, no changes will be detected.
     *
     * @return void
     */
    public function preFlush(EventArgs $args)
    {
        $ea = $this->getEventAdapter($args);
        $om = $ea->getObjectManager();
        $uow = $om->getUnitOfWork();

        foreach ($this->translationInDefaultLocale as $oid => $fields) {
            $trans = reset($fields);

            assert(false !== $trans);

            if ($ea->usesPersonalTranslation(get_class($trans))) {
                $entity = $trans->getObject();
            } else {
                $entity = $uow->tryGetById($trans->getForeignKey(), $trans->getObjectClass());
            }

            if (!$entity) {
                continue;
            }

            try {
                $uow->scheduleForUpdate($entity);
            } catch (ORMInvalidArgumentException $e) {
                foreach ($fields as $field => $trans) {
                    $this->removeTranslationInDefaultLocale($oid, $field);
                }
            }
        }
    }

    /**
     * Looks for translatable objects being inserted or updated
     * for further processing
     *
     * @return void
     */
    public function onFlush(EventArgs $args)
    {
        $ea = $this->getEventAdapter($args);
        $om = $ea->getObjectManager();
        $uow = $om->getUnitOfWork();
        // check all scheduled inserts for Translatable objects
        foreach ($ea->getScheduledObjectInsertions($uow) as $object) {
            $meta = $om->getClassMetadata(get_class($object));
            $config = $this->getConfiguration($om, $meta->getName());
            if (isset($config['fields'])) {
                $this->handleTranslatableObjectUpdate($ea, $object, true);
            }
        }
        // check all scheduled updates for Translatable entities
        foreach ($ea->getScheduledObjectUpdates($uow) as $object) {
            $meta = $om->getClassMetadata(get_class($object));
            $config = $this->getConfiguration($om, $meta->getName());
            if (isset($config['fields'])) {
                $this->handleTranslatableObjectUpdate($ea, $object, false);
            }
        }
        // check scheduled deletions for Translatable entities
        foreach ($ea->getScheduledObjectDeletions($uow) as $object) {
            $meta = $om->getClassMetadata(get_class($object));
            $config = $this->getConfiguration($om, $meta->getName());
            if (isset($config['fields'])) {
                $wrapped = AbstractWrapper::wrap($object, $om);
                $transClass = $this->getTranslationClass($ea, $meta->getName());
                \assert($wrapped instanceof AbstractWrapper);
                $ea->removeAssociatedTranslations($wrapped, $transClass, $config['useObjectClass']);
            }
        }
    }

    /**
     * Checks for inserted object to update their translation
     * foreign keys
     *
     * @return void
     */
    public function postPersist(EventArgs $args)
    {
        $ea = $this->getEventAdapter($args);
        $om = $ea->getObjectManager();
        $object = $ea->getObject();
        $meta = $om->getClassMetadata(get_class($object));
        // check if entity is tracked by translatable and without foreign key
        if ($this->getConfiguration($om, $meta->getName()) && [] !== $this->pendingTranslationInserts) {
            $oid = spl_object_id($object);
            if (array_key_exists($oid, $this->pendingTranslationInserts)) {
                // load the pending translations without key
                $wrapped = AbstractWrapper::wrap($object, $om);
                $objectId = $wrapped->getIdentifier();
                $translationClass = $this->getTranslationClass($ea, get_class($object));
                foreach ($this->pendingTranslationInserts[$oid] as $translation) {
                    if ($ea->usesPersonalTranslation($translationClass)) {
                        $translation->setObject($objectId);
                    } else {
                        $translation->setForeignKey($objectId);
                    }
                    $ea->insertTranslationRecord($translation);
                }
                unset($this->pendingTranslationInserts[$oid]);
            }
        }
    }

    /**
     * After object is loaded, listener updates the translations
     * by currently used locale
     *
     * @return void
     */
    public function postLoad(EventArgs $args)
    {
        $ea = $this->getEventAdapter($args);
        $om = $ea->getObjectManager();
        $object = $ea->getObject();
        $meta = $om->getClassMetadata(get_class($object));
        $config = $this->getConfiguration($om, $meta->getName());
        $locale = $this->defaultLocale;
        $oid = null;
        if (isset($config['fields'])) {
            $locale = $this->getTranslatableLocale($object, $meta, $om);
            $oid = spl_object_id($object);
            $this->translatedInLocale[$oid] = $locale;
        }

        if ($this->skipOnLoad) {
            return;
        }

        if (isset($config['fields']) && ($locale !== $this->defaultLocale || $this->persistDefaultLocaleTranslation)) {
            // fetch translations
            $translationClass = $this->getTranslationClass($ea, $config['useObjectClass']);
            $result = $ea->loadTranslations(
                $object,
                $translationClass,
                $locale,
                $config['useObjectClass']
            );
            // translate object's translatable properties
            foreach ($config['fields'] as $field) {
                $translated = $this->defaultTranslationValue;

                foreach ($result as $entry) {
                    if ($entry['field'] == $field) {
                        $translated = $entry['content'] ?? null;

                        break;
                    }
                }

                // update translation
                if ($this->defaultTranslationValue !== $translated
                    || (!$this->translationFallback && (!isset($config['fallback'][$field]) || !$config['fallback'][$field]))
                    || ($this->translationFallback && isset($config['fallback'][$field]) && !$config['fallback'][$field])
                ) {
                    $ea->setTranslationValue($object, $field, $translated);
                    // ensure clean changeset
                    $ea->setOriginalObjectProperty(
                        $om->getUnitOfWork(),
                        $object,
                        $field,
                        $meta->getReflectionProperty($field)->getValue($object)
                    );
                }
            }
        }
    }

    /**
     * Sets translation object which represents translation in default language.
     *
     * @param int                 $oid   hash of basic entity
     * @param string              $field field of basic entity
     * @param object|Translatable $trans Translation object
     *
     * @return void
     */
    public function setTranslationInDefaultLocale($oid, $field, $trans)
    {
        if (!isset($this->translationInDefaultLocale[$oid])) {
            $this->translationInDefaultLocale[$oid] = [];
        }
        $this->translationInDefaultLocale[$oid][$field] = $trans;
    }

    /**
     * @return bool
     */
    public function isSkipOnLoad()
    {
        return $this->skipOnLoad;
    }

    /**
     * Check if object has any translation object which represents translation in default language.
     * This is for internal use only.
     *
     * @param int $oid hash of the basic entity
     *
     * @return bool
     */
    public function hasTranslationsInDefaultLocale($oid)
    {
        return array_key_exists($oid, $this->translationInDefaultLocale);
    }

    protected function getNamespace()
    {
        return __NAMESPACE__;
    }

    /**
     * Validates the given locale
     *
     * @param string $locale locale to validate
     *
     * @throws InvalidArgumentException if locale is not valid
     *
     * @return void
     */
    protected function validateLocale($locale)
    {
        if (!$this->isValidLocale($locale)) {
            throw new InvalidArgumentException('Locale or language cannot be empty and must be set through Listener or Entity');
        }
    }

    /**
     * Check if the given locale is valid
     */
    private function isValidLocale(?string $locale): bool
    {
        return is_string($locale) && strlen($locale);
    }

    /**
     * Creates the translation for object being flushed
     *
     * @throws \UnexpectedValueException if locale is not valid, or
     *                                   primary key is composite, missing or invalid
     */
    private function handleTranslatableObjectUpdate(TranslatableAdapter $ea, object $object, bool $isInsert): void
    {
        $om = $ea->getObjectManager();
        $wrapped = AbstractWrapper::wrap($object, $om);
        $meta = $wrapped->getMetadata();
        $config = $this->getConfiguration($om, $meta->getName());
        // no need cache, metadata is loaded only once in MetadataFactoryClass
        $translationClass = $this->getTranslationClass($ea, $config['useObjectClass']);
        $translationMetadata = $om->getClassMetadata($translationClass);

        // check for the availability of the primary key
        $objectId = $wrapped->getIdentifier();
        // load the currently used locale
        $locale = $this->getTranslatableLocale($object, $meta, $om);

        $uow = $om->getUnitOfWork();
        $oid = spl_object_id($object);
        $changeSet = $ea->getObjectChangeSet($uow, $object);
        $translatableFields = $config['fields'];
        foreach ($translatableFields as $field) {
            $wasPersistedSeparetely = false;
            $skip = isset($this->translatedInLocale[$oid]) && $locale === $this->translatedInLocale[$oid];
            $skip = $skip && !isset($changeSet[$field]) && !$this->getTranslationInDefaultLocale($oid, $field);
            if ($skip) {
                continue; // locale is same and nothing changed
            }
            $translation = null;
            foreach ($ea->getScheduledObjectInsertions($uow) as $trans) {
                if ($locale !== $this->defaultLocale
                    && get_class($trans) === $translationClass
                    && $trans->getLocale() === $this->defaultLocale
                    && $trans->getField() === $field
                    && $this->belongsToObject($ea, $trans, $object)) {
                    $this->setTranslationInDefaultLocale($oid, $field, $trans);

                    break;
                }
            }

            // lookup persisted translations
            foreach ($ea->getScheduledObjectInsertions($uow) as $trans) {
                if (get_class($trans) !== $translationClass
                    || $trans->getLocale() !== $locale
                    || $trans->getField() !== $field) {
                    continue;
                }

                if ($ea->usesPersonalTranslation($translationClass)) {
                    $wasPersistedSeparetely = $trans->getObject() === $object;
                } else {
                    $wasPersistedSeparetely = $trans->getObjectClass() === $config['useObjectClass']
                        && $trans->getForeignKey() === $objectId;
                }

                if ($wasPersistedSeparetely) {
                    $translation = $trans;

                    break;
                }
            }

            // check if translation already is created
            if (!$isInsert && !$translation) {
                \assert($wrapped instanceof AbstractWrapper);

                $translation = $ea->findTranslation(
                    $wrapped,
                    $locale,
                    $field,
                    $translationClass,
                    $config['useObjectClass']
                );
            }

            // create new translation if translation not already created and locale is different from default locale, otherwise, we have the date in the original record
            $persistNewTranslation = !$translation
                && ($locale !== $this->defaultLocale || $this->persistDefaultLocaleTranslation)
            ;
            if ($persistNewTranslation) {
                $translation = $translationMetadata->newInstance();
                $translation->setLocale($locale);
                $translation->setField($field);
                if ($ea->usesPersonalTranslation($translationClass)) {
                    $translation->setObject($object);
                } else {
                    $translation->setObjectClass($config['useObjectClass']);
                    $translation->setForeignKey($objectId);
                }
            }

            if ($translation) {
                // set the translated field, take value using reflection
                $content = $ea->getTranslationValue($object, $field);
                $translation->setContent($content);
                // check if need to update in database
                $transWrapper = AbstractWrapper::wrap($translation, $om);
                if (((null === $content && !$isInsert) || is_bool($content) || is_int($content) || is_string($content) || !empty($content)) && ($isInsert || !$transWrapper->getIdentifier() || isset($changeSet[$field]))) {
                    if ($isInsert && !$objectId && !$ea->usesPersonalTranslation($translationClass)) {
                        // if we do not have the primary key yet available
                        // keep this translation in memory to insert it later with foreign key
                        $this->pendingTranslationInserts[spl_object_id($object)][] = $translation;
                    } else {
                        // persist and compute change set for translation
                        if ($wasPersistedSeparetely) {
                            $ea->recomputeSingleObjectChangeset($uow, $translationMetadata, $translation);
                        } else {
                            $om->persist($translation);
                            $uow->computeChangeSet($translationMetadata, $translation);
                        }
                    }
                }
            }

            if ($isInsert && null !== $this->getTranslationInDefaultLocale($oid, $field)) {
                // We can't rely on object field value which is created in non-default locale.
                // If we provide translation for default locale as well, the latter is considered to be trusted
                // and object content should be overridden.
                $wrapped->setPropertyValue($field, $this->getTranslationInDefaultLocale($oid, $field)->getContent());
                $ea->recomputeSingleObjectChangeset($uow, $meta, $object);
                $this->removeTranslationInDefaultLocale($oid, $field);
            }
        }
        $this->translatedInLocale[$oid] = $locale;
        // check if we have default translation and need to reset the translation
        if (!$isInsert && strlen($this->defaultLocale)) {
            $this->validateLocale($this->defaultLocale);
            $modifiedChangeSet = $changeSet;
            foreach ($changeSet as $field => $changes) {
                if (in_array($field, $translatableFields, true)) {
                    if ($locale !== $this->defaultLocale) {
                        $ea->setOriginalObjectProperty($uow, $object, $field, $changes[0]);
                        unset($modifiedChangeSet[$field]);
                    }
                }
            }
            $ea->recomputeSingleObjectChangeset($uow, $meta, $object);
            // cleanup current changeset only if working in a another locale different than de default one, otherwise the changeset will always be reverted
            if ($locale !== $this->defaultLocale) {
                $ea->clearObjectChangeSet($uow, $object);
                // recompute changeset only if there are changes other than reverted translations
                if ($modifiedChangeSet || $this->hasTranslationsInDefaultLocale($oid)) {
                    foreach ($modifiedChangeSet as $field => $changes) {
                        $ea->setOriginalObjectProperty($uow, $object, $field, $changes[0]);
                    }
                    foreach ($translatableFields as $field) {
                        if (null !== $this->getTranslationInDefaultLocale($oid, $field)) {
                            $wrapped->setPropertyValue($field, $this->getTranslationInDefaultLocale($oid, $field)->getContent());
                            $this->removeTranslationInDefaultLocale($oid, $field);
                        }
                    }
                    $ea->recomputeSingleObjectChangeset($uow, $meta, $object);
                }
            }
        }
    }

    /**
     * Removes translation object which represents translation in default language.
     * This is for internal use only.
     *
     * @param int    $oid   hash of the basic entity
     * @param string $field field of basic entity
     */
    private function removeTranslationInDefaultLocale(int $oid, string $field): void
    {
        if (isset($this->translationInDefaultLocale[$oid])) {
            if (isset($this->translationInDefaultLocale[$oid][$field])) {
                unset($this->translationInDefaultLocale[$oid][$field]);
            }
            if (!$this->translationInDefaultLocale[$oid]) {
                // We removed the final remaining elements from the
                // translationInDefaultLocale[$oid] array, so we might as well
                // completely remove the entry at $oid.
                unset($this->translationInDefaultLocale[$oid]);
            }
        }
    }

    /**
     * Gets translation object which represents translation in default language.
     * This is for internal use only.
     *
     * @param int    $oid   hash of the basic entity
     * @param string $field field of basic entity
     *
     * @return object|Translatable|null Returns translation object if it exists or NULL otherwise
     */
    private function getTranslationInDefaultLocale(int $oid, string $field)
    {
        return $this->translationInDefaultLocale[$oid][$field] ?? null;
    }

    /**
     * Checks if the translation entity belongs to the object in question
     */
    private function belongsToObject(TranslatableAdapter $ea, object $trans, object $object): bool
    {
        if ($ea->usesPersonalTranslation(get_class($trans))) {
            return $trans->getObject() === $object;
        }

        return $trans->getForeignKey() === $object->getId()
            && ($trans->getObjectClass() === get_class($object));
    }
}
