<?php

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Core\Log;

use Psr\Log\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Core\Core\RequestId;
use TYPO3\CMS\Core\Log\Exception\InvalidLogProcessorConfigurationException;
use TYPO3\CMS\Core\Log\Exception\InvalidLogWriterConfigurationException;
use TYPO3\CMS\Core\Log\Processor\ProcessorInterface;
use TYPO3\CMS\Core\Log\Processor\RequestIdProcessor;
use TYPO3\CMS\Core\Log\Writer\WriterInterface;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
 * Global LogManager that keeps track of global logging information.
 *
 * Inspired by java.util.logging
 */
class LogManager implements SingletonInterface, LogManagerInterface
{
    /**
     * @var string
     */
    public const CONFIGURATION_TYPE_WRITER = 'writer';

    /**
     * @var string
     */
    public const CONFIGURATION_TYPE_PROCESSOR = 'processor';

    /**
     * Loggers to retrieve them for repeated use.
     */
    protected array $loggers = [];

    /**
     * Default / global / root logger.
     */
    protected Logger $rootLogger;

    /**
     * Unique ID of the request
     */
    protected RequestId $requestId;

    public function __construct(RequestId $requestId = new RequestId())
    {
        $this->requestId = $requestId;
        $this->rootLogger = new Logger('');
        $this->loggers[''] = $this->rootLogger;
    }

    /**
     * For use in unit test context only. Resets the internal logger registry.
     */
    public function reset()
    {
        $this->loggers = [];
    }

    /**
     * Gets a logger instance for the given name.
     *
     * \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Log\LogManager::class)->getLogger('main.sub.subsub');
     *
     * $name can also be submitted as a underscore-separated string, which will
     * be converted to dots. This is useful to call this method with __CLASS__
     * as parameter.
     *
     * @param string $name Logger name, empty to get the global "root" logger.
     * @return Logger Logger with name $name
     */
    public function getLogger(string $name = ''): LoggerInterface
    {
        // Transform namespaces and underscore class names to the dot-name style
        $separators = ['_', '\\'];
        $name = str_replace($separators, '.', $name);

        return $this->loggers[$name] ??= $this->makeLogger($name);
    }

    /**
     * Instantiates a new logger object, with the appropriate attached writers and processors.
     */
    protected function makeLogger(string $name): Logger
    {
        $logger = new Logger($name);
        $this->setWritersForLogger($logger);
        $this->addRequestIdProcessorToLogger($logger);
        $this->setProcessorsForLogger($logger);
        return $logger;
    }

    /**
     * Attaches the request id processor for all severity levels covered by the
     * configured writers of the given logger. The processor is registered before
     * any configured processor and on purpose not via $TYPO3_CONF_VARS['LOG'],
     * so log records are guaranteed to carry the request id, even if the global
     * processor configuration is overridden. Registering it for exactly the
     * most verbose writer level avoids raising the minimum log level, which
     * would create and process log records no writer ever receives.
     */
    protected function addRequestIdProcessorToLogger(Logger $logger): void
    {
        $writerLevels = array_map(LogLevel::normalizeLevel(...), array_keys($logger->getWriters()));
        $minimumLevel = $writerLevels === [] ? LogLevel::normalizeLevel(\Psr\Log\LogLevel::EMERGENCY) : max($writerLevels);
        $logger->addProcessor(LogLevel::getInternalName($minimumLevel), new RequestIdProcessor($this->requestId));
    }

    /**
     * For use in unit test context only.
     *
     * @param string $name
     */
    public function registerLogger($name)
    {
        $this->loggers[$name] = null;
    }

    /**
     * For use in unit test context only.
     *
     * @return array
     */
    public function getLoggerNames()
    {
        return array_keys($this->loggers);
    }

    /**
     * Appends the writers to the given logger as configured.
     *
     * @param \TYPO3\CMS\Core\Log\Logger $logger Logger to configure
     */
    protected function setWritersForLogger(Logger $logger)
    {
        $configuration = $this->getConfigurationForLogger(self::CONFIGURATION_TYPE_WRITER, $logger->getName());
        foreach ($configuration as $severityLevel => $writer) {
            $writer = array_filter($writer, static fn(array $options): bool => !($options['disabled'] ?? false));
            foreach ($writer as $logWriterClassName => $logWriterOptions) {
                try {
                    unset($logWriterOptions['disabled']);
                    /** @var WriterInterface $logWriter */
                    $logWriter = GeneralUtility::makeInstance($logWriterClassName, $logWriterOptions);
                    $logger->addWriter($severityLevel, $logWriter);
                } catch (InvalidArgumentException|InvalidLogWriterConfigurationException $e) {
                    $logger->warning('Instantiation of LogWriter "{class_name}" failed for logger {name}', [
                        'class_name' => $logWriterClassName,
                        'name' => $logger->getName(),
                        'exception' => $e,
                    ]);
                }
            }
        }
    }

    /**
     * Appends the processors to the given logger as configured.
     *
     * @param \TYPO3\CMS\Core\Log\Logger $logger Logger to configure
     */
    protected function setProcessorsForLogger(Logger $logger)
    {
        $configuration = $this->getConfigurationForLogger(self::CONFIGURATION_TYPE_PROCESSOR, $logger->getName());
        foreach ($configuration as $severityLevel => $processor) {
            foreach ($processor as $logProcessorClassName => $logProcessorOptions) {
                try {
                    /** @var ProcessorInterface $logProcessor */
                    $logProcessor = GeneralUtility::makeInstance($logProcessorClassName, $logProcessorOptions);
                    $logger->addProcessor($severityLevel, $logProcessor);
                } catch (InvalidArgumentException|InvalidLogProcessorConfigurationException $e) {
                    $logger->warning('Instantiation of LogProcessor "{class_name}" failed for logger {name}', [
                        'class_name' => $logProcessorClassName,
                        'name' => $logger->getName(),
                        'exception' => $e,
                    ]);
                }
            }
        }
    }

    /**
     * Returns the configuration from $TYPO3_CONF_VARS['LOG'] as
     * hierarchical array for different components of the class hierarchy.
     *
     * @param string $configurationType Type of config to return (writer, processor)
     * @param string $loggerName Logger name
     * @throws \Psr\Log\InvalidArgumentException
     * @return array
     */
    protected function getConfigurationForLogger($configurationType, $loggerName)
    {
        // Split up the logger name (dot-separated) into its parts
        $explodedName = explode('.', $loggerName);
        // Search in the $TYPO3_CONF_VARS['LOG'] array
        // for these keys, for example "writerConfiguration"
        $configurationKey = $configurationType . 'Configuration';
        $configuration = $GLOBALS['TYPO3_CONF_VARS']['LOG'] ?? [];
        $result = $configuration[$configurationKey] ?? [];
        // Walk from general to special (t3lib, t3lib.db, t3lib.db.foo)
        // and search for the most specific configuration
        foreach ($explodedName as $partOfClassName) {
            if (!isset($configuration[$partOfClassName])) {
                break;
            }
            if (!empty($configuration[$partOfClassName][$configurationKey])) {
                $result = $configuration[$partOfClassName][$configurationKey];
            }
            $configuration = $configuration[$partOfClassName];
        }
        // Validate the config
        foreach ($result as $level => $unused) {
            try {
                LogLevel::validateLevel(LogLevel::normalizeLevel($level));
            } catch (InvalidArgumentException $e) {
                throw new InvalidArgumentException('The given severity level "' . htmlspecialchars($level) . '" for ' . $configurationKey . ' of logger "' . $loggerName . '" is not valid.', 1326406447);
            }
        }
        return $result;
    }
}
