<?php

/**
 * Joomla! Content Management System
 *
 * @copyright  (C) 2018 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\CMS\Workflow;

use Joomla\CMS\Application\CMSApplicationInterface;
use Joomla\CMS\Event\AbstractEvent;
use Joomla\CMS\Event\Workflow\WorkflowTransitionEvent;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Table\Category;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\ParameterType;
use Joomla\Event\DispatcherAwareInterface;
use Joomla\Registry\Registry;
use Joomla\Utilities\ArrayHelper;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Workflow Class.
 *
 * @since  4.0.0
 */
class Workflow
{
    /**
     * The booted component
     *
     * @var ComponentInterface
     */
    protected $component = null;

    /**
     * Name of the extension the workflow belong to
     *
     * @var    string
     * @since  4.0.0
     */
    protected $extension = null;

    /**
     * Application Object
     *
     * @var    CMSApplicationInterface
     * @since  4.0.0
     */
    protected $app;

    /**
     * Database Driver
     *
     * @var    DatabaseDriver
     * @since  4.0.0
     */
    protected $db;

    /**
     * Condition to names mapping
     *
     * @since  4.0.0
     */
    public const CONDITION_NAMES = [
        self::CONDITION_PUBLISHED   => 'JPUBLISHED',
        self::CONDITION_UNPUBLISHED => 'JUNPUBLISHED',
        self::CONDITION_TRASHED     => 'JTRASHED',
        self::CONDITION_ARCHIVED    => 'JARCHIVED',
    ];

    /**
     * Every item with a state which has the condition PUBLISHED is visible/active on the page
     */
    public const CONDITION_PUBLISHED = 1;

    /**
     * Every item with a state which has the condition UNPUBLISHED is not visible/inactive on the page
     */
    public const CONDITION_UNPUBLISHED = 0;

    /**
     * Every item with a state which has the condition TRASHED is trashed
     */
    public const CONDITION_TRASHED = -2;

    /**
     * Every item with a state which has the condition ARCHIVED is archived
     */
    public const CONDITION_ARCHIVED = 2;

    /**
     * Class constructor
     *
     * @param   string                    $extension  The extension name
     * @param   ?CMSApplicationInterface  $app        Application Object
     * @param   ?DatabaseDriver           $db         Database Driver Object
     *
     * @since   4.0.0
     */
    public function __construct(string $extension, ?CMSApplicationInterface $app = null, ?DatabaseDriver $db = null)
    {
        $this->extension = $extension;

        // Initialise default objects if none have been provided
        if ($app === null) {
            @trigger_error('From 6.0 declaring the app dependency will be mandatory.', E_USER_DEPRECATED);
            $app = Factory::getApplication();
        }

        if (!is_a($app, DispatcherAwareInterface::class)) {
            trigger_error('The given application object is not Dispatcher aware, which is mandatory.', E_ERROR);
        }

        $this->app = $app;

        if ($db === null) {
            @trigger_error('From 6.0 declaring the database dependency will be mandatory.', E_USER_DEPRECATED);
            $db = Factory::getContainer()->get(DatabaseDriver::class);
        }

        $this->db = $db;
    }

    /**
     * Returns the translated condition name, based on the given number
     *
     * @param   integer  $value  The condition ID
     *
     * @return  string
     *
     * @since   4.0.0
     */
    public function getConditionName(int $value): string
    {
        $component = $this->getComponent();

        if ($component instanceof WorkflowServiceInterface) {
            $conditions = $component->getConditions($this->extension);
        } else {
            $conditions = self::CONDITION_NAMES;
        }

        return ArrayHelper::getValue($conditions, $value, '', 'string');
    }

    /**
     * Returns the booted component
     *
     * @return ComponentInterface
     *
     * @since   4.0.0
     */
    protected function getComponent()
    {
        if (\is_null($this->component)) {
            $parts = explode('.', $this->extension);

            $this->component = $this->app->bootComponent($parts[0]);
        }

        return $this->component;
    }

