<?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;

use Closure;
use Elastic\Apm\ExecutionSegmentInterface;
use Elastic\Apm\Impl\BackendComm\SerializationUtil;
use Elastic\Apm\Impl\BreakdownMetrics\PerTransaction as BreakdownMetricsPerTransaction;
use Elastic\Apm\Impl\Config\OptionNames;
use Elastic\Apm\Impl\Config\Snapshot as ConfigSnapshot;
use Elastic\Apm\Impl\Log\LogCategory;
use Elastic\Apm\Impl\Log\Logger;
use Elastic\Apm\Impl\Log\LogStreamInterface;
use Elastic\Apm\Impl\Util\IdGenerator;
use Elastic\Apm\Impl\Util\ObserverSet;
use Elastic\Apm\Impl\Util\RandomUtil;
use Elastic\Apm\Impl\Util\StackTraceUtil;
use Elastic\Apm\SpanInterface;
use Elastic\Apm\TransactionContextInterface;
use Elastic\Apm\TransactionInterface;
use Throwable;

/**
 * Code in this file is part of implementation internals and thus it is not covered by the backward compatibility.
 *
 * @internal
 */
final class Transaction extends ExecutionSegment implements TransactionInterface
{
    /** @var ?string */
    private $parentId = null;

    /** @var int */
    public $startedSpansCount = 0;

    /** @var int */
    private $droppedSpansCount = 0;

    /** @var ?string */
    private $result = null;

    /** @var bool */
    private $isSampled;

    /** @var ?TransactionContext */
    private $context = null;

    /** @var Tracer */
    private $tracer;

    /** @var ConfigSnapshot */
    private $config;

    /** @var ?Span */
    private $currentSpan = null;

    /** @var Logger */
    private $logger;

    /** @var int */
    public $numberOfErrorsSent = 0;

    /** @var ?BreakdownMetricsPerTransaction */
    private $breakdownMetricsPerTransaction = null;

    /** @var ?string */
    private $outgoingTraceState;

    /** @var ObserverSet<Transaction> */
    public $onAboutToEnd;

    /** @var ObserverSet<?Span> */
    public $onCurrentSpanChanged;

    /** @var ?bool */
    private $cachedIsSpanCompressionEnabled = null;

    /** @var ?int */
    private $cachedStackTraceLimitConfig = null;

    /** @var ?float */
    private $cachedSpanStackTraceMinDurationConfig = null;

    public function __construct(TransactionBuilder $builder)
    {
        $this->tracer = $builder->tracer;
        $this->config = $builder->tracer->getConfig();
        if ($this->config->breakdownMetrics()) {
            $this->breakdownMetricsPerTransaction = new BreakdownMetricsPerTransaction($this);
        }

        $distributedTracingData = self::extractDistributedTracingData($builder);
        if ($distributedTracingData === null) {
            $traceId = IdGenerator::generateId(Constants::TRACE_ID_SIZE_IN_BYTES);
            $sampleRate = $this->tracer->getConfig()->transactionSampleRate();
            $isSampled = self::makeSamplingDecision($sampleRate);
            /**
             * @link https://github.com/elastic/apm/blob/main/specs/agents/tracing-sampling.md#non-sampled-transactions
             * For non-sampled transactions set the transaction attributes sampled: false and sample_rate: 0
             */
            $sampleRateToMarkTransaction = $isSampled ? $sampleRate : 0.0;
            $this->outgoingTraceState
                = $this->tracer->httpDistributedTracing()->buildOutgoingTraceStateForRootTransaction($sampleRate);
        } else {
            $traceId = $distributedTracingData->traceId;
            $this->parentId = $distributedTracingData->parentId;
            $isSampled = $distributedTracingData->isSampled;
            $sampleRateToMarkTransaction = $distributedTracingData->sampleRate;
            $this->outgoingTraceState = $distributedTracingData->outgoingTraceState;
        }

        parent::__construct(
            $builder->tracer,
            null /* <- parentExecutionSegment */,
            $traceId,
            $builder->name,
            $builder->type,
            $sampleRateToMarkTransaction,
            $builder->timestamp
        );

        $this->logger = $this->tracer->loggerFactory()
                                     ->loggerForClass(LogCategory::PUBLIC_API, __NAMESPACE__, __CLASS__, __FILE__)
                                     ->addContext('this', $this);

        $this->isSampled = $isSampled;

        $this->onCurrentSpanChanged = new ObserverSet();
        $this->onAboutToEnd = new ObserverSet();

        ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
        && $loggerProxy->log('Transaction created');
    }

