<?php

declare(strict_types=1);

namespace Rowbot\URL;

use InvalidArgumentException;
use JsonSerializable;
use Rowbot\URL\Component\PathList;
use Rowbot\URL\Component\QueryList;
use Rowbot\URL\Exception\JsonException;
use Rowbot\URL\Exception\TypeError;
use Rowbot\URL\State\FragmentState;
use Rowbot\URL\State\HostnameState;
use Rowbot\URL\State\HostState;
use Rowbot\URL\State\PathStartState;
use Rowbot\URL\State\PortState;
use Rowbot\URL\State\QueryState;
use Rowbot\URL\State\SchemeStartState;
use Rowbot\URL\String\CodePoint;
use Rowbot\URL\String\IDLString;
use Rowbot\URL\String\USVStringInterface;

use function json_encode;
use function json_last_error;
use function json_last_error_msg;

use const JSON_ERROR_NONE;
use const JSON_UNESCAPED_SLASHES;

/**
 * Represents a URL that can be manipulated.
 *
 * @see https://url.spec.whatwg.org/#api
 * @see https://developer.mozilla.org/en-US/docs/Web/API/URL
 *
 * @property string                      $href
 * @property string                      $origin
 * @property string                      $protocol
 * @property string                      $username
 * @property string                      $password
 * @property string                      $host
 * @property string                      $hostname
 * @property string                      $port
 * @property string                      $pathname
 * @property string                      $search
 * @property \Rowbot\URL\URLSearchParams $searchParams
 * @property string                      $hash
 */
class URL implements JsonSerializable
{
    /**
     * @var \Rowbot\URL\URLSearchParams
     */
    private $queryObject;

    /**
     * @var \Rowbot\URL\URLRecord
     */
    private $url;

    /**
     * @throws \Rowbot\URL\Exception\TypeError
     */
    public function __construct(string $url, string $base = null)
    {
        $parsedBase = null;
        $parser = new BasicURLParser();

        if ($base !== null) {
            $parsedBase = $parser->parse(new IDLString($base));

            if ($parsedBase === false) {
                throw new TypeError($base . ' is not a valid URL.');
            }
        }

        $parsedURL = $parser->parse(new IDLString($url), $parsedBase);

        if ($parsedURL === false) {
            throw new TypeError($url . ' is not a valid URL.');
        }

        $this->url = $parsedURL;
        $this->queryObject = new URLSearchParams();
        $this->queryObject->setUrl($parsedURL);

        if ($this->url->query === null) {
            return;
        }

        $this->queryObject->setList(QueryList::fromString($this->url->query));
    }

    public function __clone()
    {
        $this->url = clone $this->url;
        $this->queryObject = clone $this->queryObject;
        $this->queryObject->setUrl($this->url);
    }

    /**
     * @return string|\Rowbot\URL\URLSearchParams
     *
     * @throws \InvalidArgumentException When an invalid $name value is passed.
     */
    public function __get(string $name)
    {
        if ($name === 'hash') {
            if ($this->url->fragment === null || $this->url->fragment === '') {
                return '';
            }

            return '#' . $this->url->fragment;
        }

        if ($name === 'host') {
            if ($this->url->host->isNull()) {
                return '';
            }

            $serializer = $this->url->host->getSerializer();

            if ($this->url->port === null) {
                return $serializer->toFormattedString();
            }

            return $serializer->toFormattedString() . ':' . $this->url->port;
        }

        if ($name === 'hostname') {
            if ($this->url->host->isNull()) {
                return '';
            }

            return $this->url->host->getSerializer()->toFormattedString();
        }

        if ($name === 'href') {
            return $this->url->serializeURL();
        }

        if ($name === 'origin') {
            return (string) $this->url->getOrigin();
        }

        if ($name === 'password') {
            return $this->url->password;
        }

        if ($name === 'pathname') {
            if ($this->url->cannotBeABaseUrl) {
                return (string) $this->url->path->first();
            }

            if ($this->url->path->isEmpty()) {
                return '';
            }

            return '/' . $this->url->path;
        }

        if ($name === 'port') {
            if ($this->url->port === null) {
                return '';
            }

            return (string) $this->url->port;
        }

        if ($name === 'protocol') {
            return $this->url->scheme . ':';
        }

        if ($name === 'search') {
            if ($this->url->query === null || $this->url->query === '') {
                return '';
            }

            return '?' . $this->url->query;
        }

        if ($name === 'searchParams') {
            return $this->queryObject;
        }

        if ($name === 'username') {
            return $this->url->username;
        }

        throw new InvalidArgumentException($name . ' is not a valid property.');
    }