    /**
     * Try to load a workflow default stage by category ID.
     *
     * @param   integer   $catId  The category ID.
     *
     * @return  boolean|integer  An integer, holding the stage ID or false
     * @since   4.0.0
     */
    public function getDefaultStageByCategory($catId = 0)
    {
        // Let's check if a workflow ID is assigned to a category
        $category = new Category($this->db);

        $categories = array_reverse($category->getPath($catId));

        $workflow_id = 0;

        foreach ($categories as $cat) {
            $cat->params = new Registry($cat->params);

            $workflow_id = $cat->params->get('workflow_id');

            if ($workflow_id == 'inherit') {
                $workflow_id = 0;
            } elseif ($workflow_id == 'use_default') {
                $workflow_id = 0;

                break;
            } elseif ($workflow_id > 0) {
                break;
            }
        }

        // Check if the workflow exists
        if ($workflow_id = (int) $workflow_id) {
            $query = $this->db->createQuery();

            $query->select(
                [
                    $this->db->quoteName('ws.id'),
                ]
            )
                ->from(
                    [
                        $this->db->quoteName('#__workflow_stages', 'ws'),
                        $this->db->quoteName('#__workflows', 'w'),
                    ]
                )
                ->where(
                    [
                        $this->db->quoteName('ws.workflow_id') . ' = ' . $this->db->quoteName('w.id'),
                        $this->db->quoteName('ws.default') . ' = 1',
                        $this->db->quoteName('w.published') . ' = 1',
                        $this->db->quoteName('ws.published') . ' = 1',
                        $this->db->quoteName('w.id') . ' = :workflowId',
                        $this->db->quoteName('w.extension') . ' = :extension',
                    ]
                )
                ->bind(':workflowId', $workflow_id, ParameterType::INTEGER)
                ->bind(':extension', $this->extension);

            $stage_id = (int) $this->db->setQuery($query)->loadResult();

            if (!empty($stage_id)) {
                return $stage_id;
            }
        }

        // Use default workflow
        $query  = $this->db->createQuery();

        $query->select(
            [
                $this->db->quoteName('ws.id'),
            ]
        )
            ->from(
                [
                    $this->db->quoteName('#__workflow_stages', 'ws'),
                    $this->db->quoteName('#__workflows', 'w'),
                ]
            )
            ->where(
                [
                    $this->db->quoteName('ws.default') . ' = 1',
                    $this->db->quoteName('ws.workflow_id') . ' = ' . $this->db->quoteName('w.id'),
                    $this->db->quoteName('w.published') . ' = 1',
                    $this->db->quoteName('ws.published') . ' = 1',
                    $this->db->quoteName('w.default') . ' = 1',
                    $this->db->quoteName('w.extension') . ' = :extension',
                ]
            )
            ->bind(':extension', $this->extension);

        $stage_id = (int) $this->db->setQuery($query)->loadResult();

        // Last check if we have a workflow ID
        if (!empty($stage_id)) {
            return $stage_id;
        }

        return false;
    }

    /**
     * Check if a transition can be executed
     *
     * @param   integer[]  $pks           The item IDs, which should use the transition
     * @param   integer    $transitionId  The transition which should be executed
     *
     * @return  object | null
     */
    public function getValidTransition(array $pks, int $transitionId)
    {
        $pks = ArrayHelper::toInteger($pks);
        $pks = array_filter($pks);

        if (!\count($pks)) {
            return null;
        }

        $query = $this->db->createQuery();

        $user = $this->app->getIdentity();

        $query->select(
            [
                $this->db->quoteName('t.id'),
                $this->db->quoteName('t.to_stage_id'),
                $this->db->quoteName('t.from_stage_id'),
                $this->db->quoteName('t.options'),
                $this->db->quoteName('t.workflow_id'),
            ]
        )
            ->from(
                [
                    $this->db->quoteName('#__workflow_transitions', 't'),
                ]
            )
            ->join(
                'INNER',
                $this->db->quoteName('#__workflows', 'w'),
                $this->db->quoteName('t.workflow_id') . ' = ' . $this->db->quoteName('w.id')
            )
            ->join(
                'LEFT',
                $this->db->quoteName('#__workflow_stages', 's'),
                $this->db->quoteName('s.id') . ' = ' . $this->db->quoteName('t.to_stage_id')
            )
            ->where(
                [
                    $this->db->quoteName('t.id') . ' = :id',
                    $this->db->quoteName('t.published') . ' = 1',
                    $this->db->quoteName('w.extension') . ' = :extension',
                ]
            )
            ->bind(':id', $transitionId, ParameterType::INTEGER)
            ->bind(':extension', $this->extension);

        $transition = $this->db->setQuery($query)->loadObject();

        $parts  = explode('.', $this->extension);
        $option = reset($parts);

        if (!empty($transition->id) && $user->authorise('core.execute.transition', $option . '.transition.' . (int) $transition->id)) {
            return $transition;
        }

        return null;
    }

