<?php

namespace SilverStripe\ORM;

use BadMethodCallException;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\Queries\SQLSelect;
use SilverStripe\ORM\Queries\SQLDelete;
use SilverStripe\ORM\FieldType\DBComposite;
use InvalidArgumentException;
use Exception;

/**
 * Subclass of {@link DataList} representing a many_many relation.
 */
class ManyManyList extends RelationList
{

    /**
     * @var string $joinTable
     */
    protected $joinTable;

    /**
     * @var string $localKey
     */
    protected $localKey;

    /**
     * @var string $foreignKey
     */
    protected $foreignKey;

    /**
     * @var array $extraFields
     */
    protected $extraFields;

    /**
     * @var array $_compositeExtraFields
     */
    protected $_compositeExtraFields = [];

    /**
     * Create a new ManyManyList object.
     *
     * A ManyManyList object represents a list of {@link DataObject} records
     * that correspond to a many-many relationship.
     *
     * Generation of the appropriate record set is left up to the caller, using
     * the normal {@link DataList} methods. Addition arguments are used to
     * support {@link add()} and {@link remove()} methods.
     *
     * @param string $dataClass The class of the DataObjects that this will list.
     * @param string $joinTable The name of the table whose entries define the content of this many_many relation.
     * @param string $localKey The key in the join table that maps to the dataClass' PK.
     * @param string $foreignKey The key in the join table that maps to joined class' PK.
     * @param array $extraFields A map of field => fieldtype of extra fields on the join table.
     *
     * @example new ManyManyList('Group','Group_Members', 'GroupID', 'MemberID');
     */
    public function __construct($dataClass, $joinTable, $localKey, $foreignKey, $extraFields = [])
    {
        parent::__construct($dataClass);

        $this->joinTable = $joinTable;
        $this->localKey = $localKey;
        $this->foreignKey = $foreignKey;
        $this->extraFields = $extraFields;

        $this->linkJoinTable();
    }

    /**
     * Setup the join between this dataobject and the necessary mapping table
     */
    protected function linkJoinTable()
    {
        // Join to the many-many join table
        $dataClassIDColumn = DataObject::getSchema()->sqlColumnForField($this->dataClass(), 'ID');
        $this->dataQuery->innerJoin(
            $this->joinTable,
            "\"{$this->joinTable}\".\"{$this->localKey}\" = {$dataClassIDColumn}"
        );

        // Add the extra fields to the query
        if ($this->extraFields) {
            $this->appendExtraFieldsToQuery();
        }
    }

    /**
     * Adds the many_many_extraFields to the select of the underlying
     * {@link DataQuery}.
     *
     * @return void
     */
    protected function appendExtraFieldsToQuery()
    {
        $finalized = [];

        foreach ($this->extraFields as $field => $spec) {
            $obj = Injector::inst()->create($spec);

            if ($obj instanceof DBComposite) {
                $this->_compositeExtraFields[$field] = [];

                // append the composite field names to the select
                foreach ($obj->compositeDatabaseFields() as $subField => $subSpec) {
                    $col = $field . $subField;
                    $finalized[] = $col;

                    // cache
                    $this->_compositeExtraFields[$field][] = $subField;
                }
            } else {
                $finalized[] = $field;
            }
        }

        $this->dataQuery->addSelectFromTable($this->joinTable, $finalized);
    }

    /**
     * Create a DataObject from the given SQL row.
     *
     * @param array $row
     * @return DataObject
     */
    public function createDataObject($row)
    {
        // remove any composed fields
        $add = [];

        if ($this->_compositeExtraFields) {
            foreach ($this->_compositeExtraFields as $fieldName => $composed) {
                // convert joined extra fields into their composite field types.
                $value = [];

                foreach ($composed as $subField) {
                    if (isset($row[$fieldName . $subField])) {
                        $value[$subField] = $row[$fieldName . $subField];

                        // don't duplicate data in the record
                        unset($row[$fieldName . $subField]);
                    }
                }

                $obj = Injector::inst()->create($this->extraFields[$fieldName], $fieldName);
                $obj->setValue($value, null, false);
                $add[$fieldName] = $obj;
            }
        }

        $dataObject = parent::createDataObject($row);

        foreach ($add as $fieldName => $obj) {
            $dataObject->$fieldName = $obj;
        }

        return $dataObject;
    }

    /**
     * Return a filter expression for when getting the contents of the
     * relationship for some foreign ID
     *
     * @param int|null|string|array $id
     *
     * @return array
     */
    protected function foreignIDFilter($id = null)
    {
        if ($id === null) {
            $id = $this->getForeignID();
        }

        // Apply relation filter
        $key = "\"{$this->joinTable}\".\"{$this->foreignKey}\"";
        if (is_array($id)) {
            return ["$key IN (" . DB::placeholders($id) . ")"  => $id];
        }
        if ($id !== null) {
            return [$key => $id];
        }
        return null;
    }

