<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Tests\Http;

use App\Http\Response;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
 * R01-N20: pin the redirect contract.
 *
 * `Response::redirect` is path-only. `Response::external` is the explicit
 * cross-origin helper. Both reject CR/LF (header injection) and protocol-
 * relative shapes (`//host/path`) that look like paths but leave the
 * origin.
 */
final class ResponseTest extends TestCase
{
    /** @return list<array{0:string,1:string}> */
    public static function pathOnlyAccepted(): array
    {
        return [
            ['/',                        'root'],
            ['/foo',                     'simple path'],
            ['/users?error=db',          'path with query string'],
            ['/sprints/1#tab=tasks',     'path with fragment'],
            ['/users?flash=demoted',     'flash query'],
            ['/sprints/import/abc-123',  'token-style segment'],
        ];
    }

    #[DataProvider('pathOnlyAccepted')]
    public function testRedirectAcceptsPathOnlyLocations(string $location, string $label): void
    {
        $resp = Response::redirect($location);
        self::assertSame(302, $resp->status, $label);
        self::assertSame($location, $resp->headers['Location'], $label);
    }

    public function testRedirectAcceptsCustomStatus(): void
    {
        $resp = Response::redirect('/foo', 303);
        self::assertSame(303, $resp->status);
    }

    /** @return list<array{0:string,1:string}> */
    public static function nonPathLocations(): array
    {
        return [
            ['',                                  'empty string'],
            ['foo',                               'relative path'],
            ['foo/bar',                           'multi-segment relative'],
            ['?next=/x',                          'query-only (relative)'],
            ['#anchor',                           'fragment-only'],
            ['//evil.example.com/x',              'protocol-relative URL → off-origin redirect'],
            ['///evil.example.com/x',             'triple-slash variant'],
            ['https://evil.example.com/x',        'absolute https URL'],
            ['http://evil.example.com/',          'absolute http URL'],
            ['javascript:alert(1)',               'javascript: scheme'],
            ['data:text/html,<script>',           'data: scheme'],
            ["/foo\r\nX-Injected: yes",           'CR/LF — header injection'],
            ["/foo\nLocation: https://evil/",     'LF — header injection'],
            ["/foo\0bar",                         'null byte'],
        ];
    }

    #[DataProvider('nonPathLocations')]
    public function testRedirectRejectsNonPathLocations(string $location, string $label): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('path-only');
        Response::redirect($location);
    }

    /** @return list<array{0:string,1:string}> */
    public static function externalAccepted(): array
    {
        return [
            ['https://app.example.com/',                            'https + path'],
            ['https://app.example.com/sprints/1?tab=foo',           'https + query'],
            ['http://localhost:8080/healthz',                       'http on localhost with port'],
            ['HTTPS://APP.EXAMPLE.COM/x',                           'uppercase scheme is normalised at compare'],
        ];
    }

    #[DataProvider('externalAccepted')]
    public function testExternalAcceptsHttpAndHttpsUrls(string $url, string $label): void
    {
        $resp = Response::external($url, 308);
        self::assertSame(308, $resp->status, $label);
        self::assertSame($url, $resp->headers['Location'], $label);
    }

    /** @return list<array{0:string,1:string}> */
    public static function externalRejected(): array
    {
        return [
            ['',                                'empty'],
            ['/foo',                            'path-only — must use redirect()'],
            ['//evil.example.com/x',            'protocol-relative'],
            ['javascript:alert(1)',             'javascript: scheme'],
            ['file:///etc/passwd',              'file: scheme'],
            ['ftp://files.example.com/x',       'ftp: scheme'],
            ['data:text/html,<x>',              'data: scheme'],
            ['https://',                        'no host'],
            ['just text',                       'unparseable'],
            ["https://evil.example.com/\r\n",   'CR/LF in URL'],
        ];
    }

    #[DataProvider('externalRejected')]
    public function testExternalRejectsNonHttpSchemesAndMalformed(string $url, string $label): void
    {
        $this->expectException(InvalidArgumentException::class);
        Response::external($url);
    }

    public function testIsPathOnlyHelperMatchesRedirectContract(): void
    {
        // Drift fence: if these helpers ever diverge from what redirect()
        // / external() accept, the test above will fail too — but pin the
        // pure helper independently so refactors are caught instantly.
        self::assertTrue(Response::isPathOnly('/'));
        self::assertTrue(Response::isPathOnly('/users'));
        self::assertFalse(Response::isPathOnly('//evil'));
        self::assertFalse(Response::isPathOnly(''));
        self::assertFalse(Response::isPathOnly('users'));
        self::assertFalse(Response::isPathOnly("/x\nfoo"));

        self::assertTrue(Response::isExternalUrl('https://x.example/'));
        self::assertTrue(Response::isExternalUrl('http://x.example/'));
        self::assertFalse(Response::isExternalUrl('/foo'));
        self::assertFalse(Response::isExternalUrl('javascript:x'));
        self::assertFalse(Response::isExternalUrl('//x.example/'));
    }
}
