<?php

declare(strict_types=1);

use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Psr\Log\InvalidArgumentException;

/**
 * PSR-3 Logger implementation.
 *
 * Usage:
 *
 * ```php
 * use Psr\Log\LoggerInterface;
 *
 * class MyClass {
 *   private readonly LoggerInterface $log;
 *
 *   public function __construct() {
 *     $this->log = new Awardit_Magento_Logger("my_channel_name");
 *   }
 *
 *   public function doSomething(): void {
 *     $this->log->debug("We are doing something");
 *     // Exceptions can be logged directly to the log-methods and will result
 *     // in a structured dump including stack-trace:
 *     $this->log->warn(new Exception("We have handled it"));
 *   }
 * }
 * ```
 */
class Awardit_Magento_Logger implements LoggerInterface {
    const LEVELS = [
        Zend_Log::EMERG => LogLevel::EMERGENCY,
        Zend_Log::ALERT => LogLevel::ALERT,
        Zend_Log::CRIT => LogLevel::CRITICAL,
        Zend_Log::ERR => LogLevel::ERROR,
        Zend_Log::WARN => LogLevel::WARNING,
        Zend_Log::NOTICE => LogLevel::NOTICE,
        Zend_Log::INFO => LogLevel::INFO,
        Zend_Log::DEBUG => LogLevel::DEBUG,
    ];
    const JSON_FLAGS = JSON_INVALID_UTF8_SUBSTITUTE |
        JSON_PRESERVE_ZERO_FRACTION |
        JSON_UNESCAPED_SLASHES |
        JSON_UNESCAPED_UNICODE;

    public function __construct(
        private readonly string $channel
    ) {}

    public function emergency(
        string|Stringable $message,
        array $context = []
    ): void {
        if( ! array_key_exists("caller", $context)) {
            $context["caller"] = self::getCaller();
        }

        $this->log(LogLevel::EMERGENCY, $message, $context);
    }

    public function alert(
        string|Stringable $message,
        array $context = []
    ): void {
        if( ! array_key_exists("caller", $context)) {
            $context["caller"] = self::getCaller();
        }

        $this->log(LogLevel::ALERT, $message, $context);
    }

    public function critical(
        string|Stringable $message,
        array $context = []
    ): void {
        if( ! array_key_exists("caller", $context)) {
            $context["caller"] = self::getCaller();
        }

        $this->log(LogLevel::CRITICAL, $message, $context);
    }

    public function error(
        string|Stringable $message,
        array $context = []
    ): void {
        if( ! array_key_exists("caller", $context)) {
            $context["caller"] = self::getCaller();
        }

        $this->log(LogLevel::ERROR, $message, $context);
    }

    public function warning(
        string|Stringable $message,
        array $context = []
    ): void {
        if( ! array_key_exists("caller", $context)) {
            $context["caller"] = self::getCaller();
        }

        $this->log(LogLevel::WARNING, $message, $context);
    }

    public function notice(
        string|Stringable $message,
        array $context = []
    ): void {
        if( ! array_key_exists("caller", $context)) {
            $context["caller"] = self::getCaller();
        }

        $this->log(LogLevel::NOTICE, $message, $context);
    }

    public function info(
        string|Stringable $message,
        array $context = []
    ): void {
        if( ! array_key_exists("caller", $context)) {
            $context["caller"] = self::getCaller();
        }

        $this->log(LogLevel::DEBUG, $message, $context);
    }

    public function debug(
        string|Stringable $message,
        array $context = []
    ): void {
        if( ! array_key_exists("caller", $context)) {
            $context["caller"] = self::getCaller();
        }

        $this->log(LogLevel::DEBUG, $message, $context);
    }

    public function log(
        $level,
        string|Stringable $message,
        array $context = []
    ): void {
        $levelId = array_search($level, self::LEVELS, true);

        if($levelId === false) {
            throw new InvalidArgumentException();
        }

        if( ! array_key_exists("caller", $context)) {
            $context["caller"] = self::getCaller();
        }

        self::writeLog($this->channel, $levelId, $level, $message, $context);
    }

    /**
     * Formats and writes a JSON log message.
     *
     * If the message is an instance of an exception, it will be logged as
     * structured data including stack trace.
     *
     * @param LogLevel::EMERGENCY|LogLevel::ALERT|LogLevel::CRITICAL|LogLevel::ERROR|LogLevel::WARNING|LogLevel::NOTICE|LogLevel::INFO|LogLevel::DEBUG $level
     * @param Zend_Log::EMERG|Zend_Log::ALERT|Zend_Log::CRIT|Zend_Log::ERR|Zend_Log::WARN|Zend_Log::NOTICE|Zend_Log::INFO|Zend_Log::DEBUG $levelId
     */
    public static function writeLog(
        string $channel,
        int $levelId,
        string $level,
        string|Stringable $message,
        array $context = []
    ): void {
        if($message instanceof Throwable) {
            $context["exception"] = $message;

            $message = $message->getMessage();
        }

        if(array_key_exists("exception", $context) && $context["exception"] instanceof Throwable) {
            $context["exception"] = self::normalizeException($context["exception"]);
        }

        if( ! array_key_exists("storeCode", $context)) {
            $context["storeCode"] = Mage::getCurrentStoreCode();
        }

        if( ! array_key_exists("caller", $context)) {
            $context["caller"] = self::getCaller();
        }

        $record = [
            "level" => $levelId,
            "level_name" => $level,
            "channel" => $channel,
            "message" => (string)$message,
            "context" => $context,
            "extra" => [],
        ];

        if(extension_loaded("ddtrace")) {
            /**
             * @psalm-suppress UndefinedFunction
             * @var array
             */
            $record["dd"] = \DDTrace\current_context();
        }

        error_log(json_encode($record, self::JSON_FLAGS));
    }

    /**
     * Returns information about the callsite of the current method/function,
     * including class, function/method, file, and line.
     */
    public static function getCaller(): array {
        $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
        // 0: Awardit_Magento_Logger::getCaller, line-numbers from 1
        // 1: Caller of getCaller, line-numbers from 2
        // 2: Caller of log-method

        return [
            // These are callsites, the parent call has the method/function called from
            "class" => $trace[2]["class"] ?? null,
            "function" => $trace[2]["function"] ?? null,
            "location" => [
                "file" => $trace[1]["file"] ?? null,
                "line" => $trace[1]["line"] ?? null,
            ],
        ];
    }

    /**
     * Normalizes an exception to a JSON-serializable structure which includes
     * stack-trace and previous exception.
     *
     * @return array{
     *   class:string,
     *   message:string,
     *   code:int,
     *   file:string,
     *   trace:Array<string>,
     *   previous?:array,
     * }
     */
    private static function normalizeException(Throwable $e): array {
        $data = [
            "class" => get_class($e),
            "message" => $e->getMessage(),
            "code" => (int)$e->getCode(),
            "file" => $e->getFile().":".$e->getLine(),
            "trace" => [],
        ];

        /**
         * @var array{line:int, file?:string} $frame
         */
        foreach($e->getTrace() as $frame) {
            if(isset($frame["file"])) {
                $data["trace"][] = $frame["file"].":".$frame["line"];
            }
        }

        if($previous = $e->getPrevious()) {
            $data["previous"] = self::normalizeException($previous);
        }

        return $data;
    }
}
