<?php

/*
 * Licensed to Elasticsearch B.V. under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch B.V. licenses this file to you under
 * the Apache License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

declare(strict_types=1);

namespace Elastic\Apm\Impl\Log;

use Elastic\Apm\Impl\Util\ArrayUtil;
use Elastic\Apm\Impl\Util\DbgUtil;
use Elastic\Apm\Impl\Util\StaticClassTrait;
use Elastic\Apm\Impl\Util\TextUtil;
use ReflectionClass;
use ReflectionException;
use Throwable;

/**
 * Code in this file is part of implementation internals and thus it is not covered by the backward compatibility.
 *
 * @internal
 */
final class LoggableToJsonEncodable
{
    use StaticClassTrait;

    public const MAX_DEPTH_IN_PROD_MODE = 10;

    /** @var int */
    public static $maxDepth = self::MAX_DEPTH_IN_PROD_MODE;

    private const IS_DTO_OBJECT_CACHE_MAX_COUNT_LOW_WATER_MARK = 10000;
    private const IS_DTO_OBJECT_CACHE_MAX_COUNT_HIGH_WATER_MARK = 2 * self::IS_DTO_OBJECT_CACHE_MAX_COUNT_LOW_WATER_MARK;

    /** @var array<string, bool> */
    private static $isDtoObjectCache = [];

    /**
     * @param array<string, mixed> $value
     * @param int                  $depth
     *
     * @return array<string, mixed>
     */
    public static function convertArrayForMaxDepth(array $value, int $depth): array
    {
        return [LogConsts::MAX_DEPTH_REACHED => $depth, LogConsts::TYPE_KEY => DbgUtil::getType($value), LogConsts::ARRAY_COUNT_KEY => count($value)];
    }

    /**
     * @param object $value
     * @param int    $depth
     *
     * @return array<string, mixed>
     */
    public static function convertObjectForMaxDepth(object $value, int $depth): array
    {
        return [LogConsts::MAX_DEPTH_REACHED => $depth, LogConsts::TYPE_KEY => DbgUtil::getType($value)];
    }

    /**
     * @param mixed $value
     *
     * @return mixed
     */
    public static function convert($value, int $depth)
    {
        if ($value === null) {
            return null;
        }

        // Scalar variables are those containing an int, float, string or bool.
        // Types array, object and resource are not scalar.
        if (is_scalar($value)) {
            return $value;
        }

        if (is_array($value)) {
            if ($depth >= self::$maxDepth) {
                return self::convertArrayForMaxDepth($value, $depth);
            }
            return self::convertArray($value, $depth + 1);
        }

        if (is_resource($value)) {
            return self::convertOpenResource($value);
        }

        if (is_object($value)) {
            if ($depth >= self::$maxDepth) {
                return self::convertObjectForMaxDepth($value, $depth);
            }
            return self::convertObject($value, $depth);
        }

        /** @phpstan-ignore-next-line */
        return [LogConsts::TYPE_KEY => DbgUtil::getType($value), LogConsts::VALUE_AS_STRING_KEY => strval($value)];
    }

    /**
     * @param array<mixed> $array
     *
     * @return array<mixed>
     */
    private static function convertArray(array $array, int $depth): array
    {
        return self::convertArrayImpl($array, ArrayUtil::isList($array), $depth);
    }

    /**
     * @param array<mixed> $array
     * @param bool                $isListArray
     * @param int                 $depth
     *
     * @return array<mixed>
     */
    private static function convertArrayImpl(array $array, bool $isListArray, int $depth): array
    {
        $arrayCount = count($array);
        $smallArrayMaxCount = $isListArray
            ? LogConsts::SMALL_LIST_ARRAY_MAX_COUNT
            : LogConsts::SMALL_MAP_ARRAY_MAX_COUNT;
        if ($arrayCount <= $smallArrayMaxCount) {
            return self::convertSmallArray($array, $isListArray, $depth);
        }

        $result = [LogConsts::TYPE_KEY => LogConsts::LIST_ARRAY_TYPE_VALUE];
        $result[LogConsts::ARRAY_COUNT_KEY] = $arrayCount;

        $halfOfSmallArrayMaxCount = intdiv($smallArrayMaxCount, 2);
        $firstElements = array_slice($array, 0, $halfOfSmallArrayMaxCount);
        $result['0-' . intdiv($smallArrayMaxCount, 2)]
            = self::convertSmallArray($firstElements, $isListArray, $depth);

        $result[($arrayCount - $halfOfSmallArrayMaxCount) . '-' . $arrayCount]
            = self::convertSmallArray(array_slice($array, -$halfOfSmallArrayMaxCount), $isListArray, $depth);

        return $result;
    }

