<?php

declare(strict_types=1);

/*
 * Copyright (c) 2017-2022 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\SeCookie;

class Cookie
{
    public const NO_SAME_SITE_POSTFIX = '_NOSS';
    private CookieOptions $cookieOptions;

    public function __construct(?CookieOptions $cookieOptions = null)
    {
        $this->cookieOptions = $cookieOptions ?? new CookieOptions();
    }

    public function delete(string $cookieName): void
    {
        $this->set($cookieName, '');
    }

    public function set(string $cookieName, string $cookieValue): void
    {
        $this->sendHeader(
            sprintf(
                'Set-Cookie: %s=%s; %s',
                $cookieName,
                $cookieValue,
                implode('; ', $this->cookieOptions->attributeValueList('' === $cookieValue, false))
            )
        );

        // after Chrome moves to SameSite=Lax by default, we have to explicitly
        // specify SameSite=None if we do NOT want Lax behavior. However, this
        // breaks some old(er) browsers as they interprete SameSite=None as
        // SameSite=Strict. For those we have to send a version without any
        // SameSite attribute.
        //
        // The approach that does *not* involve browser user agent sniffing
        // requires sending two cookies, one with SameSite=None and one without
        // any SameSite attribute...
        //
        // @see https://www.chromium.org/updates/same-site
        // @see https://web.dev/samesite-cookie-recipes/#handling-incompatible-clients
        if ('None' === $this->cookieOptions->getSameSite()) {
            $this->sendHeader(
                sprintf(
                    'Set-Cookie: %s%s=%s; %s',
                    $cookieName,
                    self::NO_SAME_SITE_POSTFIX,
                    $cookieValue,
                    implode('; ', $this->cookieOptions->attributeValueList('' === $cookieValue, true))
                )
            );
        }
    }

    public function get(string $cookieName): ?string
    {
        if (null === $cookieValue = $this->readCookie($cookieName)) {
            // check the cookie with the NO_SAME_SITE_POSTFIX *only* if the
            // current CookieOptions include SameSite=None
            if ('None' === $this->cookieOptions->getSameSite()) {
                return $this->readCookie($cookieName . self::NO_SAME_SITE_POSTFIX);
            }

            return null;
        }

        return $cookieValue;
    }

    protected function sendHeader(string $headerKeyValue): void
    {
        // keep existing headers with same name
        header($headerKeyValue, false);
    }

    protected function readCookie(string $cookieName): ?string
    {
        if (!\array_key_exists($cookieName, $_COOKIE)) {
            return null;
        }

        /** @var array<string>|string */
        $cookieValue = $_COOKIE[$cookieName];
        if (!\is_string($cookieValue)) {
            return null;
        }

        return $cookieValue;
    }
}
