<?php

namespace MintyPHP\Http;

use MintyPHP\Debugger;

final class ErrorHandler
{
    /** @phpstan-ignore property.onlyWritten (Memory reserve freed on OOM to allow error rendering.) */
    private static ?string $reservedMemory = null;
    private static bool $registered = false;
    private static bool $handling = false;

    public static function register(): void
    {
        if (self::$registered) {
            return;
        }

        self::$registered = true;
        self::$reservedMemory = str_repeat('x', 64 * 1024); // 64 KB reserve for OOM recovery.

        set_exception_handler([self::class, 'handleException']);
        set_error_handler([self::class, 'handleError']);
        register_shutdown_function([self::class, 'handleShutdown']);
    }

    public static function handleException(\Throwable $exception): void
    {
        // Prevent recursive handling if the error page itself throws.
        if (self::$handling) {
            self::renderMinimalFallback($exception);
            return;
        }

        self::$handling = true;

        try {
            // Free reserved memory so we have room to work after OOM.
            self::$reservedMemory = null;

            // Discard any partial output from the failed request.
            if (!self::isPhpUnitRuntime()) {
                while (ob_get_level() > 0) {
                    ob_end_clean();
                }
            }

            // Set HTTP 500 if no error status has been set yet.
            $currentStatus = http_response_code();
            if ($currentStatus === false || $currentStatus < 400) {
                http_response_code(500);
            }

            // Collect diagnostic data.
            $data = ErrorDataCollector::collect($exception);

            // Log to file (always, in both dev and prod).
            ErrorLogger::log($exception, $data);

            // Bail out if headers were already sent (cannot render a page).
            if (headers_sent()) {
                self::$handling = false;
                return;
            }

            // API requests get a JSON response, not HTML.
            if (self::isApiRequest()) {
                self::sendApiError($data['request']['request_id'] ?? null);
                self::$handling = false;
                return;
            }

            // Render HTML error page.
            if (self::shouldShowDebugPage()) {
                echo ErrorRenderer::renderDev($data);
            } else {
                $requestId = (string) ($data['request']['request_id'] ?? 'unknown');
                echo ErrorRenderer::renderProd($requestId);
            }
        } catch (\Throwable $inner) {
            self::renderMinimalFallback($exception);
        }

        self::$handling = false;
    }

    /**
     * Converts PHP errors into ErrorException.
     * Respects the error_reporting() bitmask (honors @ suppression operator).
     */
    public static function handleError(int $severity, string $message, string $file, int $line): true
    {
        if (!(error_reporting() & $severity)) {
            // Error was suppressed with @.
            return true;
        }

        if (self::shouldSuppressVendorIssue($severity, $file)) {
            return true;
        }

        throw new \ErrorException($message, 0, $severity, $file, $line);
    }

    /**
     * Catches fatal errors that bypass set_error_handler (E_PARSE, E_COMPILE_ERROR, etc.).
     */
    public static function handleShutdown(): void
    {
        self::$reservedMemory = null;

        $error = error_get_last();
        if (!is_array($error)) {
            return;
        }

        $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
        if (!in_array($error['type'], $fatalTypes, true)) {
            return;
        }

        // Only act if we haven't already handled this (e.g. via set_exception_handler).
        if (self::$handling) {
            return;
        }

        $exception = new \ErrorException(
            $error['message'],
            0,
            $error['type'],
            $error['file'],
            $error['line'],
        );

        self::handleException($exception);
    }

    private static function isApiRequest(): bool
    {
        if (defined('MINTY_API_REQUEST')) {
            return true;
        }

        $uri = $_SERVER['REQUEST_URI'] ?? '';
        $path = ltrim(parse_url($uri, PHP_URL_PATH) ?? '', '/');

        return str_starts_with($path, 'api/');
    }

    private static function shouldShowDebugPage(): bool
    {
        // Check the Debugger flag, which mirrors APP_DEBUG.
        if (class_exists(Debugger::class, false) && Debugger::$enabled) {
            return true;
        }

        return false;
    }

    private static function sendApiError(?string $requestId): void
    {
        header('Content-Type: application/json; charset=utf-8');
        $payload = [
            'ok' => false,
            'error_code' => 'internal_error',
        ];
        if ($requestId !== null) {
            $payload['request_id'] = $requestId;
        }

        echo json_encode($payload, JSON_UNESCAPED_SLASHES);
    }

    private static function renderMinimalFallback(\Throwable $exception): void
    {
        // Last-resort plain-text response when everything else is broken.
        $requestId = 'unknown';
        try {
            $requestId = RequestContext::currentId() ?? RequestContext::id();
        } catch (\Throwable) {
        }

        if (!headers_sent()) {
            http_response_code(500);
            header('Content-Type: text/plain; charset=utf-8');
        }

        echo "500 Internal Server Error\nRequest ID: {$requestId}\n";

        // In debug mode, also show the exception for developers.
        if (class_exists(Debugger::class, false) && Debugger::$enabled) {
            echo "\n" . $exception->getMessage() . "\n" . $exception->getTraceAsString() . "\n";
        }
    }

    private static function isPhpUnitRuntime(): bool
    {
        return defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__');
    }

    private static function shouldSuppressVendorIssue(int $severity, string $file): bool
    {
        if (!self::isVendorPath($file)) {
            return false;
        }

        return match (self::vendorIssueMode()) {
            'suppress_deprecations' => self::isDeprecationSeverity($severity),
            'suppress_deprecations_warnings' => self::isDeprecationSeverity($severity) || self::isWarningSeverity($severity),
            default => false,
        };
    }

    private static function vendorIssueMode(): string
    {
        $raw = getenv('APP_VENDOR_PHP_ISSUES_MODE');
        if ($raw === false) {
            return 'strict';
        }

        $mode = strtolower(trim((string) $raw));
        return in_array($mode, ['strict', 'suppress_deprecations', 'suppress_deprecations_warnings'], true)
            ? $mode
            : 'strict';
    }

    private static function isDeprecationSeverity(int $severity): bool
    {
        return in_array($severity, [E_DEPRECATED, E_USER_DEPRECATED], true);
    }

    private static function isWarningSeverity(int $severity): bool
    {
        return in_array($severity, [E_WARNING, E_USER_WARNING], true);
    }

    private static function isVendorPath(string $file): bool
    {
        $normalized = str_replace('\\', '/', $file);
        return str_contains($normalized, '/vendor/');
    }
}