    private static function extractDistributedTracingData(TransactionBuilder $builder): ?DistributedTracingDataInternal
    {
        /** @var string[] $traceParentHeaderValues */
        $traceParentHeaderValues = [];
        /** @var string[] $traceStateHeaderValues */
        $traceStateHeaderValues = [];
        self::extractDistributedTracingHeaders(
            $builder,
            $traceParentHeaderValues /* <- ref */,
            $traceStateHeaderValues /* <- ref */
        );
        return $builder->tracer->httpDistributedTracing()->parseHeaders(
            $traceParentHeaderValues,
            $traceStateHeaderValues
        );
    }


    /**
     * @param TransactionBuilder $builder
     * @param string[]           $traceParentHeaderValues
     * @param string[]           $traceStateHeaderValues
     */
    private static function extractDistributedTracingHeaders(
        TransactionBuilder $builder,
        array &$traceParentHeaderValues,
        array &$traceStateHeaderValues
    ): void {
        if ($builder->serializedDistTracingData !== null) {
            $traceParentHeaderValues[] = $builder->serializedDistTracingData;
            return;
        }

        $headersExtractor = $builder->headersExtractor;
        if ($headersExtractor === null) {
            return;
        }

        /**
         * @param null|string|string[] $headersExtractorRetVal
         *
         * @return string[]
         */
        $adaptHeadersExtractorRetVal = function ($headersExtractorRetVal): array {
            return $headersExtractorRetVal === null
                ? []
                : (is_string($headersExtractorRetVal) ? [$headersExtractorRetVal] : $headersExtractorRetVal);
        };

        $traceParentHeaderValues = $adaptHeadersExtractorRetVal(
            $headersExtractor(HttpDistributedTracing::TRACE_PARENT_HEADER_NAME)
        );
        $traceStateHeaderValues = $adaptHeadersExtractorRetVal(
            $headersExtractor(HttpDistributedTracing::TRACE_STATE_HEADER_NAME)
        );
    }

    /** @inheritDoc */
    public function getParentId(): ?string
    {
        return $this->parentId;
    }

    private static function makeSamplingDecision(float $sampleRate): bool
    {
        if ($sampleRate === 0.0) {
            return false;
        }
        if ($sampleRate === 1.0) {
            return true;
        }

        return RandomUtil::generate01Float() < $sampleRate;
    }

    public function tracer(): Tracer
    {
        return $this->tracer;
    }

    /** @inheritDoc */
    public function isSampled(): bool
    {
        return $this->isSampled;
    }

    /** @inheritDoc */
    public function containingTransaction(): Transaction
    {
        return $this;
    }

    /** @inheritDoc */
    public function parentExecutionSegment(): ?ExecutionSegment
    {
        return null;
    }

    /** @inheritDoc */
    public function context(): TransactionContextInterface
    {
        if (!$this->isSampled()) {
            return NoopTransactionContext::singletonInstance();
        }

        if ($this->context === null) {
            $this->context = new TransactionContext($this);
        }

        return $this->context;
    }

    public function cloneContextData(): ?TransactionContext
    {
        if ($this->context === null) {
            return null;
        }
        return clone $this->context;
    }

    /** @inheritDoc */
    public function setResult(?string $result): void
    {
        if ($this->beforeMutating()) {
            return;
        }

        $this->result = $this->tracer->limitNullableKeywordString($result);
    }

    /** @inheritDoc */
    public function getResult(): ?string
    {
        return $this->result;
    }

    /** @inheritDoc */
    public function getCurrentSpan(): SpanInterface
    {
        return $this->currentSpan ?? NoopSpan::singletonInstance();
    }

    public function setCurrentSpan(?Span $newCurrentSpan): void
    {
        $this->currentSpan = $newCurrentSpan;
        $this->onCurrentSpanChanged->callCallbacks($this->currentSpan);
    }

    public function getCurrentExecutionSegment(): ExecutionSegmentInterface
    {
        return $this->currentSpan ?? $this;
    }

