<?php

declare(strict_types=1);

namespace Apexl\Io\Logger\Handlers;

use DateTimeInterface;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Logger;
use Throwable;

/**
 * Posts records to a Google Chat space through an incoming webhook.
 *
 * Monolog ships no Google Chat handler, so this is written against the API directly.
 * The message shape — a notification `text` plus a cardsV2 card whose collapsible
 * "Details" section carries one decoratedText widget per field — is adapted from
 * theriddleofenigma/laravel-google-chat-log (MIT). That package targets Monolog 3 and
 * Laravel 11, so none of it could be depended on; the card layout and the level colours
 * are the parts worth borrowing.
 *
 * Chat has no per-message colour the way a Slack attachment does. The level colour goes
 * on the level widget instead, via the `<font>` tag that Chat allows inside card text.
 */
final class GoogleChatHandler extends AbstractProcessingHandler
{
    /**
     * Level colours, as in the reference package. Red covers everything at error and
     * above: by the time a message is that severe the distinction between error and
     * emergency is not one a colour needs to carry.
     */
    private const array COLOURS = [
        'DEBUG' => '#000000',
        'INFO' => '#48d62f',
        'NOTICE' => '#00aeff',
        'WARNING' => '#ffc400',
        'ERROR' => '#ff1100',
        'CRITICAL' => '#ff1100',
        'ALERT' => '#ff1100',
        'EMERGENCY' => '#ff1100',
    ];

    /** Chat rejects a message whose text exceeds this. */
    private const int MAX_TEXT_LENGTH = 4096;

    /** The card header is a single line; the full message is in the details section. */
    private const int MAX_HEADER_LENGTH = 200;

    private const int CONNECT_TIMEOUT_SECONDS = 2;

    private const int TIMEOUT_SECONDS = 5;

    public function __construct(
        private readonly string $webhookUrl,
        private readonly string $subtitle,
        private readonly bool $includeContext = true,
        private readonly array $excludeFields = [],
        private readonly ?string $threadKey = null,
        int|string $level = Logger::ERROR,
        bool $bubble = true,
    ) {
        parent::__construct($level, $bubble);
    }

