Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.67% covered (success)
98.67%
74 / 75
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralErrorHandler
98.65% covered (success)
98.65%
73 / 74
90.91% covered (success)
90.91%
10 / 11
36
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 unregister
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setDebug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDebugMode
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveDebugFlag
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 resolveEnvIsDev
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 handleError
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 handleException
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 handleShutdown
n/a
0 / 0
n/a
0 / 0
5
 buildDebugMarkdown
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeLog
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
5
 ensureLogDirectoryExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Error;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use Throwable;
12
13/**
14 * Central Error and Exception Handler Engine.
15 *
16 * Captures all PHP errors, uncaught exceptions, and fatal shutdown errors,
17 * formatting and logging them centrally outside the public web root.
18 *
19 * @package App\Core\Error
20 */
21final class CentralErrorHandler
22{
23    /**
24     * Absolute filesystem path to storage/logs directory.
25     *
26     * @var string|null
27     */
28    private static ?string $logDirectory = null;
29
30    /**
31     * Explicit debug mode flag override.
32     *
33     * @var bool|null
34     */
35    private static ?bool $debug = null;
36
37    /**
38     * Registers global PHP error, exception, and shutdown handlers.
39     *
40     * @param string $logDirectory Target directory for log files.
41     * @return void
42     */
43    public static function register(string $logDirectory): void
44    {
45        self::$logDirectory = $logDirectory;
46        self::ensureLogDirectoryExists($logDirectory);
47
48        set_error_handler([self::class, 'handleError']);
49        set_exception_handler([self::class, 'handleException']);
50        register_shutdown_function([self::class, 'handleShutdown']);
51    }
52
53    /**
54     * Unregisters error and exception handlers.
55     *
56     * @return void
57     */
58    public static function unregister(): void
59    {
60        restore_error_handler();
61        restore_exception_handler();
62    }
63
64    /**
65     * Configures explicit debug mode override.
66     *
67     * @param bool|null $debug Debug mode state.
68     * @return void
69     */
70    public static function setDebug(?bool $debug): void
71    {
72        self::$debug = $debug;
73    }
74
75    /**
76     * Determines whether application is currently running in debug/developer mode.
77     *
78     * @return bool True if debug or dev mode is active.
79     */
80    public static function isDebugMode(): bool
81    {
82        if (self::$debug !== null) {
83            return self::$debug;
84        }
85
86        $debugVal = self::resolveDebugFlag();
87        if ($debugVal !== null) {
88            return $debugVal;
89        }
90
91        return self::resolveEnvIsDev();
92    }
93
94    /**
95     * Resolves explicit boolean debug flag from environment or server superglobals.
96     *
97     * @return bool|null Resolved flag or null if unspecified.
98     */
99    private static function resolveDebugFlag(): ?bool
100    {
101        $candidates = [
102            $_ENV['APP_DEBUG'] ?? null,
103            $_SERVER['APP_DEBUG'] ?? null,
104            getenv('APP_DEBUG') ?: null,
105        ];
106        foreach ($candidates as $candidate) {
107            if ($candidate !== null && $candidate !== false && $candidate !== '') {
108                return (bool) filter_var($candidate, FILTER_VALIDATE_BOOLEAN);
109            }
110        }
111
112        return null;
113    }
114
115    /**
116     * Determines whether active environment indicates development mode.
117     *
118     * @return bool True if environment is not strictly production.
119     */
120    private static function resolveEnvIsDev(): bool
121    {
122        $candidates = [
123            $_ENV['APP_ENV'] ?? null,
124            $_SERVER['APP_ENV'] ?? null,
125            getenv('APP_ENV') ?: null,
126        ];
127        foreach ($candidates as $candidate) {
128            if ($candidate !== null && $candidate !== false && $candidate !== '') {
129                return !in_array(strtolower((string) $candidate), ['prod', 'production'], true);
130            }
131        }
132
133        return true;
134    }
135
136    /**
137     * Handles PHP runtime errors and converts non-suppressed errors to ConvertedPhpErrorException.
138     *
139     * @param int    $severity Error severity level.
140     * @param string $message  Error message.
141     * @param string $file     File where error occurred.
142     * @param int    $line     Line number where error occurred.
143     * @return bool Returns true to bypass standard PHP error handler.
144     * @throws ConvertedPhpErrorException Converted PHP error exception.
145     */
146    public static function handleError(int $severity, string $message, string $file, int $line): bool
147    {
148        if (!(error_reporting() & $severity)) {
149            return false;
150        }
151
152        throw new ConvertedPhpErrorException($message, 0, $severity, $file, $line);
153    }
154
155    /**
156     * Handles uncaught exceptions.
157     *
158     * @param Throwable $exception Uncaught exception instance.
159     * @return void
160     */
161    public static function handleException(Throwable $exception): void
162    {
163        self::writeLog(
164            'PHP_EXCEPTION',
165            $exception->getMessage(),
166            $exception->getFile(),
167            $exception->getLine(),
168            $exception->getTraceAsString()
169        );
170        // @codeCoverageIgnoreStart
171        if (PHP_SAPI !== 'cli' && !headers_sent()) {
172            ErrorResponseRenderer::respondWithError(
173                get_class($exception),
174                $exception->getMessage(),
175                $exception->getFile(),
176                $exception->getLine(),
177                $exception->getTraceAsString(),
178                self::isDebugMode()
179            );
180        }
181        // @codeCoverageIgnoreEnd
182    }
183
184    /**
185     * Handles fatal shutdown errors.
186     *
187     * @return void
188     */
189    public static function handleShutdown(): void
190    {
191        // @codeCoverageIgnoreStart
192        $error = error_get_last();
193        if (
194            $error !== null && in_array(
195                $error['type'],
196                [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR],
197                true
198            )
199        ) {
200            self::writeLog('PHP_FATAL_ERROR', $error['message'], $error['file'], $error['line'], '');
201            if (PHP_SAPI !== 'cli' && !headers_sent()) {
202                ErrorResponseRenderer::respondWithError(
203                    'PHP Fatal Error',
204                    $error['message'],
205                    $error['file'],
206                    $error['line'],
207                    '',
208                    self::isDebugMode()
209                );
210            }
211        }
212        // @codeCoverageIgnoreEnd
213    }
214
215    /**
216     * Builds structured markdown error report for one-click clipboard copying.
217     *
218     * @param string $errorClass Classification or exception class.
219     * @param string $message    Main error message.
220     * @param string $file       Originating file path.
221     * @param int    $line       Originating line number.
222     * @param string $trace      Trace string.
223     * @return string Formatted markdown string.
224     */
225    public static function buildDebugMarkdown(
226        string $errorClass,
227        string $message,
228        string $file,
229        int    $line,
230        string $trace
231    ): string {
232        return ErrorResponseRenderer::buildDebugMarkdown($errorClass, $message, $file, $line, $trace);
233    }
234
235    /**
236     * Writes formatted log entry to target log file with fallback to system error_log.
237     *
238     * @param string $type Error category indicator.
239     * @param string $message Main error message.
240     * @param string $file Target file path.
241     * @param int $line Target line number.
242     * @param string $trace Stack trace string.
243     * @return void
244     */
245    public static function writeLog(string $type, string $message, string $file, int $line, string $trace): void
246    {
247        $dir = self::$logDirectory ?? dirname(__DIR__, 2) . '/storage/logs';
248        self::ensureLogDirectoryExists($dir);
249
250        $timestamp = date('Y-m-d H:i:s.u');
251        $requestUri = str_replace(["\r", "\n"], '', (string)($_SERVER['REQUEST_URI'] ?? 'CLI'));
252        $requestMethod = str_replace(["\r", "\n"], '', (string)($_SERVER['REQUEST_METHOD'] ?? 'CLI'));
253        $ipAddress = str_replace(["\r", "\n"], '', (string)($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));
254        $userAgent = str_replace(["\r", "\n"], '', (string)($_SERVER['HTTP_USER_AGENT'] ?? 'N/A'));
255        $rawSessionId = (string) session_id();
256        $sessionId = $rawSessionId !== '' ? substr(hash('sha256', $rawSessionId), 0, 12) : 'none';
257        $userId = isset($_SESSION['user_id']) ? (string)$_SESSION['user_id'] : 'guest';
258
259        $entry = sprintf(
260            "[%s] [%s] [%s %s] [IP: %s] [User: %s] [Session: %s] [UA: %s]\n" .
261            "Message: %s\nFile: %s:%d\nTrace:\n%s\n--------------------------------------------------\n",
262            $timestamp,
263            $type,
264            $requestMethod,
265            $requestUri,
266            $ipAddress,
267            $userId,
268            $sessionId,
269            $userAgent,
270            $message,
271            $file,
272            $line,
273            $trace !== '' ? $trace : 'None'
274        );
275
276        $logFile = $dir . '/php_errors.log';
277        $written = @file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
278
279        if ($written === false) {
280            // @codeCoverageIgnoreStart
281            error_log('[Ammonly CentralErrorHandler Fallback] ' . $entry);
282            // @codeCoverageIgnoreEnd
283        }
284    }
285
286    /**
287     * Ensures log directory exists with appropriate permissions (0755).
288     *
289     * @param string $dir Path to log directory.
290     * @return void
291     */
292    private static function ensureLogDirectoryExists(string $dir): void
293    {
294        if (!is_dir($dir)) {
295            @mkdir($dir, 0755, true);
296        }
297    }
298}