<?php

/**
 * @see       https://github.com/laminas/laminas-mvc-console for the canonical source repository
 * @copyright https://github.com/laminas/laminas-mvc-console/blob/master/COPYRIGHT.md
 * @license   https://github.com/laminas/laminas-mvc-console/blob/master/LICENSE.md New BSD License
 */

namespace Laminas\Mvc\Console\View;

use Laminas\Console\Adapter\AdapterInterface as ConsoleAdapter;
use Laminas\Console\ColorInterface;
use Laminas\Console\Request as ConsoleRequest;
use Laminas\Console\Response as ConsoleResponse;
use Laminas\EventManager\AbstractListenerAggregate;
use Laminas\EventManager\EventManagerInterface;
use Laminas\ModuleManager\Feature\ConsoleBannerProviderInterface;
use Laminas\ModuleManager\Feature\ConsoleUsageProviderInterface;
use Laminas\ModuleManager\ModuleManagerInterface;
use Laminas\Mvc\Application;
use Laminas\Mvc\Console\Exception\RuntimeException;
use Laminas\Mvc\Console\View\ViewModel as ConsoleModel;
use Laminas\Mvc\MvcEvent;
use Laminas\ServiceManager\Exception\ServiceNotFoundException;
use Laminas\Stdlib\ResponseInterface as Response;
use Laminas\Stdlib\StringUtils;
use Laminas\Text\Table;

class RouteNotFoundStrategy extends AbstractListenerAggregate
{
    /**
     * Whether or not to display the reason for routing failure
     *
     * @var bool
     */
    protected $displayNotFoundReason = true;

    /**
     * The reason for a not-found condition
     *
     * @var bool|string
     */
    protected $reason = false;

    /**
     * {@inheritDoc}
     */
    public function attach(EventManagerInterface $events, $priority = 1)
    {
        $this->listeners[] = $events->attach(MvcEvent::EVENT_DISPATCH_ERROR, [$this, 'handleRouteNotFoundError']);
    }

    /**
     * Set flag indicating whether or not to display the routing failure
     *
     * @param  bool $displayNotFoundReason
     * @return RouteNotFoundStrategy
     */
    public function setDisplayNotFoundReason($displayNotFoundReason)
    {
        $this->displayNotFoundReason = (bool) $displayNotFoundReason;
        return $this;
    }

    /**
     * Do we display the routing failure?
     *
     * @return bool
     */
    public function displayNotFoundReason()
    {
        return $this->displayNotFoundReason;
    }

    /**
     * Detect if an error is a route not found condition
     *
     * If a "controller not found" or "invalid controller" error type is
     * encountered, sets the response status code to 404.
     *
     * @param  MvcEvent $e
     * @throws RuntimeException
     * @throws ServiceNotFoundException
     * @return void
     */
    public function handleRouteNotFoundError(MvcEvent $e)
    {
        $error = $e->getError();
        if (empty($error)) {
            return;
        }

        $response = $e->getResponse();
        $request  = $e->getRequest();

        switch ($error) {
            case Application::ERROR_CONTROLLER_NOT_FOUND:
            case Application::ERROR_CONTROLLER_INVALID:
            case Application::ERROR_ROUTER_NO_MATCH:
                $this->reason = $error;
                if (! $response) {
                    $response = new ConsoleResponse();
                    $e->setResponse($response);
                }
                $response->setMetadata('error', $error);
                break;
            default:
                return;
        }

        $result = $e->getResult();
        if ($result instanceof Response) {
            // Already have a response as the result
            return;
        }

        // Prepare Console View Model
        $model = new ConsoleModel();
        $model->setErrorLevel(1);

        // Fetch service manager
        $sm = $e->getApplication()->getServiceManager();

        // Try to fetch module manager
        $mm = null;
        try {
            $mm = $sm->get('ModuleManager');
        } catch (ServiceNotFoundException $exception) {
            // The application does not have or use module manager, so we cannot use it
        }

        // Try to fetch current console adapter
        try {
            $console = $sm->get('console');
            if (! $console instanceof ConsoleAdapter) {
                throw new ServiceNotFoundException();
            }
        } catch (ServiceNotFoundException $exception) {
            // The application does not have console adapter
            throw new RuntimeException('Cannot access Console adapter - is it defined in ServiceManager?');
        }

        // Retrieve the script's name (entry point)
        $scriptName = '';
        if ($request instanceof ConsoleRequest) {
            $scriptName = basename($request->getScriptName());
        }

        // Get application banner
        $banner = $this->getConsoleBanner($console, $mm);

        // Get application usage information
        $usage = $this->getConsoleUsage($console, $scriptName, $mm);

        // Inject the text into view
        $result  = $banner ? rtrim($banner, "\r\n") : '';
        $result .= $usage ? "\n\n" . trim($usage, "\r\n") : '';
        $result .= "\n"; // to ensure we output a final newline
        $result .= $this->reportNotFoundReason($e);
        $model->setResult($result);

        // Inject the result into MvcEvent
        $e->setResult($model);
    }