    protected function write(array $record): void
    {
        try {
            $this->post(json_encode($this->payload($record), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
        } catch (Throwable $throwable) {
            // Never let a failed notification take down the process it is reporting on.
            error_log(
                sprintf(
                    'Unable to post log record to Google Chat: %s',
                    $throwable->getMessage()
                )
            );
        }
    }

    private function payload(array $record): array
    {
        $levelName = (string) ($record['level_name'] ?? 'INFO');
        $message = (string) ($record['message'] ?? '');

        $payload = [
            'text' => $this->truncate(sprintf('%s: %s', $levelName, $message), self::MAX_TEXT_LENGTH),
            'cardsV2' => [
                [
                    'cardId' => 'apexl-io-logger',
                    'card' => [
                        'header' => [
                            'title' => $this->truncate(
                                sprintf('%s: %s', $levelName, $message),
                                self::MAX_HEADER_LENGTH
                            ),
                            'subtitle' => $this->subtitle,
                        ],
                        'sections' => [
                            [
                                'header' => 'Details',
                                'collapsible' => true,
                                // Level, time and channel stay visible; the context blob
                                // is the part worth collapsing.
                                'uncollapsibleWidgetsCount' => 3,
                                'widgets' => $this->widgets($record, $levelName, $message),
                            ],
                        ],
                    ],
                ],
            ],
        ];

        if ($this->threadKey !== null) {
            $payload['thread'] = ['threadKey' => $this->threadKey];
        }

        return $payload;
    }

    private function widgets(array $record, string $levelName, string $message): array
    {
        $widgets = [
            $this->widget('TICKET', sprintf(
                '<font color="%s">%s</font>',
                self::COLOURS[$levelName] ?? self::COLOURS['INFO'],
                $levelName,
            )),
            $this->widget('CLOCK', $this->datetime($record)),
            $this->widget('BOOKMARK', (string) ($record['channel'] ?? '')),
        ];

        // The header truncates; anything longer belongs in the body in full.
        if (mb_strlen($message) > self::MAX_HEADER_LENGTH) {
            $widgets[] = $this->widget('DESCRIPTION', $message);
        }

        if (!$this->includeContext) {
            return $widgets;
        }

        foreach (['context', 'extra'] as $field) {
            $rendered = $this->renderField($field, (array) ($record[$field] ?? []));

            if ($rendered !== null) {
                $widgets[] = $this->widget('DESCRIPTION', $rendered);
            }
        }

        return $widgets;
    }

    private function widget(string $icon, string $text): array
    {
        return [
            'decoratedText' => [
                'startIcon' => ['knownIcon' => $icon],
                'text' => $text,
                'wrapText' => true,
            ],
        ];
    }

    /**
     * Throwables are the usual reason a record reaches a chat space, and json_encode
     * renders one as `{}` — every useful field on it is private. They are flattened to
     * a string first. The trace is left out: it does not survive the message limit, and
     * the file and line are enough to find it in the file log.
     */
    private function renderField(string $field, array $values): ?string
    {
        foreach ($this->excludeFields as $path) {
            if (!str_starts_with($path, sprintf('%s.', $field))) {
                continue;
            }

            unset($values[substr($path, strlen($field) + 1)]);
        }

        $values = array_map(
            fn(mixed $value): mixed => $value instanceof Throwable
                ? sprintf(
                    '%s: %s at %s:%d',
                    $value::class,
                    $value->getMessage(),
                    $value->getFile(),
                    $value->getLine(),
                )
                : $value,
            $values,
        );

        if ($values === []) {
            return null;
        }

        return sprintf(
            '<b>%s</b><br>%s',
            $field,
            $this->truncate(
                (string) json_encode($values, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
                self::MAX_TEXT_LENGTH,
            ),
        );
    }

    private function datetime(array $record): string
    {
        $datetime = $record['datetime'] ?? null;

        return $datetime instanceof DateTimeInterface
            ? $datetime->format('Y-m-d H:i:s T')
            : (string) $datetime;
    }

    private function truncate(string $value, int $limit): string
    {
        return mb_strlen($value) > $limit
            ? sprintf('%s…', mb_substr($value, 0, $limit - 1))
            : $value;
    }

    /**
     * Chat answers a revoked or mistyped webhook with a 4xx rather than a transport
     * error, so the status is checked explicitly: a notification channel that has
     * silently stopped working is worse than one that was never configured.
     */
    private function post(string $body): void
    {
        $curl = curl_init();

        curl_setopt_array($curl, [
            CURLOPT_URL => $this->url(),
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=UTF-8'],
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT_SECONDS,
            CURLOPT_TIMEOUT => self::TIMEOUT_SECONDS,
        ]);

        $response = curl_exec($curl);
        $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        $error = curl_error($curl);

        curl_close($curl);

        if ($error !== '') {
            error_log(sprintf('Google Chat log handler: %s', $error));

            return;
        }

        if ($status < 200 || $status >= 300) {
            error_log(sprintf(
                'Google Chat log handler: webhook returned %d — %s',
                $status,
                $this->truncate(is_string($response) ? $response : '', 500),
            ));
        }
    }

    /**
     * A thread key groups a stream's records into one thread rather than scattering
     * them through the space, which is the closest Chat gets to Slack's per-channel
     * behaviour. The fallback keeps posting if the thread has since been deleted.
     */
    private function url(): string
    {
        if ($this->threadKey === null) {
            return $this->webhookUrl;
        }

        return sprintf(
            '%s%smessageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD',
            $this->webhookUrl,
            str_contains($this->webhookUrl, '?') ? '&' : '?',
        );
    }
}