    public function tryToAllocateStartedSpan(): bool
    {
        if ($this->startedSpansCount < $this->config->transactionMaxSpans()) {
            ++$this->startedSpansCount;
            return true;
        }

        ++$this->droppedSpansCount;
        if ($this->droppedSpansCount === 1) {
            ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
            && $loggerProxy->log(
                'Starting to drop spans because of ' . OptionNames::TRANSACTION_MAX_SPANS . ' config',
                [OptionNames::TRANSACTION_MAX_SPANS . ' config' => $this->config->transactionMaxSpans()]
            );
        }
        return false;
    }

    public function beginSpan(
        ExecutionSegment $parentExecutionSegment,
        string $name,
        string $type,
        ?string $subtype,
        ?string $action,
        ?float $timestamp
    ): ?Span {
        if ($this->beforeMutating() || !$this->tracer->isRecording()) {
            return null;
        }

        $isDropped = false;
        // Started and dropped spans should be counted only for sampled transactions
        if ($this->isSampled) {
            $isDropped = !$this->tryToAllocateStartedSpan();
        }

        return new Span(
            $this->tracer,
            $this /* <- containingTransaction */,
            $parentExecutionSegment,
            $name,
            $type,
            $subtype,
            $action,
            $timestamp,
            $isDropped,
            $this->sampleRate
        );
    }

    /** @inheritDoc */
    public function beginChildSpan(
        string $name,
        string $type,
        ?string $subtype = null,
        ?string $action = null,
        ?float $timestamp = null
    ): SpanInterface {
        return
            $this->beginSpan(
                $this /* <- parentExecutionSegment */,
                $name,
                $type,
                $subtype,
                $action,
                $timestamp
            )
            ?? NoopSpan::singletonInstance();
    }

    /** @inheritDoc */
    public function captureChildSpan(
        string $name,
        string $type,
        Closure $callback,
        ?string $subtype = null,
        ?string $action = null,
        ?float $timestamp = null
    ) {
        /** @noinspection PhpUnhandledExceptionInspection */
        return $this->captureChildSpanImpl(
            $name,
            $type,
            $callback,
            $subtype,
            $action,
            $timestamp,
            1 /* numberOfStackFramesToSkip */
        );
    }

    /** @inheritDoc */
    public function beginCurrentSpan(
        string $name,
        string $type,
        ?string $subtype = null,
        ?string $action = null,
        ?float $timestamp = null
    ): SpanInterface {
        $newCurrentSpan = $this->beginSpan(
            $this->currentSpan ?? $this /* <- parentExecutionSegment */,
            $name,
            $type,
            $subtype,
            $action,
            $timestamp
        );
        $this->setCurrentSpan($newCurrentSpan);
        return $this->getCurrentSpan();
    }

    /** @inheritDoc */
    public function captureCurrentSpan(
        string $name,
        string $type,
        Closure $callback,
        ?string $subtype = null,
        ?string $action = null,
        ?float $timestamp = null
    ) {
        $newSpan = $this->beginCurrentSpan($name, $type, $subtype, $action, $timestamp);
        try {
            return $callback($newSpan);
        } catch (Throwable $throwable) {
            $newSpan->createErrorFromThrowable($throwable);
            throw $throwable;
        } finally {
            // Since endSpanEx was not called directly it should not be kept in the stack trace
            $newSpan->endSpanEx(/* numberOfStackFramesToSkip: */ 1);
        }
    }

    /** @inheritDoc */
    public function ensureParentId(): string
    {
        if ($this->parentId === null) {
            $this->parentId = IdGenerator::generateId(Constants::EXECUTION_SEGMENT_ID_SIZE_IN_BYTES);
            ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
            && $loggerProxy->log(
                'Setting parent ID for already existing transaction',
                ['parentId' => $this->parentId]
            );
        }

        return $this->parentId;
    }

    /** @inheritDoc */
    public function dispatchCreateError(ErrorExceptionData $errorExceptionData): ?string
    {
        if ($this->currentSpan === null) {
            return $this->tracer->doCreateError($errorExceptionData, /* transaction: */ $this, /* span */ null);
        }

        return $this->currentSpan->dispatchCreateError($errorExceptionData);
    }

