<?php

declare(strict_types=1);

namespace Fennec\Container;

/**
 * Conteneur d'injection de dépendances avec autowiring par Reflection.
 *
 * Ne contient aucun binding métier en dur : les correspondances concrètes
 * (interface → implémentation) sont enregistrées par le projet qui l'utilise,
 * pas par le framework lui-même.
 */
final class Container
{
    /** @var array<class-string, \Closure> */
    private array $bindings = [];

    /** @var array<class-string, true> */
    private array $sharedAbstracts = [];

    /** @var array<class-string, object> */
    private array $instances = [];

    /** @var list<class-string> Pile des classes en cours de résolution, pour détecter les cycles. */
    private array $resolutionStack = [];

    /**
     * Enregistre un binding transitoire : une nouvelle instance à chaque résolution.
     *
     * @param class-string $abstract
     * @param \Closure|class-string|null $concrete Implémentation concrète, ou null pour autowirer $abstract lui-même.
     */
    public function bind(string $abstract, \Closure|string|null $concrete = null): void
    {
        $this->register($abstract, $concrete, shared: false);
    }

    /**
     * Enregistre un binding partagé : la même instance est réutilisée après la première résolution.
     *
     * @param class-string $abstract
     * @param \Closure|class-string|null $concrete Implémentation concrète, ou null pour autowirer $abstract lui-même.
     */
    public function singleton(string $abstract, \Closure|string|null $concrete = null): void
    {
        $this->register($abstract, $concrete, shared: true);
    }

    /**
     * Enregistre directement une instance déjà construite (ex. Config, construit au bootstrap
     * avant même que le Container existe).
     *
     * @param class-string $abstract
     */
    public function instance(string $abstract, object $instance): void
    {
        $this->instances[$abstract] = $instance;
        $this->sharedAbstracts[$abstract] = true;
    }

    /**
     * Indique si $abstract a été explicitement enregistré (via bind/singleton/instance).
     *
     * Une classe concrète autowirable mais jamais enregistrée renvoie false ici,
     * même si get() saurait la construire.
     *
     * @param class-string $abstract
     */
    public function has(string $abstract): bool
    {
        return isset($this->instances[$abstract]) || isset($this->bindings[$abstract]);
    }

    /**
     * Résout $abstract : instance mise en cache, binding enregistré, ou autowiring direct.
     *
     * @template T of object
     * @param class-string<T> $abstract
     * @return T
     *
     * @throws CircularDependencyException Si la résolution boucle sur elle-même.
     * @throws UnresolvableParameterException Si un paramètre de constructeur ne peut pas être déterminé.
     * @throws \ReflectionException Si $abstract (ou une dépendance résolue) n'est pas une classe valide.
     */
    public function get(string $abstract): object
    {
        if (isset($this->instances[$abstract])) {
            return $this->instances[$abstract];
        }

        $factory = $this->bindings[$abstract] ?? fn (self $container): object => $container->build($abstract);

        $instance = $factory($this);

        if ($this->sharedAbstracts[$abstract] ?? false) {
            $this->instances[$abstract] = $instance;
        }

        return $instance;
    }

    /**
     * @param class-string $abstract
     * @param \Closure|class-string|null $concrete
     */
    private function register(string $abstract, \Closure|string|null $concrete, bool $shared): void
    {
        $concrete ??= $abstract;

        $this->bindings[$abstract] = $concrete instanceof \Closure
            ? $concrete
            : fn (self $container): object => $container->build($concrete);

        if ($shared) {
            $this->sharedAbstracts[$abstract] = true;
        } else {
            unset($this->sharedAbstracts[$abstract]);
        }

        unset($this->instances[$abstract]);
    }

    /**
     * @param class-string $concreteClass
     *
     * @throws CircularDependencyException
     * @throws UnresolvableParameterException
     * @throws \ReflectionException Si $concreteClass n'est pas une classe valide.
     */
    private function build(string $concreteClass): object
    {
        if (\in_array($concreteClass, $this->resolutionStack, true)) {
            throw new CircularDependencyException($this->resolutionStack, $concreteClass);
        }

        $this->resolutionStack[] = $concreteClass;

        try {
            $reflection = new \ReflectionClass($concreteClass);
            $constructor = $reflection->getConstructor();

            if ($constructor === null) {
                return $reflection->newInstance();
            }

            $arguments = [];

            foreach ($constructor->getParameters() as $parameter) {
                $arguments[] = $this->resolveParameter($parameter);
            }

            return $reflection->newInstanceArgs($arguments);
        } finally {
            \array_pop($this->resolutionStack);
        }
    }

    /**
     * @throws UnresolvableParameterException
     */
    private function resolveParameter(\ReflectionParameter $parameter): mixed
    {
        $type = $parameter->getType();

        if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
            return $this->get($type->getName());
        }

        if ($parameter->isDefaultValueAvailable()) {
            return $parameter->getDefaultValue();
        }

        if ($type instanceof \ReflectionNamedType && $type->allowsNull()) {
            return null;
        }

        throw new UnresolvableParameterException(
            $parameter->getDeclaringClass()?->getName() ?? 'unknown',
            $parameter->getName(),
            $type === null
                ? 'no type declared and no default value available.'
                : \sprintf('type "%s" cannot be autowired and has no default value.', (string) $type),
        );
    }
}