    /**
     * Return a filter expression for the join table when writing to the join table
     *
     * When writing (add, remove, removeByID), we need to filter the join table to just the relevant
     * entries. However some subclasses of ManyManyList (Member_GroupSet) modify foreignIDFilter to
     * include additional calculated entries, so we need different filters when reading and when writing
     *
     * @param array|int|null $id (optional) An ID or an array of IDs - if not provided, will use the current ids
     * as per getForeignID
     * @return array Condition In array(SQL => parameters format)
     */
    protected function foreignIDWriteFilter($id = null)
    {
        return $this->foreignIDFilter($id);
    }

    /**
     * Add an item to this many_many relationship
     * Does so by adding an entry to the joinTable.
     *
     * Can also be used to update an already existing joinTable entry:
     *
     *     $manyManyList->add($recordID,["ExtraField" => "value"]);
     *
     * @throws InvalidArgumentException
     * @throws Exception
     *
     * @param DataObject|int $item
     * @param array $extraFields A map of additional columns to insert into the joinTable.
     * Column names should be ANSI quoted.
     * @throws Exception
     */
    public function add($item, $extraFields = [])
    {
        // Ensure nulls or empty strings are correctly treated as empty arrays
        if (empty($extraFields)) {
            $extraFields = [];
        }

        // Determine ID of new record
        $itemID = null;
        if (is_numeric($item)) {
            $itemID = $item;
        } elseif ($item instanceof $this->dataClass) {
            // Ensure record is saved
            if (!$item->isInDB()) {
                $item->write();
            }
            $itemID = $item->ID;
        } else {
            throw new InvalidArgumentException(
                "ManyManyList::add() expecting a $this->dataClass object, or ID value"
            );
        }
        if (empty($itemID)) {
            throw new InvalidArgumentException("ManyManyList::add() couldn't add this record");
        }

        // Validate foreignID
        $foreignIDs = $this->getForeignID();
        if (empty($foreignIDs)) {
            throw new BadMethodCallException("ManyManyList::add() can't be called until a foreign ID is set");
        }

        // Apply this item to each given foreign ID record
        if (!is_array($foreignIDs)) {
            $foreignIDs = [$foreignIDs];
        }
        foreach ($foreignIDs as $foreignID) {
            // Check for existing records for this item
            if ($foreignFilter = $this->foreignIDWriteFilter($foreignID)) {
                // With the current query, simply add the foreign and local conditions
                // The query can be a bit odd, especially if custom relation classes
                // don't join expected tables (@see Member_GroupSet for example).
                $query = SQLSelect::create("*", "\"{$this->joinTable}\"");
                $query->addWhere($foreignFilter);
                $query->addWhere([
                    "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
                ]);
                $hasExisting = ($query->count() > 0);
            } else {
                $hasExisting = false;
            }

            // Blank manipulation
            $manipulation = [
                $this->joinTable => [
                    'command' => $hasExisting ? 'update' : 'insert',
                    'fields' => [],
                ],
            ];
            if ($hasExisting) {
                $manipulation[$this->joinTable]['where'] = [
                    "\"{$this->joinTable}\".\"{$this->foreignKey}\"" => $foreignID,
                    "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
                ];
            }

            /** @var DBField[] $fieldObjects */
            $fieldObjects = [];
            if ($extraFields && $this->extraFields) {
                // Write extra field to manipluation in the same way
                // that DataObject::prepareManipulationTable writes fields
                foreach ($this->extraFields as $fieldName => $fieldSpec) {
                    // Skip fields without an assignment
                    if (array_key_exists($fieldName, $extraFields)) {
                        $fieldObject = Injector::inst()->create($fieldSpec, $fieldName);
                        $fieldObject->setValue($extraFields[$fieldName]);
                        $fieldObject->writeToManipulation($manipulation[$this->joinTable]);
                        $fieldObjects[$fieldName] = $fieldObject;
                    }
                }
            }

            $manipulation[$this->joinTable]['fields'][$this->localKey] = $itemID;
            $manipulation[$this->joinTable]['fields'][$this->foreignKey] = $foreignID;

            // Make sure none of our field assignments are arrays
            foreach ($manipulation as $tableManipulation) {
                if (!isset($tableManipulation['fields'])) {
                    continue;
                }
                foreach ($tableManipulation['fields'] as $fieldName => $fieldValue) {
                    if (is_array($fieldValue)) {
                        // If the field allows non-scalar values we'll let it do dynamic assignments
                        if (isset($fieldObjects[$fieldName]) && $fieldObjects[$fieldName]->scalarValueOnly()) {
                            throw new InvalidArgumentException(
                                'ManyManyList::add: parameterised field assignments are disallowed'
                            );
                        }
                    }
                }
            }

            DB::manipulate($manipulation);
        }

        if ($this->addCallbacks) {
            $this->addCallbacks->call($this, $item, $extraFields);
        }
    }

