<?php declare(strict_types=1);

/**
 * @license Apache 2.0
 */

namespace OpenApi;

use OpenApi\Annotations as OA;
use OpenApi\Loggers\DefaultLogger;
use Psr\Log\LoggerInterface;

/**
 * The context in which the annotation is parsed.
 *
 * Contexts are nested to reflect the code/parsing hierarchy. They include useful metadata
 * which the processors can use to augment the annotations.
 *
 * @property string|null                  $comment     The PHP DocComment
 * @property string|null                  $filename
 * @property int|null                     $line
 * @property int|null                     $character
 * @property string|null                  $namespace
 * @property array|null                   $uses
 * @property string|null                  $class
 * @property string|null                  $interface
 * @property string|null                  $trait
 * @property string|null                  $enum
 * @property array|string|null            $extends     Interfaces may extend a list of interfaces
 * @property array|null                   $implements
 * @property string|null                  $method
 * @property string|null                  $property
 * @property \Reflector|null              $reflector   Optional reflection details
 * @property bool|null                    $static      Indicate a static method
 * @property bool|null                    $generated   Indicate the context was generated by a processor,
 *                                                     type resolver or serializer
 * @property OA\AbstractAnnotation|null   $nested
 * @property OA\AbstractAnnotation[]|null $annotations
 * @property OA\AbstractAnnotation[]|null $other       Annotations not related to OpenApi
 * @property LoggerInterface|null         $logger      Guaranteed to be set when using the <code>Generator</code>
 * @property array|null                   $scanned     Details of file scanner when using ReflectionAnalyser
 * @property string|null                  $version     The OpenAPI version in use
 */
#[\AllowDynamicProperties]
class Context implements \Stringable
{
    /**
     * Prototypical inheritance for properties.
     */
    protected ?Context $parent;

    public function __construct(array $properties = [], ?Context $parent = null)
    {
        foreach ($properties as $property => $value) {
            $this->{$property} = $value;
        }
        $this->parent = $parent;

        $this->logger = $this->logger ?: new DefaultLogger();
    }

    /**
     * Ensure this context is part of the context tree.
     */
    public function ensureRoot(?Context $rootContext): void
    {
        if ($rootContext === $this) {
            return;
        }

        if (!$this->parent) {
            // use root fallback for these...
            foreach (['logger', 'version'] as $property) {
                unset($this->{$property});
            }

            $this->parent = $rootContext;
        }
    }

    /**
     * Check if a property is set directly on this context and not its parent context.
     *
     * Example: $c->is('method') or $c->is('class')
     */
    public function is(string $property): bool
    {
        return property_exists($this, $property);
    }

    /**
     * Check if a property is NOT set directly on this context and its parent context.
     *
     * Example: $c->not('method') or $c->not('class')
     */
    public function not(string $property): bool
    {
        return $this->is($property) === false;
    }

    /**
     * Return the context containing the specified property.
     */
    public function with(string $property): ?Context
    {
        if ($this->is($property)) {
            return $this;
        }
        if ($this->parent instanceof Context) {
            return $this->parent->with($property);
        }

        return null;
    }

    /**
     * Get the root context.
     */
    public function root(): Context
    {
        if ($this->parent instanceof Context) {
            return $this->parent->root();
        }

        return $this;
    }

    /**
     * Get the OpenApi version.
     *
     * This is a best guess and only final once parsing is complete.
     */
    public function getVersion(): string
    {
        return $this->root()->version ?: OA\OpenApi::DEFAULT_VERSION;
    }

    /**
     * Check if one of the given version numbers matches the current OpenAPI version.
     *
     * @param string|array $version The version to compare. Allows patch version placeholder `x`; e.g. `3.1.x`.
     */
    public function isVersion(string|array $version): bool
    {
        foreach ((array) $version as $v) {
            if (OA\OpenApi::versionMatch($this->getVersion(), $v)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Export location for debugging.
     *
     * @return string Example: "file1.php on line 12"
     */
    public function getDebugLocation(): string
    {
        $location = '';
        $fqn = $this->fullyQualifiedName($this->class ?? $this->interface ?? $this->trait ?? $this->enum);
        if ($fqn && ($this->method || $this->property)) {
            $location .= $fqn;
            if ($this->method) {
                $location .= ($this->static ? '::' : '->') . $this->method . '()';
            } elseif ($this->property) {
                $location .= ($this->static ? '::$' : '->') . $this->property;
            }
        }
        if ($this->filename) {
            if ($location !== '') {
                $location .= ' in ';
            }
            $location .= $this->filename;
        }
        if ($this->line) {
            if ($location !== '') {
                $location .= ' on';
            }
            $location .= ' line ' . $this->line;
            if ($this->character) {
                $location .= ':' . $this->character;
            }
        }

        return $location;
    }

    public function __serialize(): array
    {
        return array_filter(get_object_vars($this), static function ($value): bool {
            $rc = is_object($value) ? new \ReflectionClass($value) : null;

            return (!$rc || !$rc->isAnonymous())
                && !$value instanceof \Reflector
                && !$value instanceof \Closure;
        });
    }

    public function __unserialize(array $data): void
    {
        foreach ($data as $name => $value) {
            $this->{$name} = $value;
        }
    }

    /**
     * Traverse the context tree to get the property value.
     */
    public function __get(string $property): mixed
    {
        if ($this->parent instanceof Context) {
            return $this->parent->{$property};
        }

        return null;
    }

    public function __toString(): string
    {
        return $this->getDebugLocation();
    }

    public function __debugInfo()
    {
        return ['-' => $this->getDebugLocation()];
    }

    /**
     * Resolve the given `source` to a fully qualified name.
     *
     * @return class-string|null
     */
    public function fullyQualifiedName(?string $source): ?string
    {
        if ($source === null) {
            return null;
        }

        $namespace = $this->namespace ? str_replace('\\\\', '\\', '\\' . $this->namespace . '\\') : '\\';

        $thisSource = $this->class ?? $this->interface ?? $this->trait;
        if ($thisSource && strcasecmp($source, $thisSource) === 0) {
            return $namespace . $thisSource;
        }
        $pos = strpos($source, '\\');
        if ($pos !== false) {
            if ($pos === 0) {
                // Fully qualified name (\Foo\Bar)
                return $source;
            }
            // Qualified name (Foo\Bar)
            if ($this->uses) {
                foreach ($this->uses as $alias => $aliasedNamespace) {
                    $alias .= '\\';
                    if (strcasecmp(substr($source, 0, strlen($alias)), $alias) === 0) {
                        // Aliased namespace (use \Long\Namespace as Foo)
                        return '\\' . $aliasedNamespace . substr($source, strlen($alias) - 1);
                    }
                }
            }
        } elseif ($this->uses) {
            // Unqualified name (Foo)
            foreach ($this->uses as $alias => $aliasedNamespace) {
                if (strcasecmp((string) $alias, $source) === 0) {
                    return '\\' . $aliasedNamespace;
                }
            }
        }

        return $namespace . $source;
    }
}