    /**
     * Executes a transition to change the current state in the association table
     *
     * @param   integer[]  $pks           The item IDs, which should use the transition
     * @param   integer    $transitionId  The transition which should be executed
     *
     * @return  boolean
     */
    public function executeTransition(array $pks, int $transitionId): bool
    {
        $pks = ArrayHelper::toInteger($pks);
        $pks = array_filter($pks);

        if (!\count($pks)) {
            return true;
        }

        $transition = $this->getValidTransition($pks, $transitionId);

        if (\is_null($transition)) {
            return false;
        }

        $transition->options = new Registry($transition->options);

        // Check if the items can execute this transition
        foreach ($pks as $pk) {
            $assoc = $this->getAssociation($pk);

            // The transition has to be in the same workflow
            if (
                !\in_array($transition->from_stage_id, [
                    $assoc->stage_id,
                    -1,
                ]) || $transition->workflow_id !== $assoc->workflow_id
            ) {
                return false;
            }
        }

        PluginHelper::importPlugin('workflow');

        $eventResult = $this->app->getDispatcher()->dispatch(
            'onWorkflowBeforeTransition',
            AbstractEvent::create(
                'onWorkflowBeforeTransition',
                [
                    'eventClass'     => WorkflowTransitionEvent::class,
                    'subject'        => $this,
                    'extension'      => $this->extension,
                    'pks'            => $pks,
                    'transition'     => $transition,
                    'stopTransition' => false,
                ]
            )
        );

        if ($eventResult->getArgument('stopTransition')) {
            return false;
        }

        $success = $this->updateAssociations($pks, (int) $transition->to_stage_id);

        if ($success) {
            $this->app->getDispatcher()->dispatch(
                'onWorkflowAfterTransition',
                AbstractEvent::create(
                    'onWorkflowAfterTransition',
                    [
                        'eventClass' => WorkflowTransitionEvent::class,
                        'subject'    => $this,
                        'extension'  => $this->extension,
                        'pks'        => $pks,
                        'transition' => $transition,
                    ]
                )
            );
        }

        return $success;
    }

    /**
     * Creates an association for the workflow_associations table
     *
     * @param   integer  $pk     ID of the item
     * @param   integer  $state  ID of state
     *
     * @return  boolean
     *
     * @since  4.0.0
     */
    public function createAssociation(int $pk, int $state): bool
    {
        try {
            $query = $this->db->createQuery();

            $query->insert($this->db->quoteName('#__workflow_associations'))
                ->columns(
                    [
                        $this->db->quoteName('item_id'),
                        $this->db->quoteName('stage_id'),
                        $this->db->quoteName('extension'),
                    ]
                )
                ->values(':pk, :state, :extension')
                ->bind(':pk', $pk, ParameterType::INTEGER)
                ->bind(':state', $state, ParameterType::INTEGER)
                ->bind(':extension', $this->extension);

            $this->db->setQuery($query)->execute();
        } catch (\Exception) {
            return false;
        }

        return true;
    }

    /**
     * Update an existing association with a new state
     *
     * @param   array    $pks    An Array of item IDs which should be changed
     * @param   integer  $state  The new state ID
     *
     * @return  boolean
     *
     * @since  4.0.0
     */
    public function updateAssociations(array $pks, int $state): bool
    {
        $pks = ArrayHelper::toInteger($pks);

        try {
            $query = $this->db->createQuery();

            $query->update($this->db->quoteName('#__workflow_associations'))
                ->set($this->db->quoteName('stage_id') . ' = :state')
                ->whereIn($this->db->quoteName('item_id'), $pks)
                ->where($this->db->quoteName('extension') . ' = :extension')
                ->bind(':state', $state, ParameterType::INTEGER)
                ->bind(':extension', $this->extension);

            $this->db->setQuery($query)->execute();
        } catch (\Exception) {
            return false;
        }

        return true;
    }

    /**
     * Removes associations from the workflow_associations table
     *
     * @param   integer[]  $pks  ID of content
     *
     * @return  boolean
     *
     * @since  4.0.0
     */
    public function deleteAssociation(array $pks): bool
    {
        $pks = ArrayHelper::toInteger($pks);

        try {
            $query = $this->db->createQuery();

            $query
                ->delete($this->db->quoteName('#__workflow_associations'))
                ->whereIn($this->db->quoteName('item_id'), $pks)
                ->where($this->db->quoteName('extension') . ' = :extension')
                ->bind(':extension', $this->extension);

            $this->db->setQuery($query)->execute();
        } catch (\Exception) {
            return false;
        }

        return true;
    }

    /**
     * Loads an existing association item with state and item ID
     *
     * @param   integer  $itemId  The item ID to load
     *
     * @return  \stdClass|null
     *
     * @since  4.0.0
     */
    public function getAssociation(int $itemId): ?\stdClass
    {
        $query = $this->db->createQuery();

        $query->select(
            [
                $this->db->quoteName('a.item_id'),
                $this->db->quoteName('a.stage_id'),
                $this->db->quoteName('s.workflow_id'),
            ]
        )
            ->from($this->db->quoteName('#__workflow_associations', 'a'))
            ->innerJoin(
                $this->db->quoteName('#__workflow_stages', 's'),
                $this->db->quoteName('a.stage_id') . ' = ' . $this->db->quoteName('s.id')
            )
            ->where(
                [
                    $this->db->quoteName('item_id') . ' = :id',
                    $this->db->quoteName('extension') . ' = :extension',
                ]
            )
            ->bind(':id', $itemId, ParameterType::INTEGER)
            ->bind(':extension', $this->extension);

        return $this->db->setQuery($query)->loadObject();
    }
}
