<?php
/**
 * Flat Bricks element tree: locate, insert, move, duplicate, delete.
 *
 * @package LolaCoreForBricks
 */

declare(strict_types=1);

namespace LolaCoreForBricks\Elements;

/**
 * Operates on the Bricks `{id, name, parent, children[], settings{}}` list.
 */
final class ElementTree
{
    use ElementTreeBranches;

    public const POSITION_CHILD  = 'child';
    public const POSITION_BEFORE = 'before';
    public const POSITION_AFTER  = 'after';

    public const ROOT_PARENT = 0;

    /**
     * @var array<int, array<string, mixed>>
     */
    private array $elements = [];

    /**
     * @param array<int|string, mixed> $elements
     */
    public function __construct(array $elements = [])
    {
        $this->elements = $this->normalizeList($elements);
    }

    /**
     * @param array<int|string, mixed> $elements
     */
    public static function fromList(array $elements): self
    {
        return new self($elements);
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    public function toList(): array
    {
        return array_values($this->elements);
    }

    /**
     * @return array<string, mixed>|null
     */
    public function findById(string $id): ?array
    {
        $index = $this->indexOf($id);

        return $index === null ? null : $this->elements[$index];
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    public function findByName(string $name): array
    {
        $matches = [];
        foreach ($this->elements as $element) {
            if ((string) ($element['name'] ?? '') === $name) {
                $matches[] = $element;
            }
        }

        return $matches;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    public function findByClass(string $classId): array
    {
        $matches = [];
        foreach ($this->elements as $element) {
            $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
            $classes  = $settings['_cssGlobalClasses'] ?? [];
            if (!is_array($classes)) {
                continue;
            }

            foreach ($classes as $applied) {
                if ((string) $applied === $classId) {
                    $matches[] = $element;
                    break;
                }
            }
        }

        return $matches;
    }

    /**
     * @return string[]
     */
    public function ids(): array
    {
        $ids = [];
        foreach ($this->elements as $element) {
            $id = (string) ($element['id'] ?? '');
            if ($id !== '') {
                $ids[] = $id;
            }
        }

        return $ids;
    }

    /**
     * @return string[]
     */
    public function descendantIds(string $id): array
    {
        $found = [];
        $this->collectDescendants($id, $found);

        return $found;
    }

    /**
     * @param array<string, mixed> $element
     * @return string|null Error code, or null on success.
     */
    public function insert(array $element, string $position, string $referenceId): ?string
    {
        $id = (string) ($element['id'] ?? '');
        if ($id === '' || $this->indexOf($id) !== null) {
            return 'invalid_id';
        }

        $resolved = $this->resolvePlacement($position, $referenceId);
        if ($resolved === null) {
            return 'reference_not_found';
        }

        [$parentId, $siblingIds, $insertAt] = $resolved;

        $element['parent']   = $this->storeParent($parentId);
        $element['children'] = $this->normalizeChildren($element['children'] ?? []);
        $element['settings'] = is_array($element['settings'] ?? null) ? $element['settings'] : [];
        $element['name']     = (string) ($element['name'] ?? '');

        $siblingIds = $this->insertIntoSiblings($siblingIds, $id, $insertAt);
        $this->writeChildren($parentId, $siblingIds);
        $this->elements[] = $element;

        return $this->firstCoherenceError();
    }

    /**
     * @return string|null Error code, or null on success.
     */
    public function move(string $elementId, string $position, string $referenceId): ?string
    {
        $index = $this->indexOf($elementId);
        if ($index === null) {
            return 'element_not_found';
        }

        $resolved = $this->resolvePlacement($position, $referenceId);
        if ($resolved === null) {
            return 'reference_not_found';
        }

        [$newParentId] = $resolved;
        if ($newParentId === $elementId || in_array($newParentId, $this->descendantIds($elementId), true)) {
            return 'cycle';
        }

        $this->detach($elementId);

        $resolved = $this->resolvePlacement($position, $referenceId);
        if ($resolved === null) {
            return 'reference_not_found';
        }

        [$parentId, $siblingIds, $insertAt] = $resolved;
        $siblingIds = array_values(array_filter(
            $siblingIds,
            static fn (string $id): bool => $id !== $elementId
        ));
        $siblingIds = $this->insertIntoSiblings($siblingIds, $elementId, $insertAt);

        $this->elements[$index]['parent'] = $this->storeParent($parentId);
        $this->writeChildren($parentId, $siblingIds);

        return $this->firstCoherenceError();
    }

    /**
     * @return array{error: ?string, new_id?: string, id_map?: array<string, string>}
     */
    public function duplicate(string $elementId, IdGenerator $ids): array
    {
        $source = $this->findById($elementId);
        if ($source === null) {
            return ['error' => 'element_not_found'];
        }

        $branchIds = array_merge([$elementId], $this->descendantIds($elementId));
        $idMap     = $ids->remap($branchIds, $this->ids());
        $clones    = [];

        foreach ($branchIds as $oldId) {
            $original = $this->findById($oldId);
            if ($original === null) {
                continue;
            }

            $clone            = $original;
            $clone['id']      = $idMap[$oldId];
            $clone['children'] = [];
            foreach ($this->normalizeChildren($original['children'] ?? []) as $childId) {
                if (isset($idMap[$childId])) {
                    $clone['children'][] = $idMap[$childId];
                }
            }

            $parent = $this->parentId($original);
            if ($oldId === $elementId) {
                $clone['parent'] = $this->storeParent($parent);
            } elseif (isset($idMap[$parent])) {
                $clone['parent'] = $idMap[$parent];
            } else {
                $clone['parent'] = $this->storeParent($parent);
            }

            $clones[] = $clone;
        }

        $newRootId = $idMap[$elementId];
        $siblings  = $this->siblingIds($this->parentId($source));
        $insertAt  = array_search($elementId, $siblings, true);
        $insertAt  = $insertAt === false ? count($siblings) : $insertAt + 1;
        $siblings  = $this->insertIntoSiblings($siblings, $newRootId, $insertAt);
        $this->writeChildren($this->parentId($source), $siblings);

        foreach ($clones as $clone) {
            $this->elements[] = $clone;
        }

        $error = $this->firstCoherenceError();

        return [
            'error'  => $error,
            'new_id' => $newRootId,
            'id_map' => $idMap,
        ];
    }

    /**
     * @return string|null Error code, or null on success.
     */
    public function delete(string $elementId): ?string
    {
        if ($this->indexOf($elementId) === null) {
            return 'element_not_found';
        }

        $remove = array_flip(array_merge([$elementId], $this->descendantIds($elementId)));
        $this->detach($elementId);

        $kept = [];
        foreach ($this->elements as $element) {
            $id = (string) ($element['id'] ?? '');
            if (!isset($remove[$id])) {
                $kept[] = $element;
            }
        }

        $this->elements = $kept;

        return $this->firstCoherenceError();
    }

    /**
     * @param array<int|string, mixed> $elements
     * @return array<int, array<string, mixed>>
     */
    private function normalizeList(array $elements): array
    {
        $normalized = [];
        foreach ($elements as $element) {
            if (!is_array($element)) {
                continue;
            }

            $element['id']       = (string) ($element['id'] ?? '');
            $element['name']     = (string) ($element['name'] ?? '');
            $element['parent']   = $this->storeParent($this->parentId($element));
            $element['children'] = $this->normalizeChildren($element['children'] ?? []);
            $element['settings'] = is_array($element['settings'] ?? null) ? $element['settings'] : [];
            $normalized[]        = $element;
        }

        return $normalized;
    }

    /**
     * @return array{0: string, 1: string[], 2: int}|null
     */
    private function resolvePlacement(string $position, string $referenceId): ?array
    {
        $position = $position === '' ? self::POSITION_CHILD : $position;

        if ($position === self::POSITION_CHILD) {
            if ($referenceId === '' || $this->isRootParent($referenceId)) {
                $siblings = $this->siblingIds('');

                return ['', $siblings, count($siblings)];
            }

            if ($this->indexOf($referenceId) === null) {
                return null;
            }

            $siblings = $this->siblingIds($referenceId);

            return [$referenceId, $siblings, count($siblings)];
        }

        if ($referenceId === '' || $this->indexOf($referenceId) === null) {
            return null;
        }

        $reference = $this->findById($referenceId);
        if ($reference === null) {
            return null;
        }

        $parentId = $this->parentId($reference);
        $siblings = $this->siblingIds($parentId);
        $at       = array_search($referenceId, $siblings, true);
        if ($at === false) {
            $at = count($siblings);
        }

        if ($position === self::POSITION_AFTER) {
            ++$at;
        }

        return [$parentId, $siblings, $at];
    }

    /**
     * @param string[] $siblings
     * @return string[]
     */
    private function insertIntoSiblings(array $siblings, string $id, int $at): array
    {
        $at = max(0, min($at, count($siblings)));
        array_splice($siblings, $at, 0, [$id]);

        return array_values($siblings);
    }

    /**
     * @param string[] $children
     */
    private function writeChildren(string $parentId, array $children): void
    {
        if ($parentId === '' || $this->isRootParent($parentId)) {
            return;
        }

        $index = $this->indexOf($parentId);
        if ($index === null) {
            return;
        }

        $this->elements[$index]['children'] = array_values($children);
    }

    private function detach(string $elementId): void
    {
        $element = $this->findById($elementId);
        if ($element === null) {
            return;
        }

        $parentId = $this->parentId($element);
        $siblings = array_values(array_filter(
            $this->siblingIds($parentId),
            static fn (string $id): bool => $id !== $elementId
        ));
        $this->writeChildren($parentId, $siblings);
    }

    /**
     * @return string[]
     */
    private function siblingIds(string $parentId): array
    {
        if ($parentId !== '' && !$this->isRootParent($parentId)) {
            $parent = $this->findById($parentId);

            return $parent === null ? [] : $this->normalizeChildren($parent['children'] ?? []);
        }

        $roots = [];
        foreach ($this->elements as $element) {
            if ($this->parentId($element) === '') {
                $id = (string) ($element['id'] ?? '');
                if ($id !== '') {
                    $roots[] = $id;
                }
            }
        }

        return $roots;
    }

    /**
     * @param array<string, mixed> $element
     */
    private function parentId(array $element): string
    {
        $parent = $element['parent'] ?? self::ROOT_PARENT;

        return $this->isRootParent($parent) ? '' : (string) $parent;
    }

    private function storeParent(string $parentId): int|string
    {
        return ($parentId === '' || $this->isRootParent($parentId)) ? self::ROOT_PARENT : $parentId;
    }

    private function isRootParent(mixed $parent): bool
    {
        return $parent === self::ROOT_PARENT
            || $parent === '0'
            || $parent === ''
            || $parent === null
            || $parent === false;
    }

    /**
     * @param mixed $children
     * @return string[]
     */
    private function normalizeChildren(mixed $children): array
    {
        if (!is_array($children)) {
            return [];
        }

        $ids = [];
        foreach ($children as $child) {
            $id = (string) $child;
            if ($id !== '') {
                $ids[] = $id;
            }
        }

        return $ids;
    }

    private function indexOf(string $id): ?int
    {
        if ($id === '') {
            return null;
        }

        foreach ($this->elements as $index => $element) {
            if ((string) ($element['id'] ?? '') === $id) {
                return $index;
            }
        }

        return null;
    }

    /**
     * @param string[] $found
     */
    private function collectDescendants(string $id, array &$found): void
    {
        $element = $this->findById($id);
        if ($element === null) {
            return;
        }

        foreach ($this->normalizeChildren($element['children'] ?? []) as $childId) {
            if (in_array($childId, $found, true)) {
                continue;
            }

            $found[] = $childId;
            $this->collectDescendants($childId, $found);
        }
    }

    /**
     * @param array<string, array<string, mixed>> $byId
     */
    private function hasCycle(array $byId): bool
    {
        foreach (array_keys($byId) as $startId) {
            $seen = [];
            $id   = $startId;
            while ($id !== '' && isset($byId[$id])) {
                if (isset($seen[$id])) {
                    return true;
                }
                $seen[$id] = true;
                $id        = $this->parentId($byId[$id]);
            }
        }

        return false;
    }
}