    /**
     * Build Console application banner text by querying currently loaded
     * modules.
     *
     * @param ModuleManagerInterface $moduleManager
     * @param ConsoleAdapter         $console
     * @return string
     */
    protected function getConsoleBanner(ConsoleAdapter $console, ModuleManagerInterface $moduleManager = null)
    {
        /*
         * Loop through all loaded modules and collect banners
         */
        $banners = [];
        if ($moduleManager !== null) {
            foreach ($moduleManager->getLoadedModules(false) as $module) {
                // Strict-type on ConsoleBannerProviderInterface, or duck-type
                // on the method it defines
                if (! $module instanceof ConsoleBannerProviderInterface
                    && ! method_exists($module, 'getConsoleBanner')
                ) {
                    continue; // this module does not provide a banner
                }

                // Don't render empty completely empty lines
                $banner = $module->getConsoleBanner($console);
                if ($banner == '') {
                    continue;
                }

                // We colorize each banners in blue for visual emphasis
                $banners[] = $console->colorize($banner, ColorInterface::BLUE);
            }
        }

        /*
         * Handle an application with no defined banners
         */
        if (! $banners) {
            return "Laminas application\nUsage:\n";
        }

        /*
         * Join the banners by a newline character
         */
        return implode("\n", $banners);
    }

    /**
     * Build Console usage information by querying currently loaded modules.
     *
     * @param ConsoleAdapter         $console
     * @param string                 $scriptName
     * @param ModuleManagerInterface $moduleManager
     * @return string
     * @throws RuntimeException
     */
    protected function getConsoleUsage(
        ConsoleAdapter $console,
        $scriptName,
        ModuleManagerInterface $moduleManager = null
    ) {
        /*
         * Loop through all loaded modules and collect usage info
         */
        $usageInfo = [];

        if ($moduleManager !== null) {
            foreach ($moduleManager->getLoadedModules(false) as $name => $module) {
                // Strict-type on ConsoleUsageProviderInterface, or duck-type
                // on the method it defines
                if (! $module instanceof ConsoleUsageProviderInterface
                    && ! method_exists($module, 'getConsoleUsage')
                ) {
                    continue; // this module does not provide usage info
                }

                // We prepend the usage by the module name (printed in red), so that each module is
                // clearly visible by the user
                $moduleName = sprintf(
                    "%s\n%s\n%s\n",
                    str_repeat('-', $console->getWidth()),
                    $name,
                    str_repeat('-', $console->getWidth())
                );

                $moduleName = $console->colorize($moduleName, ColorInterface::RED);

                $usage = $module->getConsoleUsage($console);

                // Normalize what we got from the module or discard
                if (is_array($usage) && ! empty($usage)) {
                    array_unshift($usage, $moduleName);
                    $usageInfo[$name] = $usage;
                } elseif (is_string($usage) && ($usage !== '')) {
                    $usageInfo[$name] = [$moduleName, $usage];
                }
            }
        }

        /*
         * Handle an application with no usage information
         */
        if (! $usageInfo) {
            // TODO: implement fetching available console routes from router
            return '';
        }

        /*
         * Transform arrays in usage info into columns, otherwise join everything together
         */
        $result    = '';
        $table     = false;
        $tableCols = 0;
        $tableType = 0;
        foreach ($usageInfo as $moduleName => $usage) {
            if (! is_string($usage) && ! is_array($usage)) {
                throw new RuntimeException(sprintf(
                    'Cannot understand usage info for module "%s"',
                    $moduleName
                ));
            }

            if (is_string($usage)) {
                // It's a plain string - output as is
                $result .= $usage . "\n";
                continue;
            }

            // It's an array, analyze it
            foreach ($usage as $a => $b) {
                /*
                 * 'invocation method' => 'explanation'
                 */
                if (is_string($a) && is_string($b)) {
                    if (($tableCols !== 2 || $tableType !== 1) && $table !== false) {
                        // render last table
                        $result .= $this->renderTable($table, $tableCols, $console->getWidth());
                        $table   = false;

                            // add extra newline for clarity
                        $result .= "\n";
                    }

                    // Colorize the command
                    $a = $console->colorize($scriptName . ' ' . $a, ColorInterface::GREEN);

                    $tableCols = 2;
                    $tableType = 1;
                    $table[]   = [$a, $b];
                    continue;
                }

                /*
                 * array('--param', '--explanation')
                 */
                if (is_array($b)) {
                    $count = count($b);
                    if (($count !== $tableCols || $tableType !== 2) && $table !== false) {
                        // render last table
                        $result .= $this->renderTable($table, $tableCols, $console->getWidth());
                        $table   = false;

                        // add extra newline for clarity
                        $result .= "\n";
                    }

                    $tableCols = $count;
                    $tableType = 2;
                    $table[]   = $b;
                    continue;
                }

                /*
                 * 'A single line of text'
                 */
                if ($table !== false) {
                    // render last table
                    $result .= $this->renderTable($table, $tableCols, $console->getWidth());
                    $table   = false;

                    // add extra newline for clarity
                    $result .= "\n";
                }

                $tableType = 0;
                $result   .= $b . "\n";
            }
        }

        // Finish last table
        if ($table !== false) {
            $result .= $this->renderTable($table, $tableCols, $console->getWidth());
        }

        return $result;
    }

