<?php

/**
 * @file classes/core/DataObject.php
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2000-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class DataObject
 *
 * @ingroup core
 *
 * @see Core
 *
 * @brief Any class with an associated DAO should extend this class.
 */

namespace PKP\core;

use APP\core\Application;
use PKP\db\DAO;
use PKP\db\DAORegistry;
use \PKP\filter\FilterDAO;
use PKP\facades\Locale;

/**
 * @template T of EntityDAO|DAO
 */
class DataObject
{
    /** @var array Array of object data */
    public $_data = [];

    /** @var bool whether this objects loads meta-data adapters from the database */
    public $_hasLoadableAdapters = false;

    /** @var array an array of meta-data extraction adapters (one per supported schema) */
    public $_metadataExtractionAdapters = [];

    /** @var bool whether extraction adapters have already been loaded from the database */
    public $_extractionAdaptersLoaded = false;

    /** @var array an array of meta-data injection adapters (one per supported schema) */
    public $_metadataInjectionAdapters = [];

    /** @var bool whether injection adapters have already been loaded from the database */
    public $_injectionAdaptersLoaded = false;

    /**
     * Constructor
     */
    public function __construct()
    {
    }


    //
    // Getters and Setters
    //
    /**
     * Get a piece of data for this object, localized to the current
     * locale if possible.
     */
    public function getLocalizedData(string $key, string $preferredLocale = null, string &$selectedLocale = null): mixed
    {
        foreach ($this->getLocalePrecedence($preferredLocale) as $locale) {
            $value = & $this->getData($key, $locale);
            if (!empty($value)) {
                $selectedLocale = $locale;
                return $value;
            }
            unset($value);
        }

        // Fallback: Get the first available piece of data.
        $data = $this->getData($key, null);
        foreach ((array) $data as $locale => $dataValue) {
            if (!empty($dataValue)) {
                $selectedLocale = $locale;
                return $dataValue;
            }
        }

        return null;
    }

    /**
     * Get the locale precedence order for object in the following order
     * 
     * 1. Preferred Locale if provided
     * 2. User's current local
     * 3. Object's default locale if set
     * 4. Context's primary locale if context available
     * 5. Site's primary locale
     */
    public function getLocalePrecedence(string $preferredLocale = null): array
    {
        $request = Application::get()->getRequest();

        return array_unique(
            array_filter([
                $preferredLocale ?? Locale::getLocale(),
                $this->getDefaultLocale(),
                $request->getContext()?->getPrimaryLocale(),
                $request->getSite()->getPrimaryLocale(),
            ])
        );
    }

    /**
     * Get the default locale for object
     */
    public function getDefaultLocale(): ?string 
    {
        return null;
    }

    /**
     * Get the value of a data variable.
     *
     * @param string $key
     * @param string $locale (optional)
     *
     * @return mixed
     */
    public function &getData($key, $locale = null)
    {
        if (is_null($locale)) {
            if (array_key_exists($key, $this->_data)) {
                return $this->_data[$key];
            }
        } elseif (array_key_exists($locale, (array) ($this->_data[$key] ?? []))) {
            return $this->_data[$key][$locale];
        }
        $nullVar = null;
        return $nullVar;
    }

    /**
     * Set the value of a new or existing data variable.
     *
     * @param string $key
     * @param mixed $value can be either a single value or
     *  an array of of localized values in the form:
     *   array(
     *     'fr_FR' => 'en français',
     *     'en' => 'in English',
     *     ...
     *   )
     * @param string $locale (optional) non-null for a single
     *  localized value. Null for a non-localized value or
     *  when setting all locales at once (see comment for
     *  $value parameter)
     */
    public function setData($key, $value, $locale = null)
    {
        if (is_null($locale)) {
            // This is either a non-localized value or we're passing in all locales at once.
            $this->_data[$key] = $value;
            return;
        }
        // Set a single localized value.
        if (!is_null($value)) {
            if (isset($this->_data[$key]) && !is_array($this->_data[$key])) {
                $this->_data[$key] = [];
            }
            $this->_data[$key][$locale] = $value;
            return;
        }
        // If the value is null, remove the entry.
        if (array_key_exists($key, $this->_data)) {
            if (array_key_exists($locale, (array) $this->_data[$key])) {
                unset($this->_data[$key][$locale]);
            }
            // Was this the last entry for the data variable?
            if (empty($this->_data[$key])) {
                unset($this->_data[$key]);
            }
        }
    }

    /**
     * Unset an element of the data object.
     *
     * @param string $key
     * @param string $locale (optional) non-null for a single
     *  localized value. Null for a non-localized value or
     *  when unsetting all locales at once.
     */
    public function unsetData($key, $locale = null)
    {
        if (is_null($locale)) {
            unset($this->_data[$key]);
        } else {
            unset($this->_data[$key][$locale]);
        }
    }

