<?php

declare(strict_types=1);

namespace Kreait\Firebase\Database;

use Kreait\Firebase\Database\Reference\Validator;
use Kreait\Firebase\Exception\DatabaseException;
use Kreait\Firebase\Exception\InvalidArgumentException;
use Kreait\Firebase\Exception\OutOfRangeException;
use Psr\Http\Message\UriInterface;
use Stringable;

use function array_fill_keys;
use function array_keys;
use function array_map;
use function basename;
use function dirname;
use function is_array;
use function ltrim;
use function sprintf;
use function trim;

/**
 * A Reference represents a specific location in your database and can be used
 * for reading or writing data to that database location.
 */
class Reference implements Stringable
{
    private readonly UriInterface $uri;

    /**
     * @internal
     *
     * @throws InvalidArgumentException if the reference URI is invalid
     */
    public function __construct(
        UriInterface $uri,
        private readonly ApiClient $apiClient,
        private readonly UrlBuilder $urlBuilder,
        private readonly Validator $validator = new Validator(),
    ) {
        $this->validator->validateUri($uri);

        $this->uri = $uri;
    }

    /**
     * Returns the absolute URL for this location.
     *
     * @see getUri()
     */
    public function __toString(): string
    {
        return (string) $this->getUri();
    }

    /**
     * The last part of the current path.
     *
     * For example, "ada" is the key for https://sample-app.firebaseio.example.com/users/ada.
     *
     * The key of the root Reference is null.
     */
    public function getKey(): ?string
    {
        $key = basename($this->getPath());

        return $key !== '' ? $key : null;
    }

    /**
     * Returns the full path to a reference.
     */
    public function getPath(): string
    {
        return trim($this->uri->getPath(), '/');
    }

    /**
     * The parent location of a Reference.
     *
     * @throws OutOfRangeException if requested for the root Reference
     */
    public function getParent(): self
    {
        $parentPath = dirname($this->getPath());

        if ($parentPath === '.') {
            throw new OutOfRangeException('Cannot get parent of root reference');
        }

        return new self(
            $this->uri->withPath('/'.ltrim($parentPath, '/')),
            $this->apiClient,
            $this->urlBuilder,
            $this->validator,
        );
    }

    /**
     * The root location of a Reference.
     */
    public function getRoot(): self
    {
        return new self($this->uri->withPath('/'), $this->apiClient, $this->urlBuilder, $this->validator);
    }

    /**
     * Gets a Reference for the location at the specified relative path.
     *
     * The relative path can either be a simple child name (for example, "ada")
     * or a deeper slash-separated path (for example, "ada/name/first").
     *
     * @throws InvalidArgumentException if the path is invalid
     */
    public function getChild(string $path): self
    {
        $childPath = sprintf('/%s/%s', trim($this->uri->getPath(), '/'), trim($path, '/'));

        try {
            return new self(
                $this->uri->withPath($childPath),
                $this->apiClient,
                $this->urlBuilder,
                $this->validator,
            );
        } catch (\InvalidArgumentException $e) {
            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
        }
    }

    /**
     * Generates a new Query object ordered by the specified child key.
     *
     * @see Query::orderByChild()
     */
    public function orderByChild(string $path): Query
    {
        return $this->query()->orderByChild($path);
    }

    /**
     * Generates a new Query object ordered by key.
     *
     * @see Query::orderByKey()
     */
    public function orderByKey(): Query
    {
        return $this->query()->orderByKey();
    }

    /**
     * Generates a new Query object ordered by child values.
     *
     * @see Query::orderByValue()
     */
    public function orderByValue(): Query
    {
        return $this->query()->orderByValue();
    }

    /**
     * Generates a new Query limited to the first specific number of children.
     *
     * @see Query::limitToFirst()
     */
    public function limitToFirst(int $limit): Query
    {
        return $this->query()->limitToFirst($limit);
    }

    /**
     * Generates a new Query object limited to the last specific number of children.
     *
     * @see Query::limitToLast()
     */
    public function limitToLast(int $limit): Query
    {
        return $this->query()->limitToLast($limit);
    }

    /**
     * Creates a Query with the specified starting point (inclusive).
     *
     * @see Query::startAt()
     */
    public function startAt(bool|string|int|float $value): Query
    {
        return $this->query()->startAt($value);
    }

    /**
     * Creates a Query with the specified starting point (exclusive).
     *
     * @see Query::startAfter()
     */
    public function startAfter(bool|string|int|float $value): Query
    {
        return $this->query()->startAfter($value);
    }

    /**
     * Creates a Query with the specified ending point (inclusive).
     *
     * @see Query::endAt()
     */
    public function endAt(bool|string|int|float $value): Query
    {
        return $this->query()->endAt($value);
    }

    /**
     * Creates a Query with the specified ending point (exclusive).
     *
     * @see Query::endBefore()
     */
    public function endBefore(bool|string|int|float $value): Query
    {
        return $this->query()->endBefore($value);
    }

