<?php

declare(strict_types=1);

/*
 * Copyright (c) 2017-2023 François Kooman <fkooman@tuxed.net>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

namespace fkooman\OAuth\Server;

class ClientInfo
{
    private string $clientId;

    /** @var array<string> */
    private array $redirectUriList;

    private ?string $clientSecret;

    private ?string $displayName;

    private bool $requiresApproval;

    private ?Scope $allowedScope;

    /**
     * @param array<string> $redirectUriList
     */
    public function __construct(string $clientId, array $redirectUriList, ?string $clientSecret, ?string $displayName, bool $requiresApproval, ?Scope $allowedScope = null)
    {
        $this->clientId = $clientId;
        $this->redirectUriList = $redirectUriList;
        $this->clientSecret = $clientSecret;
        $this->displayName = $displayName;
        $this->requiresApproval = $requiresApproval;
        $this->allowedScope = $allowedScope;
    }

    public function clientId(): string
    {
        return $this->clientId;
    }

    public function clientSecret(): ?string
    {
        return $this->clientSecret;
    }

    /**
     * Get the scope object with scopes this OAuth client is able to request.
     *
     * `null` is returned when all scopes are allowed.
     */
    public function allowedScope(): ?Scope
    {
        return $this->allowedScope;
    }

    /**
     * Get the "display name", or client ID if not set.
     */
    public function displayName(): string
    {
        return $this->displayName ?? $this->clientId;
    }

    public function requiresApproval(): bool
    {
        return $this->requiresApproval;
    }

    /**
     * @return array<string>
     */
    public function redirectUriList(): array
    {
        return $this->redirectUriList;
    }

    public function isValidRedirectUri(string $redirectUri): bool
    {
        if (\in_array($redirectUri, $this->redirectUriList, true)) {
            // exact string match, make sure the URL does not contain the
            // literal "{PORT}" we use for "native clients"
            // @see https://todo.sr.ht/~fkooman/php-oauth2-server/3
            if (false !== strpos($redirectUri, '{PORT}')) {
                return false;
            }

            return true;
        }

        // parsing is NOT great... but don't see how to avoid it here, we need
        // to accept all ports and both IPv4 and IPv6 for loopback entries
        foreach ($this->redirectUriList as $clientRedirectUri) {
            // IPv4 loopback
            if (0 === strpos($clientRedirectUri, 'http://127.0.0.1:{PORT}/')) {
                if (self::portMatch($clientRedirectUri, $redirectUri)) {
                    return true;
                }
            }

            // IPv6 loopback
            if (0 === strpos($clientRedirectUri, 'http://[::1]:{PORT}/')) {
                if (self::portMatch($clientRedirectUri, $redirectUri)) {
                    return true;
                }
            }
        }

        return false;
    }

    public static function fromData(array $clientData): self
    {
        // We take the field names from "OAuth 2.0 Dynamic Client Registration
        // Protocol" as much as possible.
        // @see https://www.rfc-editor.org/rfc/rfc7591

        if (null !== $allowedScope = Extractor::optionalString($clientData, 'scope')) {
            $allowedScope = new Scope($allowedScope);
        }

        return new self(
            Extractor::requireString($clientData, 'client_id'),
            Extractor::requireStringArray($clientData, 'redirect_uris'),
            Extractor::optionalString($clientData, 'client_secret'),
            Extractor::optionalString($clientData, 'client_name'),
            Extractor::optionalBool($clientData, 'requires_approval') ?? true,
            $allowedScope
        );
    }

    private static function portMatch(string $clientRedirectUri, string $redirectUri): bool
    {
        $uriPort = parse_url($redirectUri, PHP_URL_PORT);
        if (!\is_int($uriPort)) {
            return false;
        }

        // parse_url already makes sure that the port is between 0 and 65535,
        // i.e.: port >= 0 && port <= 65535, we make it a little more strict to
        // require >= 1024 which makes sure (on at least Linux) that user
        // processes can claim that port
        if ($uriPort < 1024) {
            return false;
        }
        $clientRedirectUriWithPort = str_replace('{PORT}', (string) $uriPort, $clientRedirectUri);

        return $redirectUri === $clientRedirectUriWithPort;
    }
}
