<?php
declare(strict_types=1);

/**
 * BEdita, API-first content management framework
 * Copyright 2016 ChannelWeb Srl, Chialab Srl
 *
 * This file is part of BEdita: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published
 * by the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
 */
namespace BEdita\API\Controller\Component;

use BEdita\API\Network\Exception\UnsupportedMediaTypeException;
use BEdita\API\Utility\JsonApi;
use Cake\Controller\Component;
use Cake\Event\EventInterface;
use Cake\Http\Exception\BadRequestException;
use Cake\Http\Exception\ConflictException;
use Cake\Http\Exception\ForbiddenException;
use Cake\Routing\Router;
use Cake\Utility\Hash;

/**
 * Handles JSON API data format in input and in output
 *
 * @since 4.0.0
 * @property \Cake\Controller\Component\RequestHandlerComponent $RequestHandler
 */
class JsonApiComponent extends Component
{
    /**
     * JSON API content type.
     *
     * @var string
     */
    public const CONTENT_TYPE = 'application/vnd.api+json';

    /**
     * Allowed JSON API content types.
     *
     * @var array
     */
    public const ALLOWED_CONTENT_TYPES = [
        'application/json',
        'application/vnd.api+json',
    ];

    /**
     * @inheritDoc
     */
    public $components = ['RequestHandler'];

    /**
     * @inheritDoc
     */
    protected $_defaultConfig = [
        'contentType' => null,
        'checkMediaType' => true,
        'resourceTypes' => null,
        'clientGeneratedIds' => false,
        'parseJson' => true,
    ];

    /**
     * @inheritDoc
     */
    public function initialize(array $config): void
    {
        $contentType = self::CONTENT_TYPE;
        if (!empty($config['contentType'])) {
            $contentType = $this->getController()->getResponse()->getMimeType($config['contentType']) ?: $config['contentType'];
        }
        $this->getController()->setResponse($this->getController()->getResponse()->withType($contentType));

        $this->RequestHandler->setConfig('viewClassMap.jsonapi', 'BEdita/API.JsonApi');
    }

    /**
     * @inheritDoc
     */
    public function beforeFilter(EventInterface $event)
    {
        $this->RequestHandler->setConfig('viewClassMap.json', 'BEdita/API.JsonApi');

        $contentType = $this->getController()->getRequest()->getHeaderLine('Content-Type');
        if (!in_array($contentType, static::ALLOWED_CONTENT_TYPES) || !$this->getConfig('parseJson')) {
            return;
        }

        $data = $this->parseInput();
        $this->getController()->setRequest($this->getController()->getRequest()->withParsedBody($data));
    }

    /**
     * Input data parser for JSON API format.
     * Request body has already been parsed by BodyParserMiddlare as simple JSON.
     *
     * @return array JSON API input data array
     * @throws \Cake\Http\Exception\BadRequestException When the request is malformed
     */
    protected function parseInput(): array
    {
        $data = $this->getController()->getRequest()->getData();
        if (empty($data)) {
            return [];
        }
        try {
            if (!is_array($data) || !array_key_exists('data', $data)) {
                throw new BadRequestException(__d('bedita', 'Invalid JSON input'));
            }

            return JsonApi::parseData((array)$data['data']);
        } catch (\InvalidArgumentException $e) {
            throw new BadRequestException(
                __d('bedita', 'Bad JSON API input'),
                400,
                $e
            );
        }
    }

    /**
     * Set occurred error.
     *
     * @param int $status HTTP error code.
     * @param string $title Brief description of error.
     * @param string $detail Long description of error
     * @param string $code Application specific error code.
     * @param array|null $meta Additional metadata about error.
     * @return void
     */
    public function error($status, $title, $detail = null, $code = null, ?array $meta = null)
    {
        $controller = $this->getController();

        $status = (string)$status;
        $code = (string)$code;

        $error = compact('status', 'title', 'detail', 'code', 'meta');
        $error = array_filter($error);

        $controller->set('_error', $error);
    }

    /**
     * Get links according to JSON API specifications.
     *
     * @return array
     */
    public function getLinks()
    {
        $request = $this->getController()->getRequest()->withParam('pass', []);
        $links = [
            'self' => Router::reverse($request, true),
            'home' => Router::url(['_name' => 'api:home'], true),
        ];

        $paging = $request->getAttribute('paging');
        if (!empty($paging) && is_array($paging)) {
            $paging = reset($paging);
            $query = $request->getQueryParams();

            $query['page'] = null;
            $links['first'] = Router::reverse($request->withQueryParams($query), true);

            $query['page'] = $paging['pageCount'] > 1 ? $paging['pageCount'] : null;
            $links['last'] = Router::reverse($request->withQueryParams($query), true);

            $links['prev'] = null;
            if ($paging['prevPage']) {
                $query['page'] = $paging['page'] > 2 ? $paging['page'] - 1 : null;
                $links['prev'] = Router::reverse($request->withQueryParams($query), true);
            }

            $links['next'] = null;
            if ($paging['nextPage']) {
                $query['page'] = $paging['page'] + 1;
                $links['next'] = Router::reverse($request->withQueryParams($query), true);
            }
        }

        return $links;
    }