    /**
     * @param array<mixed> $array
     * @param bool                $isListArray
     * @param int                 $depth
     *
     * @return array<mixed>
     */
    private static function convertSmallArray(array $array, bool $isListArray, int $depth): array
    {
        return $isListArray ? self::convertSmallListArray($array, $depth) : self::convertSmallMapArray($array, $depth);
    }

    /**
     * @param array<mixed> $listArray
     *
     * @return array<mixed>
     */
    private static function convertSmallListArray(array $listArray, int $depth): array
    {
        $result = [];
        foreach ($listArray as $value) {
            $result[] = self::convert($value, $depth);
        }
        return $result;
    }

    /**
     * @param array<mixed> $mapArrayValue
     *
     * @return array<mixed>
     */
    private static function convertSmallMapArray(array $mapArrayValue, int $depth): array
    {
        return self::isStringKeysMapArray($mapArrayValue)
            ? self::convertSmallStringKeysMapArray($mapArrayValue, $depth)
            : self::convertSmallMixedKeysMapArray($mapArrayValue, $depth);
    }

    /**
     * @param array<mixed> $mapArrayValue
     *
     * @return bool
     */
    private static function isStringKeysMapArray(array $mapArrayValue): bool
    {
        foreach ($mapArrayValue as $key => $_) {
            if (!is_string($key)) {
                return false;
            }
        }
        return true;
    }

    /**
     * @param array<mixed> $mapArrayValue
     *
     * @return array<mixed>
     */
    private static function convertSmallStringKeysMapArray(array $mapArrayValue, int $depth): array
    {
        $result = [];
        foreach ($mapArrayValue as $key => $value) {
            $result[$key] = self::convert($value, $depth);
        }
        return $result;
    }

    /**
     * @param array<mixed> $mapArrayValue
     * @param int                 $depth
     *
     * @return array<mixed>
     */
    private static function convertSmallMixedKeysMapArray(array $mapArrayValue, int $depth): array
    {
        $result = [];
        foreach ($mapArrayValue as $key => $value) {
            $result[] = ['key' => self::convert($key, $depth), 'value' => self::convert($value, $depth)];
        }
        return $result;
    }

    /**
     * @param resource $resource
     *
     * @return array<string, mixed>
     */
    private static function convertOpenResource($resource): array
    {
        return [
            LogConsts::TYPE_KEY          => LogConsts::RESOURCE_TYPE_VALUE,
            LogConsts::RESOURCE_TYPE_KEY => get_resource_type($resource),
            LogConsts::RESOURCE_ID_KEY   => intval($resource),
        ];
    }

    /**
     * @param object $object
     * @param int    $depth
     *
     * @return mixed
     */
    private static function convertObject(object $object, int $depth)
    {
        if ($object instanceof LoggableInterface) {
            return self::convertLoggable($object, $depth);
        }

        if ($object instanceof Throwable) {
            return self::convertThrowable($object, $depth);
        }

        $fqClassName = get_class($object);
        $isFromElasticNamespace = TextUtil::isPrefixOf('Elastic\\Apm\\', $fqClassName)
                                  || TextUtil::isPrefixOf('ElasticApmTests\\', $fqClassName);
        if ($isFromElasticNamespace && self::isDtoObject($object)) {
            return self::convertDtoObject($object, $depth);
        }

        if (method_exists($object, '__debugInfo')) {
            return [
                LogConsts::TYPE_KEY                => get_class($object),
                LogConsts::VALUE_AS_DEBUG_INFO_KEY => self::convert($object->__debugInfo(), $depth),
            ];
        }

        if (method_exists($object, '__toString')) {
            return [
                LogConsts::TYPE_KEY            => get_class($object),
                LogConsts::VALUE_AS_STRING_KEY => self::convert($object->__toString(), $depth),
            ];
        }

        return [
            LogConsts::TYPE_KEY        => get_class($object),
            LogConsts::OBJECT_ID_KEY   => spl_object_id($object),
            LogConsts::OBJECT_HASH_KEY => spl_object_hash($object),
        ];
    }