    /** @inheritDoc */
    public function getDistributedTracingDataInternal(): ?DistributedTracingDataInternal
    {
        if ($this->currentSpan === null) {
            return $this->doGetDistributedTracingData(/* span */ null);
        }

        return $this->currentSpan->getDistributedTracingDataInternal();
    }

    public function doGetDistributedTracingData(?Span $span): ?DistributedTracingDataInternal
    {
        if (!$this->tracer->isRecording()) {
            return null;
        }

        $result = new DistributedTracingDataInternal();
        $result->traceId = $this->traceId;
        if ($span === null) {
            $result->parentId = $this->id;
            $this->wasPropogatedViaDistributedTracing = true;
        } else {
            $result->parentId = $span->getId();
            $span->wasPropogatedViaDistributedTracing = true;
        }
        $result->isSampled = $this->isSampled;
        $result->outgoingTraceState = $this->outgoingTraceState;
        return $result;
    }

    public function getConfig(): ConfigSnapshot
    {
        return $this->config;
    }

    /** @inheritDoc */
    public function discard(): void
    {
        while ($this->currentSpan !== null) {
            $spanToDiscard = $this->currentSpan;
            $this->setCurrentSpan($this->currentSpan->parentIfSpan());
            if (!$spanToDiscard->hasEnded()) {
                $spanToDiscard->discard();
            }
        }

        parent::discard();
    }

    /** @inheritDoc */
    public function end(?float $duration = null): void
    {
        if (!$this->endExecutionSegment($duration)) {
            return;
        }

        $this->onAboutToEnd->callCallbacks($this);

        $this->prepareForSerialization();

        $this->tracer->sendTransactionToApmServer($this->breakdownMetricsPerTransaction, $this);

        if ($this->tracer->getCurrentTransaction() === $this) {
            $this->tracer->resetCurrentTransaction();
        }
    }

    public function addSpanSelfTime(string $spanType, ?string $spanSubtype, float $spanSelfTimeInMicroseconds): void
    {
        if ($this->beforeMutating() || !$this->tracer->isRecording()) {
            return;
        }

        /**
         * if addSpanSelfTime is called that means $this->breakdownMetricsPerTransaction is not null

         * Local variable to workaround PHPStan not having a way to declare that
         * $this->breakdownMetricsPerTransaction is not null
         *
         * @var BreakdownMetricsPerTransaction $breakdownMetricsPerTransaction
         */
        $breakdownMetricsPerTransaction = $this->breakdownMetricsPerTransaction;
        $breakdownMetricsPerTransaction->addSpanSelfTime(
            $spanType,
            $spanSubtype,
            $spanSelfTimeInMicroseconds
        );
    }

    /** @inheritDoc */
    protected function updateBreakdownMetricsOnEnd(float $monotonicClockNow): void
    {
        $this->doUpdateBreakdownMetricsOnEnd(
            $monotonicClockNow,
            BreakdownMetricsPerTransaction::TRANSACTION_SPAN_TYPE,
            /* subtype: */ null
        );
    }

    public function isSpanCompressionEnabled(): bool
    {
        if ($this->cachedIsSpanCompressionEnabled === null) {
            $this->cachedIsSpanCompressionEnabled = $this->getConfig()->spanCompressionEnabled();
            ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
            && $loggerProxy->log(
                'Span compression is ' . ($this->cachedIsSpanCompressionEnabled ? 'enabled' : 'DISABLED') . ' via configuration option `' . OptionNames::SPAN_COMPRESSION_ENABLED . '\''
            );
        }
        return $this->cachedIsSpanCompressionEnabled;
    }

    public function getStackTraceLimitConfig(): int
    {
        if ($this->cachedStackTraceLimitConfig === null) {
            $this->cachedStackTraceLimitConfig = $this->getConfig()->stackTraceLimit();

            /**
             * stack_trace_limit
             *      0 - stack trace collection should be disabled
             *      any positive value - the value is the maximum number of frames to collect
             *      any negative value - all frames should be collected
             */
            $msgPrefix = $this->cachedStackTraceLimitConfig === 0
                ? 'Span stack trace collection is DISABLED'
                : ($this->cachedStackTraceLimitConfig < 0
                    ? 'Span stack trace collection will include all the frames'
                    : 'Span stack trace collection will include up to ' . $this->cachedStackTraceLimitConfig . ' frames');
            ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
            && $loggerProxy->log($msgPrefix . ' (set by configuration option `' . OptionNames::STACK_TRACE_LIMIT . '\')');
        }

        return $this->cachedStackTraceLimitConfig;
    }

