<?php

namespace ipl\Orm;

use ArrayIterator;
use InvalidArgumentException;
use ipl\Orm\Exception\InvalidRelationException;
use ipl\Orm\Relation\BelongsTo;
use ipl\Orm\Relation\BelongsToMany;
use ipl\Orm\Relation\BelongsToOne;
use ipl\Orm\Relation\HasMany;
use ipl\Orm\Relation\HasOne;
use IteratorAggregate;
use Traversable;

use function ipl\Stdlib\get_php_type;

/**
 * Collection of a model's relations.
 */
class Relations implements IteratorAggregate
{
    /** @var Relation[] */
    protected array $relations = [];

    /**
     * Get whether a relation with the given name exists
     *
     * @param string $name
     *
     * @return bool
     */
    public function has(string $name): bool
    {
        return isset($this->relations[$name]);
    }

    /**
     * Get the relation with the given name
     *
     * @param string $name
     *
     * @return Relation
     *
     * @throws InvalidArgumentException If the relation with the given name does not exist
     */
    public function get(string $name): Relation
    {
        $this->assertRelationExists($name);

        return $this->relations[$name];
    }

    /**
     * Add the given relation to the collection
     *
     * @param Relation $relation
     *
     * @return $this
     *
     * @throws InvalidArgumentException If a relation with the given name already exists
     */
    public function add(Relation $relation): static
    {
        $name = $relation->getName();

        $this->assertRelationDoesNotYetExist($name);

        $this->relations[$name] = $relation;

        return $this;
    }

    /**
     * Create a new relation from the given class, name and target model class
     *
     * @param string $class       Class of the relation to create
     * @param string $name        Name of the relation
     * @param string $targetClass Target model class
     *
     * @return BelongsTo|BelongsToOne|BelongsToMany|HasMany|HasOne|Relation
     *
     * @throws InvalidArgumentException If the target model class is not of type string
     */
    public function create(string $class, string $name, string $targetClass): Relation
    {
        $relation = new $class();

        if (! $relation instanceof Relation) {
            throw new InvalidArgumentException(sprintf(
                '%s() expects parameter 1 to be a subclass of %s, %s given',
                __METHOD__,
                Relation::class,
                get_php_type($relation)
            ));
        }

        // Test target model
        $target = new $targetClass();
        if (! $target instanceof Model) {
            throw new InvalidArgumentException(sprintf(
                '%s() expects parameter 3 to be a subclass of %s, %s given',
                __METHOD__,
                Model::class,
                get_php_type($target)
            ));
        }

        /** @var Relation $relation */
        $relation
            ->setName($name)
            ->setTarget($target)
            ->setTargetClass($targetClass);

        return $relation;
    }

    /**
     * Define a one-to-one relationship
     *
     * @param string $name        Name of the relation
     * @param string $targetClass Target model class
     *
     * @return HasOne
     */
    public function hasOne(string $name, string $targetClass): HasOne
    {
        /** @var HasOne $relation */
        $relation = $this->create(HasOne::class, $name, $targetClass);

        $this->add($relation);

        return $relation;
    }

    /**
     * Define a one-to-many relationship
     *
     * @param string $name        Name of the relation
     * @param string $targetClass Target model class
     *
     * @return HasMany
     */
    public function hasMany(string $name, string $targetClass): HasMany
    {
        /** @var HasMany $relation */
        $relation = $this->create(HasMany::class, $name, $targetClass);

        $this->add($relation);

        return $relation;
    }

    /**
     * Define the inverse of a one-to-one or one-to-many relationship
     *
     * @param string $name        Name of the relation
     * @param string $targetClass Target model class
     *
     * @return BelongsTo
     */
    public function belongsTo(string $name, string $targetClass): BelongsTo
    {
        /** @var BelongsTo $relation */
        $relation = $this->create(BelongsTo::class, $name, $targetClass);

        $this->add($relation);

        return $relation;
    }

    /**
     * Define a one-to-one relationship with a junction table
     *
     * @param string $name        Name of the relation
     * @param string $targetClass Target model class
     *
     * @return BelongsToOne
     */
    public function belongsToOne(string $name, string $targetClass): BelongsToOne
    {
        /** @var BelongsToOne $relation */
        $relation = $this->create(BelongsToOne::class, $name, $targetClass);

        $this->add($relation);

        return $relation;
    }

    /**
     * Define a many-to-many relationship
     *
     * @param string $name        Name of the relation
     * @param string $targetClass Target model class
     *
     * @return BelongsToMany
     */
    public function belongsToMany(string $name, string $targetClass): BelongsToMany
    {
        /** @var BelongsToMany $relation */
        $relation = $this->create(BelongsToMany::class, $name, $targetClass);

        $this->add($relation);

        return $relation;
    }

    public function getIterator(): Traversable
    {
        return new ArrayIterator($this->relations);
    }

    /**
     * Throw exception if a relation with the given name already exists
     *
     * @param string $name
     *
     * @return void
     *
     * @throws InvalidArgumentException
     */
    protected function assertRelationDoesNotYetExist(string $name): void
    {
        if ($this->has($name)) {
            throw new InvalidArgumentException("Relation '$name' already exists");
        }
    }

    /**
     * Throw exception if a relation with the given name does not exist
     *
     * @param string $name
     *
     * @throws InvalidArgumentException
     */
    protected function assertRelationExists(string $name): void
    {
        if (! $this->has($name)) {
            throw new InvalidRelationException($name);
        }
    }
}
