<?php

namespace SugarCalendarBookings\Support;

use Closure;
use Exception;
use Throwable;

/**
 * Container.
 *
 * A dependency injection container that manages class instances and their creation.
 *
 * @since 1.0.0
 */
class Container {

	/**
	 * Class definitions.
	 *
	 * @since 1.0.0
	 *
	 * @var array<class-string, Closure>
	 */
	private $definitions = [];

	/**
	 * Singleton definitions.
	 *
	 * @since 1.0.0
	 *
	 * @var array<class-string, Closure>
	 */
	private $singletons = [];

	/**
	 * Initialized singleton instances.
	 *
	 * @since 1.0.0
	 *
	 * @var array<class-string, mixed>
	 */
	private $initialized_singletons = [];

	/**
	 * Fully booted classes (init() and hooks() have been called).
	 *
	 * @since 1.2.0
	 *
	 * @var array<class-string, bool>
	 */
	private $booted_classes = [];

	/**
	 * Register a singleton class.
	 *
	 * @since 1.0.0
	 *
	 * @template T
	 *
	 * @param class-string<T>  $class_name The class to register.
	 * @param null|Closure():T $builder    Optional custom builder function.
	 */
	public function singleton( $class_name, ?Closure $builder = null ): void {

		$this->singletons[ $class_name ] = $builder ?? fn() => new $class_name();
	}

	/**
	 * Register a class binding.
	 *
	 * @since 1.0.0
	 *
	 * @template T
	 *
	 * @param class-string<T>  $class_name The class to register.
	 * @param null|Closure():T $builder    Optional custom builder function.
	 */
	public function bind( $class_name, ?Closure $builder = null ): void {

		$this->definitions[ $class_name ] = $builder ?? fn() => new $class_name();
	}

	/**
	 * Get an instance of a class from the container.
	 *
	 * @since 1.0.0
	 *
	 * @template T
	 *
	 * @param class-string<T> $class_name The class to retrieve.
	 *
	 * @return T
	 * @throws Exception If the class is not found in the container.
	 */
	public function get( $class_name ) {

		if ( array_key_exists( $class_name, $this->initialized_singletons ) ) {
			return $this->initialized_singletons[ $class_name ];
		}

		if ( array_key_exists( $class_name, $this->singletons ) ) {
			// phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found
			return $this->initialized_singletons[ $class_name ] = $this->singletons[ $class_name ]();
		}

		if ( array_key_exists( $class_name, $this->definitions ) ) {
			return $this->definitions[ $class_name ]();
		}

		// Auto-register as singleton if class exists and can be instantiated.
		try {
			if ( class_exists( $class_name ) ) {
				// phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found
				return $this->initialized_singletons[ $class_name ] = new $class_name();
			}
		} catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Do nothing.
		}

		throw new Exception(
			sprintf(
			/* translators: %1$s: class name. */
				esc_html__( 'Class not found in container: %1$s.', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				esc_html( $class_name )
			)
		);
	}

	/**
	 * Check if a class is registered in the container.
	 *
	 * @since 1.0.0
	 *
	 * @param string $class_name The class to check.
	 *
	 * @return bool
	 */
	public function has( string $class_name ): bool {

		return array_key_exists( $class_name, $this->definitions ) || array_key_exists( $class_name, $this->singletons );
	}

	/**
	 * Check if a singleton class has already been fully booted.
	 *
	 * Used by loaders to skip booting classes that were already booted by another loader.
	 * A class is considered "booted" only after its init() and hooks() methods have been called.
	 *
	 * @since 1.2.0
	 *
	 * @param string $class_name The class to check.
	 *
	 * @return bool
	 */
	public function is_resolved( string $class_name ): bool {

		return array_key_exists( $class_name, $this->booted_classes );
	}

	/**
	 * Mark a class as fully booted.
	 *
	 * Called by loaders after init() and hooks() have been executed.
	 *
	 * @since 1.2.0
	 *
	 * @param string $class_name The class to mark as booted.
	 */
	public function mark_as_booted( string $class_name ): void {

		$this->booted_classes[ $class_name ] = true;
	}

	/**
	 * Make a new instance of a class with constructor parameters.
	 *
	 * Unlike get(), this always creates a new instance and does not cache.
	 * Uses the registered builder if available, otherwise instantiates directly.
	 *
	 * Note: Classes registered for make() (with required constructor params)
	 * should NOT be called via get(). Use make() for factory instances.
	 *
	 * @since 1.3.0
	 *
	 * @template T
	 *
	 * @param class-string<T> $class_name The class to instantiate.
	 * @param mixed           ...$args    Constructor arguments.
	 *
	 * @return T
	 * @throws Exception If the class cannot be instantiated.
	 */
	public function make( $class_name, ...$args ) {

		// Use registered singleton builder if available (supports Pro overrides).
		if ( array_key_exists( $class_name, $this->singletons ) ) {
			return $this->singletons[ $class_name ]( ...$args );
		}

		// Use registered definition builder if available.
		if ( array_key_exists( $class_name, $this->definitions ) ) {
			return $this->definitions[ $class_name ]( ...$args );
		}

		// Fallback: instantiate directly if class exists.
		if ( class_exists( $class_name ) ) {
			try {
				return new $class_name( ...$args );
			} catch ( Throwable $e ) {
				throw new Exception(
					sprintf(
						/* translators: %1$s - class name; %2$s - error message. */
						esc_html__( 'Failed to instantiate class %1$s: %2$s', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
						esc_html( $class_name ),
						esc_html( $e->getMessage() )
					)
				);
			}
		}

		throw new Exception(
			sprintf(
				/* translators: %1$s - class name. */
				esc_html__( 'Cannot instantiate class: %1$s.', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				esc_html( $class_name )
			)
		);
	}
}