    private function getSpanStackTraceMinDurationConfig(): float
    {
        if ($this->cachedSpanStackTraceMinDurationConfig === null) {
            $this->cachedSpanStackTraceMinDurationConfig = $this->getConfig()->spanStackTraceMinDuration();

            /**
             * span_stack_trace_min_duration
             *      0 - collect stack traces for spans with any duration
             *      any positive value - it limits stack trace collection to spans with duration equal to or greater than
             *      any negative value - it disable stack trace collection for spans completely
             */
            $msgPrefix = $this->cachedSpanStackTraceMinDurationConfig === 0.0
                ? 'Span stack trace collection is enabled for spans with any duration'
                : ($this->cachedSpanStackTraceMinDurationConfig < 0
                    ? 'Span stack trace collection is DISABLED for spans with any duration'
                    : 'Span stack trace collection is enabled for spans with duration >= ' . $this->cachedSpanStackTraceMinDurationConfig . ' ms');
            ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
            && $loggerProxy->log($msgPrefix . ' (set by configuration option `' . OptionNames::SPAN_STACK_TRACE_MIN_DURATION . '\')');
        }

        return $this->cachedSpanStackTraceMinDurationConfig;
    }

    public function shouldCollectStackTraceForSpanDuration(float $durationInMilliseconds): bool
    {
        /**
         * span_stack_trace_min_duration
         *      0 - collect stack traces for spans with any duration
         *      any positive value - it limits stack trace collection to spans with duration equal to or greater than
         *      any negative value - it disable stack trace collection for spans completely
         */
        return (($stackTraceMinDuration = $this->getSpanStackTraceMinDurationConfig()) >= 0) && $durationInMilliseconds >= $stackTraceMinDuration;
    }

    /**
     * @param int $numberOfStackFramesToSkip
     *
     * @return null|StackTraceFrame[]
     *
     * @phpstan-param 0|positive-int $numberOfStackFramesToSkip
     */
    public function captureApmFormatStackTrace(int $numberOfStackFramesToSkip): ?array
    {
        return ($maxNumberOfFrames = StackTraceUtil::convertLimitConfigToMaxNumberOfFrames($this->getStackTraceLimitConfig())) === 0
            ? null
            : $this->tracer->stackTraceUtil()->captureInApmFormat(/* offset */ $numberOfStackFramesToSkip + 1, $maxNumberOfFrames);
    }

    private function prepareForSerialization(): void
    {
        SerializationUtil::prepareForSerialization(/* ref */ $this->context);
    }

    /** @inheritDoc */
    public function jsonSerialize()
    {
        $result = SerializationUtil::preProcessResult(parent::jsonSerialize());

        SerializationUtil::addNameValueIfNotNull('parent_id', $this->parentId, /* ref */ $result);

        $spanCountSubObject = ['started' => $this->startedSpansCount];
        if ($this->droppedSpansCount != 0) {
            $spanCountSubObject['dropped'] = $this->droppedSpansCount;
        }
        SerializationUtil::addNameValue('span_count', $spanCountSubObject, /* ref */ $result);

        SerializationUtil::addNameValueIfNotNull('result', $this->result, /* ref */ $result);

        // https://github.com/elastic/apm-server/blob/7.0/docs/spec/transactions/transaction.json#L72
        // 'sampled' is optional and defaults to true.
        if (!$this->isSampled) {
            SerializationUtil::addNameValue('sampled', $this->isSampled, /* ref */ $result);
        }

        SerializationUtil::addNameValueIfNotNull('context', $this->context, /* ref */ $result);

        return SerializationUtil::postProcessResult($result);
    }

    /** @inheritDoc */
    protected static function propertiesExcludedFromLog(): array
    {
        return array_merge(parent::propertiesExcludedFromLog(), ['config', 'currentSpan']);
    }

    /** @inheritDoc */
    public function toLog(LogStreamInterface $stream): void
    {
        $currentSpanId = $this->currentSpan === null ? null : $this->currentSpan->getId();
        parent::toLogLoggableTraitImpl(
            $stream,
            /* customPropValues */
            ['currentSpanId' => $currentSpanId]
        );
    }
}