    /**
     * Check whether a value exists for a given data variable.
     *
     * @param string $key
     * @param string $locale (optional)
     *
     * @return bool
     */
    public function hasData($key, $locale = null)
    {
        return is_null($locale) ? array_key_exists($key, $this->_data) : array_key_exists($locale, (array) ($this->_data[$key] ?? []));
    }

    /**
     * Return an array with all data variables.
     *
     * @return array
     */
    public function &getAllData()
    {
        return $this->_data;
    }

    /**
     * Set all data variables at once.
     *
     * @param array $data
     */
    public function setAllData($data)
    {
        $this->_data = $data;
    }

    /**
     * Get ID of object.
     *
     * @return int
     */
    public function getId()
    {
        return $this->getData('id');
    }

    /**
     * Set ID of object.
     *
     * @param int $id
     */
    public function setId($id)
    {
        $this->setData('id', $id);
    }


    //
    // MetadataProvider interface implementation
    //
    /**
     * Set whether the object has loadable meta-data adapters
     *
     * @param bool $hasLoadableAdapters
     */
    public function setHasLoadableAdapters($hasLoadableAdapters)
    {
        $this->_hasLoadableAdapters = $hasLoadableAdapters;
    }

    /**
     * Get whether the object has loadable meta-data adapters
     *
     * @return bool
     */
    public function getHasLoadableAdapters()
    {
        return $this->_hasLoadableAdapters;
    }

    /**
     * Add a meta-data adapter that will be supported
     * by this application entity. Only one adapter per schema
     * can be added.
     *
     * @param \PKP\metadata\MetadataDataObjectAdapter $metadataAdapter
     */
    public function addSupportedMetadataAdapter($metadataAdapter)
    {
        $metadataSchemaName = $metadataAdapter->getMetadataSchemaName();
        assert(!empty($metadataSchemaName));

        // NB: Some adapters are injectors and extractors at the same time,
        // notably the meta-data description dummy adapter that converts
        // from/to a meta-data description. That's why we have to check
        // input and output type separately.

        // Is this a meta-data extractor?
        $inputType = $metadataAdapter->getInputType();
        if ($inputType->checkType($this)) {
            if (!isset($this->_metadataExtractionAdapters[$metadataSchemaName])) {
                $this->_metadataExtractionAdapters[$metadataSchemaName] = $metadataAdapter;
            }
        }

        // Is this a meta-data injector?
        $outputType = $metadataAdapter->getOutputType();
        if ($outputType->checkType($this)) {
            if (!isset($this->_metadataInjectionAdapters[$metadataSchemaName])) {
                $this->_metadataInjectionAdapters[$metadataSchemaName] = $metadataAdapter;
            }
        }
    }

    /**
     * Remove all adapters for the given meta-data schema
     * (if it exists).
     *
     * @param string $metadataSchemaName fully qualified class name
     *
     * @return bool true if an adapter was removed, otherwise false.
     */
    public function removeSupportedMetadataAdapter($metadataSchemaName)
    {
        $result = false;
        if (isset($this->_metadataExtractionAdapters[$metadataSchemaName])) {
            unset($this->_metadataExtractionAdapters[$metadataSchemaName]);
            $result = true;
        }
        if (isset($this->_metadataInjectionAdapters[$metadataSchemaName])) {
            unset($this->_metadataInjectionAdapters[$metadataSchemaName]);
            $result = true;
        }
        return $result;
    }

    /**
     * Get all meta-data extraction adapters that
     * support this data object. This includes adapters
     * loaded from the database.
     *
     * @return array
     */
    public function getSupportedExtractionAdapters()
    {
        // Load meta-data adapters from the database.
        if ($this->getHasLoadableAdapters() && !$this->_extractionAdaptersLoaded) {
            $filterDao = DAORegistry::getDAO('FilterDAO'); /** @var FilterDAO $filterDao */
            $loadedAdapters = $filterDao->getObjectsByTypeDescription('class::%', 'metadata::%', $this);
            foreach ($loadedAdapters as $loadedAdapter) {
                $this->addSupportedMetadataAdapter($loadedAdapter);
            }
            $this->_extractionAdaptersLoaded = true;
        }

        return $this->_metadataExtractionAdapters;
    }

    /**
     * Get all meta-data injection adapters that
     * support this data object. This includes adapters
     * loaded from the database.
     *
     * @return array
     */
    public function getSupportedInjectionAdapters()
    {
        // Load meta-data adapters from the database.
        if ($this->getHasLoadableAdapters() && !$this->_injectionAdaptersLoaded) {
            $filterDao = DAORegistry::getDAO('FilterDAO'); /** @var FilterDAO $filterDao */
            $loadedAdapters = $filterDao->getObjectsByTypeDescription('metadata::%', 'class::%', $this, false);
            foreach ($loadedAdapters as $loadedAdapter) {
                $this->addSupportedMetadataAdapter($loadedAdapter);
            }
            $this->_injectionAdaptersLoaded = true;
        }

        return $this->_metadataInjectionAdapters;
    }