    /**
     * Creates a Query which includes children which match the specified value.
     *
     * @see Query::equalTo()
     */
    public function equalTo(bool|string|int|float $value): Query
    {
        return $this->query()->equalTo($value);
    }

    /**
     * Creates a Query with shallow results.
     *
     * @see Query::shallow()
     */
    public function shallow(): Query
    {
        return $this->query()->shallow();
    }

    /**
     * Returns the keys of a reference's children.
     *
     * @throws DatabaseException if the API reported an error
     * @throws OutOfRangeException if the reference has no children with keys
     *
     * @return string[]
     */
    public function getChildKeys(): array
    {
        $snapshot = $this->shallow()->getSnapshot();

        if (is_array($value = $snapshot->getValue())) {
            return array_map('strval', array_keys($value));
        }

        throw new OutOfRangeException(sprintf('%s has no children with keys', $this));
    }

    /**
     * Convenience method for {@see getSnapshot()}->getValue().
     *
     * @throws DatabaseException if the API reported an error
     */
    public function getValue(): mixed
    {
        return $this->getSnapshot()->getValue();
    }

    /**
     * Write data to this database location.
     *
     * This will overwrite any data at this location and all child locations.
     *
     * Passing null for the new value is equivalent to calling {@see remove()}:
     * all data at this location or any child location will be deleted.
     *
     * @throws DatabaseException if the API reported an error
     */
    public function set(mixed $value): self
    {
        if ($value === null) {
            $this->apiClient->remove($this->uri->getPath());
        } else {
            $this->apiClient->set($this->uri->getPath(), $value);
        }

        return $this;
    }

    /**
     * Returns a data snapshot of the current location.
     *
     * @throws DatabaseException if the API reported an error
     */
    public function getSnapshot(): Snapshot
    {
        $value = $this->apiClient->get($this->uri->getPath());

        return new Snapshot($this, $value);
    }

    /**
     * Generates a new child location using a unique key and returns its reference.
     *
     * This is the most common pattern for adding data to a collection of items.
     *
     * If you provide a value to push(), the value will be written to the generated location.
     * If you don't pass a value, nothing will be written to the database and the child
     * will remain empty (but you can use the reference elsewhere).
     *
     * The unique key generated by push() are ordered by the current time, so the resulting
     * list of items will be chronologically sorted. The keys are also designed to be
     * unguessable (they contain 72 random bits of entropy).
     *
     * @param mixed|null $value
     *
     * @throws DatabaseException if the API reported an error
     */
    public function push($value = null): self
    {
        $value ??= [];

        $newKey = $this->apiClient->push($this->uri->getPath(), $value);
        $newPath = sprintf('%s/%s', $this->uri->getPath(), $newKey);

        return new self($this->uri->withPath($newPath), $this->apiClient, $this->urlBuilder, $this->validator);
    }

    /**
     * Remove the data at this database location.
     *
     * Any data at child locations will also be deleted.
     *
     * @throws DatabaseException if the API reported an error
     */
    public function remove(): self
    {
        $this->apiClient->remove($this->uri->getPath());

        return $this;
    }

    /**
     * Remove the data at the given locations.
     *
     * Each location can either be a simple property (for example, "name"), or a relative path
     * (for example, "name/first") from the current location to the data to remove.
     *
     * Any data at child locations will also be deleted.
     *
     * @param string[] $keys Locations to remove
     *
     * @throws DatabaseException
     */
    public function removeChildren(array $keys): self
    {
        $this->update(
            array_fill_keys($keys, null),
        );

        return $this;
    }

    /**
     * Writes multiple values to the database at once.
     *
     * The values argument contains multiple property/value pairs that will be written to the database together.
     * Each child property can either be a simple property (for example, "name"), or a relative path
     * (for example, "name/first") from the current location to the data to update.
     *
     * As opposed to the {@see set()} method, update() can be use to selectively update only the referenced properties
     * at the current location (instead of replacing all the child properties at the current location).
     *
     * Passing null to {see update()} will remove the data at this location.
     *
     * @param array<mixed> $values
     *
     * @throws DatabaseException if the API reported an error
     */
    public function update(array $values): self
    {
        $this->apiClient->update($this->uri->getPath(), $values);

        return $this;
    }

    /**
     * Returns the absolute URL for this location.
     *
     * This method returns a URL that is ready to be put into a browser, curl command, or a
     * {@see Database::getReferenceFromUrl()} call. Since all of those expect the URL
     * to be url-encoded, toString() returns an encoded URL.
     *
     * Append '.json' to the URL when typed into a browser to download JSON formatted data.
     * If the location is secured (not publicly readable),
     * you will get a permission-denied error.
     */
    public function getUri(): UriInterface
    {
        return $this->uri;
    }

    /**
     * Returns a new query for the current reference.
     */
    private function query(): Query
    {
        return new Query($this, $this->apiClient);
    }
}
