<?php
/**
 * This source file is part of the open source project
 * ExpressionEngine (https://expressionengine.com)
 *
 * @link      https://expressionengine.com/
 * @copyright Copyright (c) 2003-2023, Packet Tide, LLC (https://www.packettide.com)
 * @license   https://expressionengine.com/license Licensed under Apache License, Version 2.0
 */

namespace ExpressionEngine\Service\Model\Query;

use ExpressionEngine\Service\Model\Relation\BelongsTo;

/**
 * Delete Query
 */
class Delete extends Query
{
    private const DELETE_BATCH_SIZE = 100;

    private $delete_list = array();

    public function run()
    {
        $builder = $this->builder;
        $from = $this->builder->getFrom();
        $from_pk = $this->store->getMetaDataReader($from)->getPrimaryKey();

        $parent_ids = $this->getParentIds($from, $from_pk);

        if (! count($parent_ids)) {
            return;
        }

        $mainClass = $this->store->getMetaDataReader($from)->getClass();
        $mainClass::emitStatic('beforeAssociationsBulkDelete', $parent_ids);

        $from_alias = 'CurrentlyDeleting';

        $delete_list = $this->getDeleteList($from, $from_alias);

        foreach ($delete_list as $delete_item) {
            list($get, $withs) = $delete_item;
            list($model, $alias) = $this->splitAlias($get);

            $batch_size = self::DELETE_BATCH_SIZE;

            // TODO yuck. The relations have this info more correctly
            // in their to and from keys. store that instead.
            $to_meta = $this->store->getMetaDataReader($model);
            $to_pk = $to_meta->getPrimaryKey();
            $events = $to_meta->getEvents();

            $has_delete_event = (
                $to_meta->publishesHooks() ||
                in_array('beforeDelete', $events) ||
                in_array('afterDelete', $events)
            );

            $basic_query = $builder
                ->getFacade()
                ->get($get)
                ->filter("{$from_alias}.{$from_pk}", 'IN', $parent_ids);

            // expensive recursion fallback
            if ($withs instanceof \Closure) {
                do {
                    $fetch_query = clone $basic_query;
                    $fetch_query->limit($batch_size);

                    $delete_collection = $withs($fetch_query);
                    $delete_ids = $this->deleteCollection($delete_collection, $to_meta);
                } while (count($delete_ids) == $batch_size);

                continue;
            }

            // TODO optimize further for on-db deletes
            do {
                $fetch_query = clone $basic_query;

                $fetch_query
                    ->with($withs)
                    ->limit($batch_size);

                if ($has_delete_event) {
                    $fetch_query->fields("{$alias}.*");
                } else {
                    $fetch_query->fields("{$alias}.{$to_pk}");
                }

                $delete_models = $fetch_query->all();

                $delete_ids = $this->deleteCollection($delete_models, $to_meta);
            } while (count($delete_ids) == $batch_size);
        }
        $mainClass::emitStatic('afterAssociationsBulkDelete', $parent_ids);
    }

    /**
     * Trigger a delete on a collection, given a collection and relevant
     * metadata
     */
    protected function deleteCollection($collection, $to_meta)
    {
        if (! count($collection)) {
            return array();
        }

        $delete_ids = $collection->getIds();
        $extra_where = array();

        if (array_key_exists('member_groups', $to_meta->getTables())) {
            $extra_where['site_id'] = array_unique($collection->pluck('site_id'));
        }

        $class = $to_meta->getClass();

        $class::emitStatic('beforeBulkDelete', $delete_ids);
        $collection->emit('beforeDelete');

        $this->deleteAsLeaf($to_meta, $delete_ids, $extra_where);

        $collection->emit('afterDelete');
        $class::emitStatic('afterBulkDelete', $delete_ids);

        return $delete_ids;
    }

    /**
     * Delete the model and its tables, ignoring any relationships
     * that might exist. This is a utility function for the main
     * delete which *is* aware of relationships.
     *
     * @param String $model       Model name to delete from
     * @param Int[]  $delete_ids  Array of primary key ids to remove
     */
    protected function deleteAsLeaf($reader, $delete_ids, $extra_where = array())
    {
        $tables = array_keys($reader->getTables(false));
        $key = $reader->getPrimaryKey();

        $query = $this->store->rawQuery();

        foreach ($extra_where as $field => $value) {
            $query->where_in($field, $value);
        }

        $query->where_in($key, $delete_ids)
            ->delete($tables);
    }

    /**
     * Fetch all ids of the parent.
     *
     * This way we can restrict all of our filters to just the
     * ids instead of running a potentially expensive query a
     * bunch of times.
     */
    protected function getParentIds($from, $from_pk)
    {
        $builder = clone $this->builder;

        return $builder
            ->fields("{$from}.{$from_pk}")
            ->all()
            ->pluck($from_pk);
    }