    /**
     * @throws \InvalidArgumentException       When an invalid $name or $value value is passed.
     * @throws \Rowbot\URL\Exception\TypeError Only when trying to set URL::$searchParams
     */
    public function __set(string $name, string $value): void
    {
        if ($name === 'searchParams') {
            throw new TypeError();
        }

        $input = new IDLString($value);
        $parser = new BasicURLParser();

        if ($name === 'hash') {
            if ($input->isEmpty()) {
                $this->url->fragment = null;

                // Terminate these steps
                return;
            }

            if ($input->startsWith('#')) {
                $input = $input->substr(1);
            }

            $this->url->fragment = '';
            $parser->parse($input, null, null, $this->url, new FragmentState());
        } elseif ($name === 'host') {
            if ($this->url->cannotBeABaseUrl) {
                // Terminate these steps
                return;
            }

            $parser->parse($input, null, null, $this->url, new HostState());
        } elseif ($name === 'hostname') {
            if ($this->url->cannotBeABaseUrl) {
                // Terminate these steps
                return;
            }

            $parser->parse($input, null, null, $this->url, new HostnameState());
        } elseif ($name === 'href') {
            $parsedURL = $parser->parse($input);

            if ($parsedURL === false) {
                throw new TypeError($value . ' is not a valid URL.');
            }

            $this->url = $parsedURL;
            $this->queryObject->setUrl($this->url);

            if ($this->url->query === null) {
                return;
            }

            $this->queryObject->setList(QueryList::fromString($this->url->query));
        } elseif ($name === 'password') {
            if ($this->url->cannotHaveUsernamePasswordPort()) {
                return;
            }

            $this->setUrlPassword($input);
        } elseif ($name === 'pathname') {
            if ($this->url->cannotBeABaseUrl) {
                // Terminate these steps
                return;
            }

            $this->url->path = new PathList();
            $parser->parse($input, null, null, $this->url, new PathStartState());
        } elseif ($name === 'port') {
            if ($this->url->cannotHaveUsernamePasswordPort()) {
                return;
            }

            if ($value === '') {
                $this->url->port = null;

                return;
            }

            $parser->parse($input, null, null, $this->url, new PortState());
        } elseif ($name === 'protocol') {
            $parser->parse($input->append(':'), null, null, $this->url, new SchemeStartState());
        } elseif ($name === 'search') {
            if ($value === '') {
                $this->url->query = null;
                $this->queryObject->setList(new QueryList());

                return;
            }

            if ($input->startsWith('?')) {
                $input = $input->substr(1);
            }

            $this->url->query = '';
            $parser->parse($input, null, null, $this->url, new QueryState());
            $this->queryObject->setList(QueryList::fromString((string) $input));
        } elseif ($name === 'username') {
            if ($this->url->cannotHaveUsernamePasswordPort()) {
                return;
            }

            $this->setUrlUsername($input);
        } else {
            throw new InvalidArgumentException($name . ' is not a valid property.');
        }
    }

    /**
     * @see https://url.spec.whatwg.org/#set-the-password
     */
    private function setUrlPassword(USVStringInterface $input): void
    {
        $this->url->password = '';

        foreach ($input as $codePoint) {
            $this->url->password .= CodePoint::utf8PercentEncode(
                $codePoint,
                CodePoint::USERINFO_PERCENT_ENCODE_SET
            );
        }
    }

    /**
     * @see https://url.spec.whatwg.org/#set-the-username
     */
    private function setUrlUsername(USVStringInterface $input): void
    {
        $this->url->username = '';

        foreach ($input as $codePoint) {
            $this->url->username .= CodePoint::utf8PercentEncode(
                $codePoint,
                CodePoint::USERINFO_PERCENT_ENCODE_SET
            );
        }
    }

    public function __toString(): string
    {
        return $this->url->serializeURL();
    }

    public function toString(): string
    {
        return $this->url->serializeURL();
    }

    /**
     * Returns a JSON encoded string without escaping forward slashes. If you
     * need forward slashes to be escaped, pass the URL object to json_encode()
     * instead of calling this method.
     *
     * @see https://url.spec.whatwg.org/#dom-url-tojson
     */
    public function toJSON(): string
    {
        // Use JSON_UNESCAPED_SLASHES here since JavaScript's JSON.stringify()
        // method does not escape forward slashes by default.
        $result = json_encode($this->url->serializeURL(), JSON_UNESCAPED_SLASHES);

        if ($result === false || json_last_error() !== JSON_ERROR_NONE) {
            throw new JsonException(json_last_error_msg(), json_last_error());
        }

        return $result;
    }

    /**
     * Returns the serialized URL for consumption by json_encode(). To match
     * JavaScript's behavior, you should pass the JSON_UNESCAPED_SLASHES option
     * to json_encode().
     */
    public function jsonSerialize(): string
    {
        return $this->url->serializeURL();
    }
}
