<?php

namespace ZipkinOpenTracing;

use Zipkin\Timestamp;
use OpenTracing\Formats;
use OpenTracing\Reference;
use Zipkin\Propagation\Map;
use OpenTracing\StartSpanOptions;
use Zipkin\Tracer as ZipkinTracer;
use OpenTracing\Tracer as OTTracer;
use Zipkin\Propagation\TraceContext;
use Zipkin\Tracing as ZipkinTracing;
use Zipkin\Propagation\SamplingFlags;
use Zipkin\Propagation\RequestHeaders;
use OpenTracing\Exceptions\UnsupportedFormat;
use OpenTracing\SpanContext as OTSpanContext;
use ZipkinOpenTracing\Span as ZipkinOpenTracingSpan;
use ZipkinOpenTracing\NoopSpan as ZipkinOpenTracingNoopSpan;
use ZipkinOpenTracing\SpanContext as ZipkinOpenTracingContext;
use ZipkinOpenTracing\PartialSpanContext as ZipkinOpenPartialTracingContext;

final class Tracer implements OTTracer
{
    /**
     * @var ZipkinTracer
     */
    private $tracer;

    /**
     * @var callable[]|array
     */
    private $injectors;

    /**
     * @var callable[]|array
     */
    private $extractors;

    public function __construct(ZipkinTracing $tracing)
    {
        $propagation = $tracing->getPropagation();
        $this->injectors = [
            Formats\TEXT_MAP => $propagation->getInjector(new Map()),
            Formats\HTTP_HEADERS => $propagation->getInjector(new RequestHeaders())
        ];
        $this->extractors = [
            Formats\TEXT_MAP => $propagation->getExtractor(new Map()),
            Formats\HTTP_HEADERS => $propagation->getExtractor(new RequestHeaders())
        ];

        $this->tracer = $tracing->getTracer();
        $this->scopeManager = new ScopeManager();
    }

    /**
     * @inheritdoc
     */
    public function getScopeManager()
    {
        return $this->scopeManager;
    }

    /**
     * @inheritdoc
     */
    public function getActiveSpan()
    {
        $activeScope = $this->scopeManager->getActive();
        if ($activeScope === null) {
            return null;
        }

        return $activeScope->getSpan();
    }

    /**
     * @inheritdoc
     */
    public function startActiveSpan($operationName, $options = [])
    {
        if (!$options instanceof StartSpanOptions) {
            $options = StartSpanOptions::create($options);
        }

        if (!$this->hasParentInOptions($options) && $this->getActiveSpan() !== null) {
            $parent = $this->getActiveSpan()->getContext();
            $options = $options->withParent($parent);
        }

        $span = $this->startSpan($operationName, $options);
        $scope = $this->scopeManager->activate($span, $options->shouldFinishSpanOnClose());

        return $scope;
    }

    /**
     * @inheritdoc
     * @return OTSpan|Span
     */
    public function startSpan($operationName, $options = [])
    {
        if (!($options instanceof StartSpanOptions)) {
            $options = StartSpanOptions::create($options);
        }

        if (!$this->hasParentInOptions($options) && $this->getActiveSpan() !== null) {
            $parent = $this->getActiveSpan()->getContext();
            $options = $options->withParent($parent);
        }

        if (empty($options->getReferences())) {
            $span = $this->tracer->newTrace();
        } else {
            /**
             * @var ZipkinOpenTracingContext $refContext
             */
            $refContext = $options->getReferences()[0]->getContext();
            $context = $refContext->getContext();

            if ($context instanceof TraceContext) {
                $span = $this->tracer->newChild($context);
            } else {
                $span = $this->tracer->nextSpan($context);
            }
        }

        if ($span->isNoop()) {
            return ZipkinOpenTracingNoopSpan::create($span);
        }

        $span->start($options->getStartTime() ?: Timestamp\now());
        $span->setName($operationName);

        $otSpan = ZipkinOpenTracingSpan::create($operationName, $span, null);

        foreach ($options->getTags() as $key => $value) {
            $otSpan->setTag($key, $value);
        }

        return $otSpan;
    }

    /**
     * @inheritdoc
     * @throws \InvalidArgumentException
     * @throws \UnexpectedValueException
     */
    public function inject(OTSpanContext $spanContext, $format, &$carrier)
    {
        if ($spanContext instanceof ZipkinOpenTracingContext) {
            $injector = $this->getInjector($format);
            return $injector($spanContext->getContext(), $carrier);
        }

        throw new \InvalidArgumentException(\sprintf(
            'Invalid span context. Expected ZipkinOpenTracing\SpanContext, got %s.',
            \is_object($spanContext) ? \get_class($spanContext) : \gettype($spanContext)
        ));
    }

    /**
     * @inheritdoc
     * @throws \UnexpectedValueException
     */
    public function extract($format, $carrier)
    {
        $extractor = $this->getExtractor($format);
        $extractedContext = $extractor($carrier);

        if ($extractedContext instanceof TraceContext) {
            return ZipkinOpenTracingContext::fromTraceContext($extractedContext);
        }

        if ($extractedContext instanceof SamplingFlags) {
            return ZipkinOpenPartialTracingContext::fromSamplingFlags($extractedContext);
        }

        throw new \UnexpectedValueException(\sprintf(
            'Invalid extracted context. Expected Zipkin\SamplingFlags, got %s',
            \is_object($extractedContext) ? \get_class($extractedContext) : \gettype($extractedContext)
        ));
    }

    /**
     * @inheritdoc
     */
    public function flush()
    {
        $this->tracer->flush();
    }

    /**
     * @param string $format
     * @return callable
     * @throws UnsupportedFormat
     */
    private function getInjector($format)
    {
        if (array_key_exists($format, $this->injectors)) {
            return $this->injectors[$format];
        }

        throw new UnsupportedFormat(\sprintf('Format %s not implemented', $format));
    }

    /**
     * @param string $format
     * @return callable
     * @throws UnsupportedFormat
     */
    private function getExtractor($format)
    {
        if (array_key_exists($format, $this->extractors)) {
            return $this->extractors[$format];
        }

        throw new UnsupportedFormat($format);
    }

    private function hasParentInOptions(StartSpanOptions $options)
    {
        $references = $options->getReferences();
        foreach ($references as $ref) {
            if ($ref->isType(Reference::CHILD_OF)) {
                return $ref->getContext();
            }
        }

        return null;
    }
}