<?php
/**
 * Piwik - Open source web analytics
 *
 * @link http://piwik.org
 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 *
 * @category Piwik
 * @package Piwik
 */
namespace Piwik\API;

use Exception;
use Piwik\API\DataTableManipulator\Flattener;
use Piwik\API\DataTableManipulator\LabelFilter;
use Piwik\API\DataTableManipulator\ReportTotalsCalculator;
use Piwik\Common;
use Piwik\DataTable\Renderer\Json;
use Piwik\DataTable\Renderer;
use Piwik\DataTable\Simple;
use Piwik\DataTable;

/**
 * @package Piwik
 * @subpackage Piwik_API
 */
class ResponseBuilder
{
    const DISPLAY_BACKTRACE_DEBUG = false;

    private $request = null;
    private $outputFormat = null;

    private $apiModule = false;
    private $apiMethod = false;

    /**
     * @param string $outputFormat
     * @param array $request
     */
    public function __construct($outputFormat, $request = array())
    {
        $this->request = $request;
        $this->outputFormat = $outputFormat;
    }

    /**
     * This method processes the data resulting from the API call.
     *
     * - If the data resulted from the API call is a DataTable then
     *         - we apply the standard filters if the parameters have been found
     *           in the URL. For example to offset,limit the Table you can add the following parameters to any API
     *        call that returns a DataTable: filter_limit=10&filter_offset=20
     *         - we apply the filters that have been previously queued on the DataTable
     * @see DataTable::queueFilter()
     *         - we apply the renderer that generate the DataTable in a given format (XML, PHP, HTML, JSON, etc.)
     *           the format can be changed using the 'format' parameter in the request.
     *        Example: format=xml
     *
     * - If there is nothing returned (void) we display a standard success message
     *
     * - If there is a PHP array returned, we try to convert it to a dataTable
     *   It is then possible to convert this datatable to any requested format (xml/etc)
     *
     * - If a bool is returned we convert to a string (true is displayed as 'true' false as 'false')
     *
     * - If an integer / float is returned, we simply return it
     *
     * @param mixed $value The initial returned value, before post process. If set to null, success response is returned.
     * @param bool|string $apiModule The API module that was called
     * @param bool|string $apiMethod The API method that was called
     * @return mixed  Usually a string, but can still be a PHP data structure if the format requested is 'original'
     */
    public function getResponse($value = null, $apiModule = false, $apiMethod = false)
    {
        $this->apiModule = $apiModule;
        $this->apiMethod = $apiMethod;

        // when null or void is returned from the api call, we handle it as a successful operation
        if (!isset($value)) {
            return $this->handleSuccess();
        }

        // If the returned value is an object DataTable we
        // apply the set of generic filters if asked in the URL
        // and we render the DataTable according to the format specified in the URL
        if ($value instanceof DataTable
            || $value instanceof DataTable\Map
        ) {
            return $this->handleDataTable($value);
        }

        // Case an array is returned from the API call, we convert it to the requested format
        // - if calling from inside the application (format = original)
        //    => the data stays unchanged (ie. a standard php array or whatever data structure)
        // - if any other format is requested, we have to convert this data structure (which we assume
        //   to be an array) to a DataTable in order to apply the requested DataTable_Renderer (for example XML)
        if (is_array($value)) {
            return $this->handleArray($value);
        }

        // original data structure requested, we return without process
        if ($this->outputFormat == 'original') {
            return $value;
        }

        if (is_object($value)
            || is_resource($value)
        ) {
            return $this->getResponseException(new Exception('The API cannot handle this data structure.'));
        }

        // bool // integer // float // serialized object
        return $this->handleScalar($value);
    }

    /**
     * Returns an error $message in the requested $format
     *
     * @param Exception $e
     * @throws Exception
     * @return string
     */
    public function getResponseException(Exception $e)
    {
        $format = strtolower($this->outputFormat);

        if ($format == 'original') {
            throw $e;
        }

        try {
            $renderer = Renderer::factory($format);
        } catch (Exception $exceptionRenderer) {
            return "Error: " . $e->getMessage() . " and: " . $exceptionRenderer->getMessage();
        }

        $e = $this->decorateExceptionWithDebugTrace($e);

        $renderer->setException($e);

        if ($format == 'php') {
            $renderer->setSerialize($this->caseRendererPHPSerialize());
        }

        return $renderer->renderException();
    }

    /**
     * @param Exception $e
     * @return Exception
     */
    protected function decorateExceptionWithDebugTrace(Exception $e)
    {
        // If we are in tests, show full backtrace
        if (defined('PIWIK_PATH_TEST_TO_ROOT')) {
            if (self::DISPLAY_BACKTRACE_DEBUG
                || \Piwik_ShouldPrintBackTraceWithMessage()
            ) {
                $message = $e->getMessage() . " in \n " . $e->getFile() . ":" . $e->getLine() . " \n " . $e->getTraceAsString();
            } else {
                $message = $e->getMessage() . "\n \n --> To temporarily debug this error further, set const DISPLAY_BACKTRACE_DEBUG=true; in " . basename(__FILE__);
            }
            return new Exception($message);
        }
        return $e;
    }

