<?php

namespace Velis\Model\RelationLoader;

use Closure;
use ReflectionClass;
use ReflectionException;
use Velis\Exception as VelisException;
use Velis\Model\BaseModel;
use Velis\Model\RelationLoader\Exceptions\ForeignKeyNotLoadedException;
use Velis\Model\RelationLoader\Exceptions\UndefinedRelationException;
use Velis\Model\RelationLoader\Exceptions\UnknownModelException;
use Velis\Model\RelationLoader\Relations\Relation;

/**
 * Core of the Relations system.
 * Allows appending relations to the passed models.
 *
 * --
 * -- Sample:
 * --
 * Story:
 * We want to get category of the ticket - and its labels, but only the `name` column, sorted descending by `name`.
 * Both for the list (eager loading) and a specific one - using lazy loading.
 *
 * In the Ticket model declare new methods:
 * public function category(): HasOneRelation {
 *     return new HasOneRelation(Category::class, 'ticket_category_id', 'ticket_category_id', $this);
 * }
 *
 * public function label(): HasManyRelation {
 *     return new HasManyRelation(Label::class, 'ticket_label_id', 'ticket_label_id', $this);
 * }
 *
 * Then anywhere in your code use:
 * $tickets = Ticket::getList(1, null, null, 10);
 * $tickets = RelationLoader::for(Ticket::class)
 *     ->to($tickets)
 *     ->append('category')
 *     ->appendWithOptions('labels', function (HasManyRelation $relation) {
 *         $relation
 *             ->select(['name'])
 *             ->orderBy('name DESC');
 *     })
 *     -> ...and any other relation you want
 *     ->load();
 *
 * $tickets[n]->relationContainer->category
 *
 * ...Or
 *
 * Ticket::instance(123)->category()->getOne();
 *
 * @author Szymon Janaczek <szymon.janaczek@velistech.com>
 *
 * @template T
 * @template-extends BaseModel
 */
class RelationLoader
{
    /** @var T */
    private $model;

    /** @var array<string, Relation> $relations */
    private array $relations = [];

    /** @var array<T> */
    private array $targets = [];

    /** @var array<string, int[]|string[]> */
    private array $foreignKeys;

    /**
     * @param T $model
     */
    private function __construct($model)
    {
        $this->model = $model;
    }

    /**
     * @param class-string<T> $model
     * @return self<T>
     * @throws UnknownModelException
     * @throws ReflectionException
     */
    public static function for(string $model): self
    {
        if (!class_exists($model)) {
            throw new UnknownModelException();
        }

        $reflector = new ReflectionClass($model);
        $modelInstance = $reflector->newInstanceWithoutConstructor();

        return new self($modelInstance);
    }

    /**
     * @param array|BaseModel $target
     * @return self<T>
     */
    public function to($target): self
    {
        if (!is_array($target)) {
            $target = [$target];
        }

        $this->targets = $target;

        return $this;
    }

    /**
     * @return self<T>
     * @throws UndefinedRelationException
     */
    public function appendWithOptions(string $name, Closure $closure): self
    {
        $this->appendDefined($name);
        $closure($this->relations[$name]);

        return $this;
    }

    /**
     * @param string $name
     * @param Relation|null $rawRelation
     * @return self<T>
     * @throws UndefinedRelationException
     */
    public function append(string $name, ?Relation $rawRelation = null): self
    {
        if ($rawRelation) {
            $this->appendRaw($name, $rawRelation);
        } else {
            $this->appendDefined($name);
        }

        return $this;
    }

    /**
     * @throws UndefinedRelationException
     */
    private function appendDefined(string $name)
    {
        if (!method_exists($this->model, $name)) {
            throw new UndefinedRelationException($name, $this->model);
        }

        $this->relations[$name] = $this->model->$name();
    }

    private function appendRaw(string $name, Relation $rawRelation)
    {
        $this->relations[$name] = $rawRelation;
    }

    /**
     * @return array<T>
     * @throws VelisException
     */
    public function load(): array
    {
        // If there are not any target models, return an empty array, instead of trying load relations.
        if (count($this->targets) <= 0) {
            return [];
        }

        $this->extractForeignKeys();
        $this->loadRelations();

        return $this->targets;
    }

    /**
     * Extract foreign keys (from target models) needed in called relations.
     */
    private function extractForeignKeys(): void
    {
        $foreignKeys = [];
        foreach ($this->relations as $relation) {
            $foreignKeys[] = $relation->getForeignKey();
        }

        $values = [];
        foreach ($foreignKeys as $foreignKey) {
            foreach ($this->targets as $target) {
                $values[$foreignKey][] = $target[$foreignKey];
            }
            $values[$foreignKey] = array_values(array_filter(array_unique($values[$foreignKey])));
        }

        $this->foreignKeys = $values;
    }

    /**
     * @throws ForeignKeyNotLoadedException
     */
    private function getValuesOfKey(Relation $relation)
    {
        if (!array_key_exists($relation->getForeignKey(), $this->foreignKeys)) {
            throw new ForeignKeyNotLoadedException($relation->getForeignKey());
        }

        return $this->foreignKeys[$relation->getForeignKey()];
    }

    /**
     * @throws ForeignKeyNotLoadedException
     */
    private function loadRelations(): void
    {
        foreach ($this->relations as $relationName => $relation) {
            $this->loadRelation($relationName, $relation);
        }
    }

    /**
     * @throws ForeignKeyNotLoadedException
     */
    private function loadRelation(string $relationName, Relation $relation): void
    {
        $foreignKeys = $this->getValuesOfKey($relation);
        $this->targets = $relation->match(
            $relationName,
            $relation->getModels($foreignKeys),
            $this->targets
        );
    }
}