    /**
     * Render a text table containing the data provided, that will fit inside console window's width.
     *
     * @param  $data
     * @param  $cols
     * @param  $consoleWidth
     * @return string
     */
    protected function renderTable($data, $cols, $consoleWidth)
    {
        $result  = '';
        $padding = 2;


        // If there is only 1 column, just concatenate it
        if ($cols == 1) {
            foreach ($data as $row) {
                if (! isset($row[0])) {
                    continue;
                }
                $result .= $row[0] . "\n";
            }
            return $result;
        }

        // Get the string wrapper supporting UTF-8 character encoding
        $strWrapper = StringUtils::getWrapper('UTF-8');

        // Determine max width for each column
        $maxW = [];
        for ($x = 1; $x <= $cols; $x += 1) {
            $maxW[$x] = 0;
            foreach ($data as $row) {
                $maxW[$x] = max($maxW[$x], $strWrapper->strlen($row[$x - 1]) + $padding * 2);
            }
        }

        /*
         * Check if the sum of x-1 columns fit inside console window width - 10
         * chars. If columns do not fit inside console window, then we'll just
         * concatenate them and output as is.
         */
        $width = 0;
        for ($x = 1; $x < $cols; $x += 1) {
            $width += $maxW[$x];
        }

        if ($width >= $consoleWidth - 10) {
            foreach ($data as $row) {
                $result .= implode("    ", $row) . "\n";
            }
            return $result;
        }

        /*
         * Use Laminas\Text\Table to render the table.
         * The last column will use the remaining space in console window
         * (minus 1 character to prevent double wrapping at the edge of the
         * screen).
         */
        $maxW[$cols] = $consoleWidth - $width - 1;
        $table       = new Table\Table();
        $table->setColumnWidths($maxW);
        $table->setDecorator(new Table\Decorator\Blank());
        $table->setPadding(2);

        foreach ($data as $row) {
            $table->appendRow($row);
        }

        return $table->render();
    }

    /**
     * Report the 404 reason and/or exceptions
     *
     * @param  \Laminas\EventManager\EventInterface $e
     * @return string
     */
    protected function reportNotFoundReason($e)
    {
        if (! $this->displayNotFoundReason()) {
            return '';
        }
        $exception = $e->getParam('exception', false);
        if (! $exception && ! $this->reason) {
            return '';
        }

        $reason    = ! empty($this->reason) ? $this->reason : 'unknown';
        $reasons   = [
            Application::ERROR_CONTROLLER_NOT_FOUND => 'Could not match to a controller',
            Application::ERROR_CONTROLLER_INVALID   => 'Invalid controller specified',
            Application::ERROR_ROUTER_NO_MATCH      => 'Invalid arguments or no arguments provided',
            'unknown'                               => 'Unknown',
        ];
        $report = sprintf("\nReason for failure: %s\n", $reasons[$reason]);

        // @todo clean up once PHP 7 requirement is enforced
        while ($exception instanceof \Exception || $exception instanceof \Throwable) {
            $report .= sprintf(
                "Exception: %s\nTrace:\n%s\n",
                $exception->getMessage(),
                $exception->getTraceAsString()
            );
            $exception = $exception->getPrevious();
        }
        return $report;
    }
}