    /**
     * Returns true if the user requested to serialize the output data (&serialize=1 in the request)
     *
     * @param mixed $defaultSerializeValue Default value in case the user hasn't specified a value
     * @return bool
     */
    protected function caseRendererPHPSerialize($defaultSerializeValue = 1)
    {
        $serialize = Common::getRequestVar('serialize', $defaultSerializeValue, 'int', $this->request);
        if ($serialize) {
            return true;
        }
        return false;
    }

    /**
     * Apply the specified renderer to the DataTable
     *
     * @param DataTable|array $dataTable
     * @return string
     */
    protected function getRenderedDataTable($dataTable)
    {
        $format = strtolower($this->outputFormat);

        // if asked for original dataStructure
        if ($format == 'original') {
            // by default "original" data is not serialized
            if ($this->caseRendererPHPSerialize($defaultSerialize = 0)) {
                $dataTable = serialize($dataTable);
            }
            return $dataTable;
        }

        $method = Common::getRequestVar('method', '', 'string', $this->request);

        $renderer = Renderer::factory($format);
        $renderer->setTable($dataTable);
        $renderer->setRenderSubTables(Common::getRequestVar('expanded', false, 'int', $this->request));
        $renderer->setHideIdSubDatableFromResponse(Common::getRequestVar('hideIdSubDatable', false, 'int', $this->request));

        if ($format == 'php') {
            $renderer->setSerialize($this->caseRendererPHPSerialize());
            $renderer->setPrettyDisplay(Common::getRequestVar('prettyDisplay', false, 'int', $this->request));
        } else if ($format == 'html') {
            $renderer->setTableId($this->request['method']);
        } else if ($format == 'csv' || $format == 'tsv') {
            $renderer->setConvertToUnicode(Common::getRequestVar('convertToUnicode', true, 'int', $this->request));
        }

        // prepare translation of column names
        if ($format == 'html' || $format == 'csv' || $format == 'tsv' || $format = 'rss') {
            $renderer->setApiMethod($method);
            $renderer->setIdSite(Common::getRequestVar('idSite', false, 'int', $this->request));
            $renderer->setTranslateColumnNames(Common::getRequestVar('translateColumnNames', false, 'int', $this->request));
        }

        return $renderer->render();
    }

    /**
     * Returns a success $message in the requested $format
     *
     * @param string $message
     * @return string
     */
    protected function handleSuccess($message = 'ok')
    {
        // return a success message only if no content has already been buffered, useful when APIs return raw text or html content to the browser
        if (!ob_get_contents()) {
            switch ($this->outputFormat) {
                case 'xml':
                    @header("Content-Type: text/xml;charset=utf-8");
                    $return =
                        "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" .
                        "<result>\n" .
                        "\t<success message=\"" . $message . "\" />\n" .
                        "</result>";
                    break;
                case 'json':
                    @header("Content-Type: application/json");
                    $return = '{"result":"success", "message":"' . $message . '"}';
                    break;
                case 'php':
                    $return = array('result' => 'success', 'message' => $message);
                    if ($this->caseRendererPHPSerialize()) {
                        $return = serialize($return);
                    }
                    break;

                case 'csv':
                    @header("Content-Type: application/vnd.ms-excel");
                    @header("Content-Disposition: attachment; filename=piwik-report-export.csv");
                    $return = "message\n" . $message;
                    break;

                default:
                    $return = 'Success:' . $message;
                    break;
            }
            return $return;
        }
    }

    /**
     * Converts the given scalar to an data table
     *
     * @param mixed $scalar
     * @return string
     */
    protected function handleScalar($scalar)
    {
        $dataTable = new Simple();
        $dataTable->addRowsFromArray(array($scalar));
        return $this->getRenderedDataTable($dataTable);
    }