    /**
     * Remove the given item from this list.
     *
     * Note that for a ManyManyList, the item is never actually deleted, only
     * the join table is affected.
     *
     * @param DataObject $item
     */
    public function remove($item)
    {
        if (!($item instanceof $this->dataClass)) {
            throw new InvalidArgumentException("ManyManyList::remove() expecting a $this->dataClass object");
        }

        $result = $this->removeByID($item->ID);

        return $result;
    }

    /**
     * Remove the given item from this list.
     *
     * Note that for a ManyManyList, the item is never actually deleted, only
     * the join table is affected
     *
     * @param int $itemID The item ID
     */
    public function removeByID($itemID)
    {
        if (!is_numeric($itemID)) {
            throw new InvalidArgumentException("ManyManyList::removeById() expecting an ID");
        }

        $query = SQLDelete::create("\"{$this->joinTable}\"");

        if ($filter = $this->foreignIDWriteFilter($this->getForeignID())) {
            $query->setWhere($filter);
        } else {
            user_error("Can't call ManyManyList::remove() until a foreign ID is set");
        }

        $query->addWhere([
            "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
        ]);

        // Perform the deletion
        $query->execute();

        if ($this->removeCallbacks) {
            $this->removeCallbacks->call($this, [$itemID]);
        }
    }

    /**
     * Remove all items from this many-many join.  To remove a subset of items,
     * filter it first.
     *
     * @return void
     */
    public function removeAll()
    {

        // Remove the join to the join table to avoid MySQL row locking issues.
        $query = $this->dataQuery();
        $foreignFilter = $query->getQueryParam('Foreign.Filter');
        $query->removeFilterOn($foreignFilter);

        // Select ID column
        $selectQuery = $query->query();
        $dataClassIDColumn = DataObject::getSchema()->sqlColumnForField($this->dataClass(), 'ID');
        $selectQuery->setSelect($dataClassIDColumn);

        $from = $selectQuery->getFrom();
        unset($from[$this->joinTable]);
        $selectQuery->setFrom($from);
        $selectQuery->setOrderBy(); // ORDER BY in subselects breaks MS SQL Server and is not necessary here
        $selectQuery->setDistinct(false);

        // Use a sub-query as SQLite does not support setting delete targets in
        // joined queries.
        $delete = SQLDelete::create();
        $delete->setFrom("\"{$this->joinTable}\"");
        $delete->addWhere($this->foreignIDFilter());
        $subSelect = $selectQuery->sql($parameters);
        $delete->addWhere([
            "\"{$this->joinTable}\".\"{$this->localKey}\" IN ($subSelect)" => $parameters
        ]);

        $affectedIds = [];
        if ($this->removeCallbacks) {
            $affectedIds = $delete
                ->toSelect()
                ->setSelect("\"{$this->joinTable}\".\"{$this->localKey}\"")
                ->execute()
                ->column();
        }

        // Perform the deletion
        $delete->execute();

        if ($this->removeCallbacks && $affectedIds) {
            $this->removeCallbacks->call($this, $affectedIds);
        }
    }

    /**
     * Find the extra field data for a single row of the relationship join
     * table, given the known child ID.
     *
     * @param string $componentName The name of the component
     * @param int $itemID The ID of the child for the relationship
     *
     * @return array Map of fieldName => fieldValue
     */
    public function getExtraData($componentName, $itemID)
    {
        $result = [];

        // Skip if no extrafields or unsaved record
        if (empty($this->extraFields) || empty($itemID)) {
            return $result;
        }

        if (!is_numeric($itemID)) {
            throw new InvalidArgumentException('ManyManyList::getExtraData() passed a non-numeric child ID');
        }

        $cleanExtraFields = [];
        foreach ($this->extraFields as $fieldName => $dbFieldSpec) {
            $cleanExtraFields[] = "\"{$fieldName}\"";
        }
        $query = SQLSelect::create($cleanExtraFields, "\"{$this->joinTable}\"");
        $filter = $this->foreignIDWriteFilter($this->getForeignID());
        if ($filter) {
            $query->setWhere($filter);
        } else {
            throw new BadMethodCallException("Can't call ManyManyList::getExtraData() until a foreign ID is set");
        }
        $query->addWhere([
            "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
        ]);
        $queryResult = $query->execute()->current();
        if ($queryResult) {
            foreach ($queryResult as $fieldName => $value) {
                $result[$fieldName] = $value;
            }
        }

        return $result;
    }

    /**
     * Gets the join table used for the relationship.
     *
     * @return string the name of the table
     */
    public function getJoinTable()
    {
        return $this->joinTable;
    }

    /**
     * Gets the key used to store the ID of the local/parent object.
     *
     * @return string the field name
     */
    public function getLocalKey()
    {
        return $this->localKey;
    }

    /**
     * Gets the key used to store the ID of the foreign/child object.
     *
     * @return string the field name
     */
    public function getForeignKey()
    {
        return $this->foreignKey;
    }

    /**
     * Gets the extra fields included in the relationship.
     *
     * @return array a map of field names to types
     */
    public function getExtraFields()
    {
        return $this->extraFields;
    }
}