    /**
     * Returns all supported meta-data schemas
     * which are supported by extractor adapters.
     *
     * @return array
     */
    public function getSupportedMetadataSchemas()
    {
        $supportedMetadataSchemas = [];
        $extractionAdapters = $this->getSupportedExtractionAdapters();
        foreach ($extractionAdapters as $metadataAdapter) {
            $supportedMetadataSchemas[] = $metadataAdapter->getMetadataSchema();
        }
        return $supportedMetadataSchemas;
    }

    /**
     * Retrieve the names of meta-data
     * properties of this data object.
     *
     * @param bool $translated if true, return localized field
     *  names, otherwise return additional field names.
     */
    public function getMetadataFieldNames($translated = true)
    {
        // Create a list of all possible meta-data field names
        $metadataFieldNames = [];
        $extractionAdapters = $this->getSupportedExtractionAdapters();
        foreach ($extractionAdapters as $metadataAdapter) {
            // Add the field names from the current adapter
            $metadataFieldNames = array_merge(
                $metadataFieldNames,
                $metadataAdapter->getDataObjectMetadataFieldNames($translated)
            );
        }
        return array_unique($metadataFieldNames);
    }

    /**
     * Retrieve the names of meta-data
     * properties that need to be persisted
     * (i.e. that have data).
     *
     * @param bool $translated if true, return localized field
     *  names, otherwise return additional field names.
     *
     * @return array an array of field names
     */
    public function getSetMetadataFieldNames($translated = true)
    {
        // Retrieve a list of all possible meta-data field names
        $metadataFieldNameCandidates = $this->getMetadataFieldNames($translated);

        // Only retain those fields that have data
        $metadataFieldNames = [];
        foreach ($metadataFieldNameCandidates as $metadataFieldNameCandidate) {
            if ($this->hasData($metadataFieldNameCandidate)) {
                $metadataFieldNames[] = $metadataFieldNameCandidate;
            }
        }
        return $metadataFieldNames;
    }

    /**
     * Retrieve the names of translated meta-data
     * properties that need to be persisted.
     *
     * @return array an array of field names
     */
    public function getLocaleMetadataFieldNames()
    {
        return $this->getMetadataFieldNames(true);
    }

    /**
     * Retrieve the names of additional meta-data
     * properties that need to be persisted.
     *
     * @return array an array of field names
     */
    public function getAdditionalMetadataFieldNames()
    {
        return $this->getMetadataFieldNames(false);
    }

    /**
     * Inject a meta-data description into this
     * data object.
     *
     * @param \PKP\metadata\MetadataDescription $metadataDescription
     *
     * @return bool true on success, otherwise false
     */
    public function injectMetadata($metadataDescription)
    {
        $dataObject = null;
        $metadataSchemaName = $metadataDescription->getMetadataSchemaName();
        $injectionAdapters = $this->getSupportedInjectionAdapters();
        if (isset($injectionAdapters[$metadataSchemaName])) {
            // Get the meta-data adapter that supports the
            // given meta-data description's schema.
            $metadataAdapter = $injectionAdapters[$metadataSchemaName]; /** @var \PKP\metadata\MetadataDataObjectAdapter $metadataAdapter */

            // Pass in a reference to the data object which
            // the filter will use to update the current instance
            // of the data object.
            $metadataAdapter->setTargetDataObject($this);

            // Use adapter filter to convert from a meta-data
            // description to a data object.
            $dataObject = $metadataAdapter->execute($metadataDescription);
        }
        return $dataObject;
    }

    /**
     * Extract a meta-data description from this
     * data object.
     *
     * @param \PKP\metadata\MetadataSchema $metadataSchema
     *
     * @return $metadataDescription MetadataDescription
     */
    public function extractMetadata($metadataSchema)
    {
        $metadataDescription = null;
        $metadataSchemaName = $metadataSchema->getClassName();
        $extractionAdapters = $this->getSupportedExtractionAdapters();
        if (isset($extractionAdapters[$metadataSchemaName])) {
            // Get the meta-data adapter that supports the
            // given meta-data description's schema.
            $metadataAdapter = $extractionAdapters[$metadataSchemaName];

            // Use adapter filter to convert from a data object
            // to a meta-data description.
            $metadataDescription = $metadataAdapter->execute($this);
        }
        return $metadataDescription;
    }

    /**
     * Get DAO class for this object.
     *
     * @return T
     */
    public function getDAO()
    {
        assert(false);
    }
}

if (!PKP_STRICT_MODE) {
    class_alias('\PKP\core\DataObject', '\DataObject');
}