    /**
     * Handles the given data table
     *
     * @param DataTable $datatable
     * @return string
     */
    protected function handleDataTable($datatable)
    {
        // if requested, flatten nested tables
        if (Common::getRequestVar('flat', '0', 'string', $this->request) == '1') {
            $flattener = new Flattener($this->apiModule, $this->apiMethod, $this->request);
            if (Common::getRequestVar('include_aggregate_rows', '0', 'string', $this->request) == '1') {
                $flattener->includeAggregateRows();
            }
            $datatable = $flattener->flatten($datatable);
        }

        if (1 == Common::getRequestVar('totals', '1', 'integer', $this->request)) {
            $genericFilter = new ReportTotalsCalculator($this->apiModule, $this->apiMethod, $this->request);
            $datatable     = $genericFilter->calculate($datatable);
        }

        // if the flag disable_generic_filters is defined we skip the generic filters
        if (0 == Common::getRequestVar('disable_generic_filters', '0', 'string', $this->request)) {
            $genericFilter = new DataTableGenericFilter($this->request);
            $genericFilter->filter($datatable);
        }

        // we automatically safe decode all datatable labels (against xss)
        $datatable->queueFilter('SafeDecodeLabel');

        // if the flag disable_queued_filters is defined we skip the filters that were queued
        if (Common::getRequestVar('disable_queued_filters', 0, 'int', $this->request) == 0) {
            $datatable->applyQueuedFilters();
        }

        // use the ColumnDelete filter if hideColumns/showColumns is provided (must be done
        // after queued filters are run so processed metrics can be removed, too)
        $hideColumns = Common::getRequestVar('hideColumns', '', 'string', $this->request);
        $showColumns = Common::getRequestVar('showColumns', '', 'string', $this->request);
        if ($hideColumns !== '' || $showColumns !== '') {
            $datatable->filter('ColumnDelete', array($hideColumns, $showColumns));
        }

        // apply label filter: only return rows matching the label parameter (more than one if more than one label)
        $label = $this->getLabelFromRequest($this->request);
        if (!empty($label)) {
            $addLabelIndex = Common::getRequestVar('labelFilterAddLabelIndex', 0, 'int', $this->request) == 1;

            $filter = new LabelFilter($this->apiModule, $this->apiMethod, $this->request);
            $datatable = $filter->filter($label, $datatable, $addLabelIndex);
        }
        return $this->getRenderedDataTable($datatable);
    }

    /**
     * Converts the given simple array to a data table
     *
     * @param array $array
     * @return string
     */
    protected function handleArray($array)
    {
        if ($this->outputFormat == 'original') {
            // we handle the serialization. Because some php array have a very special structure that
            // couldn't be converted with the automatic DataTable->addRowsFromSimpleArray
            // the user may want to request the original PHP data structure serialized by the API
            // in case he has to setup serialize=1 in the URL
            if ($this->caseRendererPHPSerialize($defaultSerialize = 0)) {
                return serialize($array);
            }
            return $array;
        }

        $multiDimensional = $this->handleMultiDimensionalArray($array);
        if ($multiDimensional !== false) {
            return $multiDimensional;
        }

        return $this->getRenderedDataTable($array);
    }

    /**
     * Is this a multi dimensional array?
     * Multi dim arrays are not supported by the Datatable renderer.
     * We manually render these.
     *
     * array(
     *         array(
     *             1,
     *             2 => array( 1,
     *                         2
     *             )
     *        ),
     *        array( 2,
     *               3
     *        )
     *    );
     *
     * @param array $array
     * @return string|bool  false if it isn't a multidim array
     */
    protected function handleMultiDimensionalArray($array)
    {
        $first = reset($array);
        foreach ($array as $first) {
            if (is_array($first)) {
                foreach ($first as $key => $value) {
                    // Yes, this is a multi dim array
                    if (is_array($value)) {
                        switch ($this->outputFormat) {
                            case 'json':
                                @header("Content-Type: application/json");
                                return self::convertMultiDimensionalArrayToJson($array);
                                break;

                            case 'php':
                                if ($this->caseRendererPHPSerialize($defaultSerialize = 0)) {
                                    return serialize($array);
                                }
                                return $array;

                            case 'xml':
                                @header("Content-Type: text/xml;charset=utf-8");
                                return $this->getRenderedDataTable($array);
                            default:
                                break;
                        }
                    }
                }
            }
        }
        return false;
    }

    /**
     * Render a multidimensional array to Json
     * Handle DataTable|Set elements in the first dimension only, following case does not work:
     * array(
     *        array(
     *            DataTable,
     *            2 => array(
     *                1,
     *                2
     *            ),
     *        ),
     *    );
     *
     * @param array $array can contain scalar, arrays, DataTable and Set
     * @return string
     */
    public static function convertMultiDimensionalArrayToJson($array)
    {
        $jsonRenderer = new Json();
        $jsonRenderer->setTable($array);
        $renderedReport = $jsonRenderer->render();
        return $renderedReport;
    }

    /**
     * Returns the value for the label query parameter which can be either a string
     * (ie, label=...) or array (ie, label[]=...).
     *
     * @param array $request
     * @return array
     */
    static public function getLabelFromRequest($request)
    {
        $label = Common::getRequestVar('label', array(), 'array', $request);
        if (empty($label)) {
            $label = Common::getRequestVar('label', '', 'string', $request);
            if (!empty($label)) {
                $label = array($label);
            }
        }

        $label = self::unsanitizeLabelParameter($label);
        return $label;
    }

    static public function unsanitizeLabelParameter($label)
    {
        // this is needed because Proxy uses Common::getRequestVar which in turn
        // uses Common::sanitizeInputValue. This causes the > that separates recursive labels
        // to become &gt; and we need to undo that here.
        $label = Common::unsanitizeInputValues($label);
        return $label;
    }
}