    /**
     * Generate a list for each child model name to delete, that contains all
     * withs() that lead back to the parent being deleted. These are returned
     * in the reverse order of how they need to be processed. Think of it as a
     * reversed topsort.
     *
     * Example:
     * get('Site')->delete()
     *
     * Returns:
     *
     * array(
     *    'Template'      => array('TemplateGroup' => array('Site' => array()))
     *    'TemplateGroup' => array('Site' => array())
     *    'Site'          => array()
     * );
     *
     * @param String  $model  Model to delete from
     * @return array  [name => withs, ...] as described above
     */
    protected function getDeleteList($model, $delete_alias)
    {
        $this->delete_list = array();
        $this->recursivePath($model, array());

        // This list is processed bottom to top.
        // Sort the branches by length, with the longest on the bottom, to
        // create a depth-first delete.
        // The main deletes are sandwiched by the weak and recursive deletes.
        // Sort weak deletes to the bottom since we may disconnect on the side
        // we end up deleting and while that is a bit redundant it is also the
        // safest way to disconnect.
        // Sort recursion to the top so that we handle those last and don't
        // recurse continually before actually getting to work.
        usort($this->delete_list, function ($a, $b) {
            if ($a[1] instanceof \Closure) {
                return ($a[2] == 'weak') ? 5e5 : -5e5;
            }

            if ($b[1] instanceof \Closure) {
                return ($b[2] == 'weak') ? -5e5 : 5e5;
            }

            return count($a[1]) - count($b[1]);
        });

        foreach ($this->delete_list as &$final) {
            if (is_array($final[1])) {
                if (count($final[1])) {
                    $last = array_pop($final[1]);
                    $final[1][] = $last . ' AS ' . $delete_alias;
                } else {
                    $final[0] .= ' AS ' . $delete_alias;
                }

                $final[1] = $this->nest($final[1]);
            }
        }

        return array_reverse($this->delete_list);
    }

    protected function recursivePath($model, $path = array())
    {
        $this->delete_list[] = array($model, array_reverse($path));

        $relations = $this->store->getAllRelations($model);

        foreach ($relations as $name => $relation) {
            // If the relation is a belongsTo then we can stop looking.
            // It may be tempting to let a weak relationship continue,
            // but that would be incorrect and inefficient since the id
            // holding side of the relationship is the being deleted anyways.
            if ($relation instanceof BelongsTo) {
                continue;
            }

            $inverse = $relation->getInverse();

            if ($relation->isWeak()) {
                $to_model = $relation->getTargetModel();
                $to_name = $inverse->getName();

                $subpath = $path;
                $subpath[] = $to_name;

                $this->delete_list[] = array($to_model, $this->weak($inverse, $subpath), 'weak');

                continue;
            }

            if ($inverse instanceof BelongsTo) {
                $to_model = $relation->getTargetModel();
                $to_name = $inverse->getName();

                // check for recursion
                if ($to_model == $model && $to_name == end($path)) {
                    $this->delete_list[] = array($to_model, $this->recursive($relation, $path), 'recursive');

                    continue;
                }

                $subpath = $path;
                $subpath[] = $to_name;

                $this->recursivePath($to_model, $subpath);
            }
        }
    }

    /**
     *
     */
    protected function nest($array)
    {
        if (empty($array)) {
            return array();
        }

        $key = array_shift($array);

        return array($key => $this->nest($array));
    }

    /**
     * Creates a worker function to handle recursive deletes inline with
     * the rest of the delete flow. Will attempt to return to a bottom-up
     * deletion if the recursion is broken. If the recursion is not broken
     * this ends up being a slow process. For something like categories we
     * can detect this inability directly from the relation, so there's
     * definitely room for improvement.
     */
    private function recursive($relation, $withs)
    {
        $withs = array_reverse($withs);

        if (count($withs)) {
            $withs[count($withs) - 1] .= ' AS CurrentlyDeleting';
        }

        $withs = $this->nest($withs);

        return function ($query) use ($relation, $withs) {
            $name = $relation->getName();

            $models = $query->with($withs)->all();

            // TODO ideally we would grab the $model->$name's with just ids
            // and then proceed to call delete on them after our current
            // delete process is done. Unfortunately we're way down the stack
            // at this point and inside a closure that can't see outside it's
            // own four walls in 5.3. So as per usual stupid old versions of
            // PHP just won't let us have nice things.
            foreach ($models as $model) {
                $model->getAssociation($name)->get()->delete();
            }

            // continue deleting
            return $models;
        };
    }

    /**
     * Creates a worker function to handle weak deletes.
     */
    private function weak($relation, $withs)
    {
        $withs = array_reverse($withs);

        if (count($withs)) {
            $withs[count($withs) - 1] .= ' AS CurrentlyDeleting';
        }

        $withs = $this->nest($withs);

        return function ($query) use ($relation, $withs) {
            // When delting data with a pivot table on/to Role, don’t just blindly delete everything.
            if (
                ($relation->getTargetModel() == 'Role' || $relation->getTargetModel() == 'ee:Role') &&
                ($relation->getPivot() != array())
            ) {
                return array();
            }

            $name = $relation->getName();

            // for delete queries, we don't need full set of columns
            // ... and some might be already gone because of previous queries
            $relation_meta = $this->store->getMetaDataReader($relation->getTargetModel());
            $relation_pk = $relation_meta->getPrimaryKey();
            if (count($withs)) {
                $query->fields('CurrentlyDeleting.' . $relation_pk);
            }
            $models = $query->with($withs)->all();

            foreach ($models as $model) {
                $relation->drop($model, $model->getAssociation($name)->get());
            }

            // do not continue deleting
            return array();
        };
    }

    protected function splitAlias($string)
    {
        $string = trim($string);
        $parts = preg_split('/\s+AS\s+/i', $string);

        if (! isset($parts[1])) {
            return array($string, $string);
        }

        return $parts;
    }
}

// EOF
