<?php

namespace Velis\Api\HttpExceptionHandler;

use Exception;
use Phalcon\Http\Message\ResponseStatusCodeInterface as ResponseStatusCode;
use Throwable;
use Velis\Api\HttpExceptionHandler\Exception\InvalidMapClassException;
use Velis\Api\HttpExceptionHandler\Exception\MapClassNotFoundException;
use Velis\Api\HttpExceptionHandler\Map\DefaultApiMap;
use Velis\Api\HttpExceptionHandler\Map\MapInterface;
use Velis\Api\HttpExceptionHandler\Map\MapItem;
use Velis\App;
use Velis\Debugger\Debugger;

/**
 * This class is responsible for handling exceptions and formatting them to the proper response
 * - using Map for legacy Exceptions.
 * @author Szymon Janaczek <szymon.janaczek@velistech.com>
 */
class ExceptionHandler
{
    /** @var class-string<MapInterface>|null */
    private ?string $map = null;
    /** @var class-string<MapInterface> */
    private string $defaultMap = DefaultApiMap::class;
    protected HttpExceptionInterface $httpException;
    protected Throwable $originalException;
    protected array $additionalResponseData = [];

    /**
     * @param class-string<MapInterface>|null $map
     */
    protected function __construct(
        protected Debugger $debugger,
        ?string $map = null
    ) {
        if ($map !== null) {
            $this->setMap($map);
        }
    }

    // region Map
    /**
     * Get a map from the given MapInterface or use the default one.
     * @return iterable<int, MapItem>
     */
    protected function getMap(): iterable
    {
        $map = $this->map;
        if (!is_string($map)) {
            $map = $this->defaultMap;
        }

        /** @var MapInterface $mapInstance */
        $mapInstance = new $map();

        return $mapInstance->load();
    }

    /**
     * Here we can define default exception handler - it will be used if we can't find proper exception in the map
     * and the handled exception is not an instance of HttpExceptionInterface.
     */
    protected function defaultMapItem(): MapItem
    {
        return new MapItem(
            exceptions: [Exception::class],
            formatter: function (MapItem $item, Exception $exception): void {
                $item->message = 'Internal Server Error';
                if ($_SERVER['ON_DEV'] || $this->isDebugMode()) {
                    $item->message = $exception->getMessage();
                }
                $item->httpCode = ResponseStatusCode::STATUS_INTERNAL_SERVER_ERROR;
                $item->code = is_int($exception->getCode()) ? $exception->getCode() : null;
            }
        );
    }
    // endregion

    /**
     * Prepare final parameters basing on the found exception. At the end, call exception's report method.
     */
    public function handle(Throwable $throwable): ExceptionHandler
    {
        $mapItem = $this->getMatchedMapItem($throwable);
        $this->originalException = $throwable;
        $this->httpException = $mapItem->getHttpException();

        if ($mapItem->shouldReport()) {
            $mapItem->report();
        }

        if ($this->isDebugMode()) {
            $this->handleDebugMode($throwable);
        }

        return $this;
    }

    /**
     * Just a static wrapper for handle method.
     * @param class-string<MapInterface>|null $map
     */
    public static function parse(
        Throwable $throwable,
        ?string $map = null,
        ?Debugger $debugger = null
    ): ExceptionHandler {
        $instance = new self(debugger: $debugger ?? App::getService('debugger'));
        if (is_string($map)) {
            $instance->setMap($map);
        }
        $instance->handle($throwable);

        return $instance;
    }

    /**
     * We have to get actual exception. If it's an instance of the HttpExceptionInterface, we can use it directly.
     * Otherwise, we have to find the proper exception in the map.
     * If we can't find it, we have to use the default MapItem.
     */
    protected function getMatchedMapItem(Throwable $exception): MapItem
    {
        if ($exception instanceof HttpExceptionInterface) {
            return $this->defaultMapItem()->setException($exception);
        }

        $map = $this->getMap();
        foreach ($map as $item) {
            if (!$item->matchException($exception)) {
                continue;
            }

            return $item;
        }

        return $this
            ->defaultMapItem()
            ->setException($exception);
    }

    /**
     * Check whether debug mode is activated.
     */
    protected function isDebugMode(): bool
    {
        if (App::$config->settings->debugMode) {
            return true;
        }

        return
            ($_REQUEST['DEBUG_MODE'] == 1 || $_REQUEST['DEBUG_MODE'] == 'true')
            && App::isSuper();
    }

    protected function handleDebugMode(Throwable $throwable): void
    {
        $this->debugger->handle($throwable);

        $this->additionalResponseData['debug'] = $this->debugger->toArray();
    }

    // region Getters
    public function getHttpException(): HttpExceptionInterface
    {
        return $this->httpException;
    }

    /**
     * @return array{message: string, error: ?string, code: ?int, details: ?array}
     */
    public function toArray(): array
    {
        return [
            'message' => $this->httpException->getMessage(),
            'error' => $this->httpException->getError(),
            'code' => $this->httpException->getCode(),
            'details' => $this->httpException->getDetails(),
        ] + $this->additionalResponseData;
    }

    public function toJson(): string
    {
        return json_encode($this->toArray());
    }

    public function toHtml(): string
    {
        if ($this->debugger->isInitialized()) {
            return $this->debugger->toHtml();
        }

        // We are on the production environment and debug mode is not enabled.
        // So the debugger is disabled and there is no need to return HTML response about API error.
        return '';
    }
    // endregion

    // region Setters
    /**
     * Set a custom MapInterface.
     * It has to be a class that implements MapInterface.
     * This setter has to validate the given class before setting it.
     * @param class-string<MapInterface> $map
     * @throws MapClassNotFoundException
     * @throws InvalidMapClassException
     */
    public function setMap(string $map): void
    {
        if (!class_exists($map, MapInterface::class)) {
            throw new MapClassNotFoundException();
        }

        if (!is_subclass_of($map, MapInterface::class)) {
            throw new InvalidMapClassException();
        }

        $this->map = $map;
    }
    // endregion
}
