<?php

declare(strict_types=1);

namespace apexl\Io\dto\Relationship;

use apexl\entityCore\collections\EntityCollection;
use apexl\Io\collections\LoaderConditionCollection;
use apexl\Io\dto\LoaderCondition;
use apexl\Io\enums\EntityFinderJsonType;
use apexl\Io\includes\Entity;
use apexl\Io\interfaces\QueryDecorator;
use apexl\Io\interfaces\RelationOrderBy;
use apexl\Io\interfaces\RelationshipInterface;
use apexl\Io\interfaces\RelationSqlFragment;
use apexl\Utils\Class\ClassBasename;
use apexl\Utils\Strings\Pluralizer;
use apexl\Vault\enums\SortDirection;
use apexl\Vault\enums\WhereCondition;
use apexl\Vault\enums\WhereType;
use apexl\Vault\interfaces\driver;
use Exception;
use Stringy\Stringy;

final readonly class BelongsToMany extends OneToMany
{
    /**
     * @param class-string<Entity> $remoteEntity
     */
    protected function __construct(
        string $name,
        Entity $localEntity,
        string $remoteEntity,
        string $localTable,
        string $localKey,
        string $remoteKey,
        ?string $aliasedRemoteTable,
        ?QueryDecorator $queryCallback,
        ?RelationSqlFragment $rawJoin,
        ?QueryDecorator $rawQuery,
        ?RelationshipInterface $parent = null,
        bool $noFields = false,
        ?RelationOrderBy $orderByCallback = null,
    ) {
        parent::__construct(
            $name,
            $localEntity,
            $remoteEntity,
            $localTable,
            $localKey,
            $remoteKey,
            $parent,
            $aliasedRemoteTable,
            $queryCallback,
            $rawJoin,
            $rawQuery,
            $noFields,
            $orderByCallback,
        );
    }

    /**
     * @param class-string<Entity> $remoteEntity
     */
    public static function create(
        string $name,
        Entity $localEntity,
        string $remoteEntity,
        ?string $localKey = null,
        ?string $remoteKey = null,
        ?QueryDecorator $queryCallback = null,
        ?RelationSqlFragment $rawJoin = null,
        ?QueryDecorator $rawQuery = null,
        ?RelationshipInterface $parent = null,
        ?string $aliasedRemoteTable = null,
        ?RelationOrderBy $orderByCallback = null,
    ): self {
        return new self(
            name: $name,
            localEntity: $localEntity,
            remoteEntity: $remoteEntity,
            localTable: $localEntity->getTableName(),
            localKey: $localKey ?? $localEntity->getPrimaryKey(),
            remoteKey: $remoteKey ?? self::remoteKey($localEntity),
            aliasedRemoteTable: $aliasedRemoteTable,
            queryCallback: $queryCallback,
            rawJoin: $rawJoin,
            rawQuery: $rawQuery,
            parent: $parent,
            orderByCallback: $orderByCallback
        );
    }

    private static function remoteKey(Entity $localEntity): string
    {
        return Pluralizer::singular(
            Stringy::create(ClassBasename::classBasename($localEntity::class))->snakeize()->append('_id')->toString()
        );
    }

    public function lazyLoad(int|string $localKey): ?EntityCollection
    {
        $query = $this->getRemoteEntity()->findMultipleQuery(
            conditions: [
                LoaderCondition::equals(
                    sprintf('%s.%s', $this->getAliasedRemoteTable(), $this->getRemoteKey()),
                    $localKey
                )
            ],
            limit: null,
            sorts: $this->orderBy(),
            tableAlias: $this->getAliasedRemoteTable(),
        );

        $result = $query->execute()->fetchAllAssoc();

        if ($result) {
            return EntityCollection::createFromArray(
                array_map(fn(array $row) => $this->getRemoteEntity()->hydrate($row), $result)
            );
        }

        return new EntityCollection();
    }

    /**
     * @throws Exception
     */
    public function set(int|array|EntityCollection|Entity $entity): void
    {
        $this->clear();

        $entities = $this->toEntityCollection($entity);

        foreach ($entities as $entity) {
            $this->add($entity);
        }

        $this->localEntity->{$this->getName()} = $entities;
    }

    public function clear(): void
    {
        $localEntityId = $this->localEntity->{$this->localEntity->getPrimaryKey()};

        $this->remoteEntity::findMultiple([
            LoaderCondition::equals($this->getRemoteKey(), $localEntityId),
        ])->each($this->remove(...));

        $this->localEntity->{$this->name} = $this->valueWhenEmpty();
    }

    /**
     * @throws Exception
     */
    public function add(int|Entity ...$entity): void
    {
        foreach ($entity as $_entity) {
            $_entity = $this->ensureEntity($_entity);
            $this->assertEntityType($_entity);

            $_entity->{$this->getRemoteKey()} = $this->localEntity->{$this->localKey};
            $_entity->store();

            /* @var EntityCollection $collection */
            $collection = ($this->localEntity->{$this->name});
            $collection->add($_entity);
        }
    }

    /**
     * @throws Exception
     */
    public function remove(int|Entity $entity): void
    {
        $entity = $this->ensureEntity($entity);

        $entity->{$this->getRemoteKey()} = null;
        $entity->store();

        $this->localEntity->{$this->getName()} = $this->localEntity->{$this->getName()}->filter(
            fn(Entity $item): bool => $item->{$item->getPrimaryKey()} !== $entity->{$entity->getPrimaryKey()}
        )->values();
    }

    /**
     * Without a sort the database is free to return related rows in any order, which
     * makes anything that reads the collection positionally quietly non-deterministic.
     * {@see HasMany} falls back to the pivot's weight; with no pivot to lean on, the
     * remote primary key is the equivalent stable default.
     *
     * It is appended rather than substituted so that it also breaks ties for relations
     * that do declare an order — sorting by a column like weight is only deterministic
     * as far as that column is distinct.
     */
    protected function orderBy(): array
    {
        return [
            ...parent::orderBy(),
            [
                sprintf('%s.%s', $this->getAliasedRemoteTable(), $this->getRemoteEntity()->getPrimaryKey()),
                SortDirection::ASC,
            ],
        ];
    }

    public function subSelect(array $fields, ?string $table = null): driver
    {
        $query = $this->getRemoteEntity()->findMultipleQuery(
            conditions: $this->conditions($table),
            limit: null,
            sorts: $this->orderBy(),
            fields: $fields,
            json: EntityFinderJsonType::MANY,
            tableAlias: $this->getAliasedRemoteTable(),
        );

        return $this->queryCallback->decorate($this, $query);
    }

    protected function conditions(?string $table = null): LoaderConditionCollection
    {
        return LoaderConditionCollection::createFromArray([
            LoaderCondition::ColumnsEqual(
                sprintf('%s.%s', $this->getAliasedRemoteTable(), $this->getRemoteKey()),
                sprintf('%s.%s', $table ?? $this->getLocalTable(), $this->localKey),
            ),
        ]);
    }

    public function loaderConditionForField(
        string $field,
        WhereCondition $condition,
        array|bool|float|int|null|string $value,
        WhereType $type = WhereType::AND
    ): LoaderCondition {
        $sql = $this->subSelectStringSingleField($field);
        $extract = sprintf('JSON_EXTRACT((%s), \'$[*].%s\')', $sql, str_replace('__', '.', $field));

        // A LIKE has to stay a LIKE. JSON_CONTAINS needs its candidate to be valid
        // JSON and matches whole values, so a substring term never matches — and an
        // unquoted term isn't valid JSON at all. JSON_EXTRACT yields an array of the
        // scalar values, so LIKE over that gives the same substring semantics as a
        // local field.
        if ($condition === WhereCondition::LIKE) {
            return new LoaderCondition($extract, WhereCondition::LIKE, $value, $type);
        }

        return new LoaderCondition(
            $extract,
            WhereCondition::JSON_CONTAINS,
            $value,
            $type,
        );
    }

    public function subSelectStringSingleField(
        string $field,
        ?string $table = null,
    ): string {
        $query = $this->getRemoteEntity()->findMultipleQuery(
            conditions: $this->conditions($table),
            limit: null,
            fields: [$field],
            json: EntityFinderJsonType::MANY,
            autoIncludePrimaryKey: false,
            tableAlias: $this->getAliasedRemoteTable(),
        );

        return $this->queryCallback->decorate($this, $query)->query->getStatement();
    }
}