    /**
     * Get common metadata.
     *
     * @return array
     */
    public function getMeta()
    {
        $meta = [];

        $paging = $this->getController()->getRequest()->getAttribute('paging');
        if (!empty($paging) && is_array($paging)) {
            $paging = reset($paging);
            $paging += [
                'current' => null,
                'page' => null,
                'count' => null,
                'perPage' => null,
                'pageCount' => null,
            ];

            $meta['pagination'] = [
                'count' => $paging['count'],
                'page' => $paging['page'],
                'page_count' => $paging['pageCount'],
                'page_items' => $paging['current'],
                'page_size' => $paging['perPage'],
            ];
        }

        return $meta;
    }

    /**
     * Check if given resource types are allowed.
     *
     * @param mixed $types One or more allowed types to check resources array against.
     * @param array|null $data Data to be checked. By default, this is taken from the request.
     * @return void
     * @throws \Cake\Http\Exception\ConflictException Throws an exception if a resource has a non-supported `type`.
     */
    protected function allowedResourceTypes($types, ?array $data = null)
    {
        $data = $data ?? $this->getController()->getRequest()->getData();
        if (!$data || !$types || !$this->getConfig('parseJson')) {
            return;
        }
        $data = (array)$data;
        $types = (array)$types;

        if (Hash::numeric(array_keys($data))) {
            foreach ($data as $item) {
                if (!is_array($item)) {
                    continue;
                }

                $this->allowedResourceTypes($types, $item);
            }

            return;
        }

        if (empty($data['type']) || !in_array($data['type'], $types)) {
            throw new ConflictException('Unsupported resource type');
        }
    }

    /**
     * Check that no resource includes a client-generated ID, if this feature is unsupported.
     *
     * @param bool $allow Should client-generated IDs be allowed?
     * @param array|null $data Data to be checked. By default, this is taken from the request.
     * @return void
     * @throws \Cake\Http\Exception\ForbiddenException Throws an exception if a resource has a client-generated
     *      ID, but this feature is not supported.
     */
    protected function allowClientGeneratedIds($allow = true, ?array $data = null)
    {
        $data = $data ?? $this->getController()->getRequest()->getData();
        if (!$data || $allow) {
            return;
        }
        $data = (array)$data;

        if (Hash::numeric(array_keys($data))) {
            foreach ($data as $item) {
                if (!is_array($item)) {
                    continue;
                }

                $this->allowClientGeneratedIds($allow, $item);
            }

            return;
        }

        if (!empty($data['id'])) {
            throw new ForbiddenException('Client-generated IDs are not supported');
        }
    }

    /**
     * Perform preliminary checks and operations.
     *
     * @return void
     * @throws \BEdita\API\Network\Exception\UnsupportedMediaTypeException Throws an exception if the `Accept` header
     *      does not comply to JSON API specifications and `checkMediaType` configuration is enabled.
     * @throws \Cake\Http\Exception\ConflictException Throws an exception if a resource in the payload has a
     *      non-supported `type`.
     * @throws \Cake\Http\Exception\ForbiddenException Throws an exception if a resource in the payload includes a
     *      client-generated ID, but the feature is not supported.
     */
    public function startup()
    {
        $controller = $this->getController();

        if ($controller->getRequest()->is('jsonapi')) {
            $this->RequestHandler->renderAs($controller, 'jsonapi');
        }

        if ($this->getConfig('checkMediaType') && trim($controller->getRequest()->getHeaderLine('accept')) !== self::CONTENT_TYPE) {
            // http://jsonapi.org/format/#content-negotiation-servers
            throw new UnsupportedMediaTypeException(
                __d('bedita', 'Bad request content type "{0}"', $controller->getRequest()->getHeaderLine('Accept'))
            );
        }

        if ($controller->getRequest()->is(['post', 'patch'])) {
            $this->allowedResourceTypes($this->getConfig('resourceTypes'));
        }

        if ($controller->getRequest()->is('post') && !$this->getConfig('clientGeneratedIds')) {
            $this->allowClientGeneratedIds(false);
        }
    }

    /**
     * Perform operations before view rendering.
     *
     * @return void
     */
    public function beforeRender()
    {
        $controller = $this->getController();

        $links = [];
        if ($controller->viewBuilder()->hasVar('_links')) {
            $links = (array)$controller->viewBuilder()->getVar('_links');
        }
        $links += $this->getLinks();

        $meta = [];
        if ($controller->viewBuilder()->hasVar('_meta')) {
            $meta = (array)$controller->viewBuilder()->getVar('_meta');
        }
        $meta += $this->getMeta();

        $controller->set([
            '_links' => $links,
            '_meta' => $meta,
        ]);
    }
}