    /**
     * @param LoggableInterface $loggable
     * @param int               $depth
     *
     * @return mixed
     */
    private static function convertLoggable(LoggableInterface $loggable, int $depth)
    {
        $logStream = new LogStream();
        $loggable->toLog($logStream);
        return self::convert($logStream->value, $depth);
    }

    /**
     * @param Throwable $throwable
     * @param int       $depth
     *
     * @return array<string, mixed>
     */
    private static function convertThrowable(Throwable $throwable, int $depth): array
    {
        return [
            LogConsts::TYPE_KEY            => get_class($throwable),
            LogConsts::VALUE_AS_STRING_KEY => self::convert($throwable->__toString(), $depth),
        ];
    }

    /**
     * @param object $object
     * @param int    $depth
     *
     * @return string|array<string, mixed>
     * @phpstan-return array<string, mixed>
     */
    private static function convertDtoObject(object $object, int $depth)
    {
        $class = get_class($object);
        try {
            $currentClass = new ReflectionClass($class);
            /** @phpstan-ignore-next-line */
        } catch (ReflectionException $ex) {
            return LoggingSubsystem::onInternalFailure('Failed to reflect', ['class' => $class], $ex);
        }

        $nameToValue = [];
        while (true) {
            foreach ($currentClass->getProperties() as $reflectionProperty) {
                if ($reflectionProperty->isStatic()) {
                    continue;
                }

                $propName = $reflectionProperty->name;
                $propValue = $reflectionProperty->getValue($object);
                $nameToValue[$propName] = self::convert($propValue, $depth);
            }
            $currentClass = $currentClass->getParentClass();
            if ($currentClass === false) {
                break;
            }
        }
        return $nameToValue;
    }

    private static function isDtoObject(object $object): bool
    {
        $class = get_class($object);
        $valueInCache = ArrayUtil::getValueIfKeyExistsElse($class, self::$isDtoObjectCache, null);
        if ($valueInCache !== null) {
            return $valueInCache;
        }

        $value = self::detectIfDtoObject($class);

        self::addToIsDtoObjectCache($class, $value);

        return $value;
    }

    /**
     * @template T of object
     *
     * @param class-string<T> $className
     *
     * @return bool
     */
    private static function detectIfDtoObject(string $className): bool
    {
        try {
            $currentClass = new ReflectionClass($className);
            /** @phpstan-ignore-next-line */
        } catch (ReflectionException $ex) {
            LoggingSubsystem::onInternalFailure('Failed to reflect', ['className' => $className], $ex);
            return false;
        }

        while (true) {
            foreach ($currentClass->getProperties() as $reflectionProperty) {
                if ($reflectionProperty->isStatic()) {
                    continue;
                }

                if (!$reflectionProperty->isPublic()) {
                    return false;
                }
            }
            $currentClass = $currentClass->getParentClass();
            if ($currentClass === false) {
                break;
            }
        }

        return true;
    }

    private static function addToIsDtoObjectCache(string $class, bool $value): void
    {
        $isDtoObjectCacheCount = count(self::$isDtoObjectCache);
        if ($isDtoObjectCacheCount >= self::IS_DTO_OBJECT_CACHE_MAX_COUNT_HIGH_WATER_MARK) {
            self::$isDtoObjectCache = array_slice(
                self::$isDtoObjectCache,
                $isDtoObjectCacheCount - self::IS_DTO_OBJECT_CACHE_MAX_COUNT_LOW_WATER_MARK
            );
        }

        self::$isDtoObjectCache[$class] = $value;
    }
}
