#!/usr/bin/env perl

use strict;
use warnings;
use Test2::V0;
use IO::Async::Loop;
use Net::Async::HTTP;
use Future::AsyncAwait;
use FindBin;
use Digest::MD5 'md5_hex';
use File::Temp;

use lib "$FindBin::Bin/../../lib";
use PAGI::Server;

# PAGI::App::File is in the sibling PAGI-Tools distribution. Skip when it is not
# installed (file-serving over the server is also covered by t/42-file-response.t).
BEGIN {
    eval { require PAGI::Tools; PAGI::Tools->VERSION(0.002000); require PAGI::App::File; 1 }
        or plan(skip_all => 'PAGI-Tools 0.002000+ (PAGI::App::File) not installed');
}

# =============================================================================
# Tests for PAGI::App::File - Static file serving
# =============================================================================

my $loop = IO::Async::Loop->new;
my $static_dir = "$FindBin::Bin/fixtures/static";

# Helper to create server with App::File
sub create_server {
    my (%opts) = @_;

    my $app = PAGI::App::File->new(
        root => $opts{root} // $static_dir,
        %{$opts{app_opts} // {}},
    )->to_app;

    my $server = PAGI::Server->new(
        app   => $app,
        host  => '127.0.0.1',
        port  => 0,
        quiet => 1,
    );

    $loop->add($server);
    $server->listen->get;

    return $server;
}

# Helper to make HTTP request
async sub http_get {
    my ($port, $path, %headers) = @_;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

    my @header_list;
    while (my ($k, $v) = each %headers) {
        push @header_list, $k, $v;
    }

    my $response = await $http->GET(
        "http://127.0.0.1:$port$path",
        headers => \@header_list,
    );

    $loop->remove($http);
    return $response;
}

# =============================================================================
# Test: Index file resolution
# =============================================================================
subtest 'Index file resolution' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $response = http_get($port, '/')->get;

    is($response->code, 200, 'GET / returns 200');
    is($response->content_type, 'text/html', 'Content-Type is text/html');
    like($response->decoded_content, qr/PAGI::App::File/, 'Contains expected content');

    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: Serve plain text file
# =============================================================================
subtest 'Serve plain text file' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $response = http_get($port, '/test.txt')->get;

    is($response->code, 200, 'GET /test.txt returns 200');
    is($response->content_type, 'text/plain', 'Content-Type is text/plain');
    like($response->decoded_content, qr/Hello from PAGI/, 'Contains expected content');
    ok($response->header('Content-Length'), 'Has Content-Length header');
    ok($response->header('ETag'), 'Has ETag header');

    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: Serve JSON file
# =============================================================================
subtest 'Serve JSON file' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $response = http_get($port, '/data.json')->get;

    is($response->code, 200, 'GET /data.json returns 200');
    is($response->content_type, 'application/json', 'Content-Type is application/json');
    like($response->decoded_content, qr/"name"/, 'Contains JSON content');

    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: Serve CSS file
# =============================================================================
subtest 'Serve CSS file' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $response = http_get($port, '/style.css')->get;

    is($response->code, 200, 'GET /style.css returns 200');
    is($response->content_type, 'text/css', 'Content-Type is text/css');
    like($response->decoded_content, qr/font-family/, 'Contains CSS content');

    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: Serve nested file
# =============================================================================
subtest 'Serve nested file in subdirectory' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $response = http_get($port, '/subdir/nested.txt')->get;

    is($response->code, 200, 'GET /subdir/nested.txt returns 200');
    is($response->content_type, 'text/plain', 'Content-Type is text/plain');
    like($response->decoded_content, qr/subdirectory/, 'Contains expected content');

    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: 404 for missing file
# =============================================================================
subtest '404 for missing file' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new(fail_on_error => 0);
    $loop->add($http);

    my $response = $http->GET("http://127.0.0.1:$port/nonexistent.txt")->get;

    is($response->code, 404, 'GET /nonexistent.txt returns 404');

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: Path traversal protection
# =============================================================================
subtest 'Path traversal protection' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new(fail_on_error => 0);
    $loop->add($http);

    my $response = $http->GET("http://127.0.0.1:$port/../../../etc/passwd")->get;

    is($response->code, 403, 'Path traversal blocked with 403');

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: ETag caching (304 Not Modified)
# =============================================================================
subtest 'ETag caching returns 304' => sub {
    my $server = create_server();
    my $port = $server->port;

    # First request to get ETag
    my $http1 = Net::Async::HTTP->new;
    $loop->add($http1);

    my $response1 = $http1->GET("http://127.0.0.1:$port/test.txt")->get;
    is($response1->code, 200, 'First request returns 200');

    my $etag = $response1->header('ETag');
    ok($etag, 'Has ETag header');

    $loop->remove($http1);

    # Second request with If-None-Match
    # Net::Async::HTTP has issues with 304 responses, so we catch the error
    # and verify the server logged a 304 by checking the response before error
    my $http2 = Net::Async::HTTP->new;
    $loop->add($http2);

    my $got_304 = 0;
    eval {
        my $response2 = $http2->GET(
            "http://127.0.0.1:$port/test.txt",
            headers => ['If-None-Match' => $etag],
        )->get;
        $got_304 = 1 if $response2->code == 304;
    };
    # Net::Async::HTTP throws on 304 with "Spurious on_read" but server did return 304
    # (we can see it in the access log). Accept either a clean 304 or the known error.
    if ($@ && $@ =~ /Spurious on_read/) {
        $got_304 = 1;  # Server returned 304, client just has a bug handling it
    }
    ok($got_304, 'Second request with matching ETag returns 304 (or known client bug)');

    $loop->remove($http2);
    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: HEAD request
# =============================================================================
subtest 'HEAD request returns headers only' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

    my $response = $http->HEAD("http://127.0.0.1:$port/test.txt")->get;

    is($response->code, 200, 'HEAD returns 200');
    ok($response->header('Content-Length'), 'Has Content-Length');
    ok($response->header('ETag'), 'Has ETag');
    is($response->content, '', 'Body is empty');

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: Range request (partial content)
# =============================================================================
subtest 'Range request returns partial content' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

    my $response = $http->GET(
        "http://127.0.0.1:$port/test.txt",
        headers => ['Range' => 'bytes=0-4'],
    )->get;

    is($response->code, 206, 'Range request returns 206 Partial Content');
    is($response->content, 'Hello', 'Returns first 5 bytes');
    like($response->header('Content-Range'), qr/bytes 0-4\/\d+/, 'Has Content-Range header');

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: Method not allowed
# =============================================================================
subtest 'POST returns 405 Method Not Allowed' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new(fail_on_error => 0);
    $loop->add($http);

    my $response = $http->POST("http://127.0.0.1:$port/test.txt", '', content_type => 'text/plain')->get;

    is($response->code, 405, 'POST returns 405');

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Security Tests - Based on Plack::App::File security patterns
# =============================================================================

subtest 'Security: null byte injection blocked' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new(fail_on_error => 0);
    $loop->add($http);

    # Try null byte injection (attempting to bypass extension checks)
    # Note: We need to send this raw because HTTP clients may reject null bytes
    # This tests that even if a null byte reaches the server, it's blocked
    my $response = $http->GET("http://127.0.0.1:$port/test.txt%00.jpg")->get;

    # Should be blocked - either 400 Bad Request or 403 Forbidden or 404
    ok($response->code >= 400, "Null byte in path blocked (status: " . $response->code . ")");

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

subtest 'Security: double-dot traversal blocked' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new(fail_on_error => 0);
    $loop->add($http);

    # Various double-dot traversal attempts
    my @traversal_paths = (
        '/../../../etc/passwd',
        '/subdir/../../../etc/passwd',
        '/subdir/../../etc/passwd',
        '/%2e%2e/%2e%2e/etc/passwd',  # URL-encoded dots
    );

    for my $path (@traversal_paths) {
        my $response = $http->GET("http://127.0.0.1:$port$path")->get;
        ok($response->code >= 400, "Traversal blocked for $path (status: " . $response->code . ")");
        unlike($response->content // '', qr/root:/, "Did not expose /etc/passwd for $path");
    }

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

subtest 'Security: triple-dot and beyond blocked' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new(fail_on_error => 0);
    $loop->add($http);

    # Triple dots and more should be blocked
    my @paths = (
        '/test/.../foo',
        '/..../etc/passwd',
        '/foo/...../bar',
    );

    for my $path (@paths) {
        my $response = $http->GET("http://127.0.0.1:$port$path")->get;
        ok($response->code >= 400, "Multi-dot component blocked for $path (status: " . $response->code . ")");
    }

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

subtest 'Security: backslash traversal blocked' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new(fail_on_error => 0);
    $loop->add($http);

    # Backslash traversal (Windows-style, but should be blocked on all platforms)
    my @paths = (
        '/..\\..\\etc\\passwd',
        '/foo\\..\\..\\etc\\passwd',
        '/subdir\\..\\..\\..\\etc\\passwd',
    );

    for my $path (@paths) {
        my $response = $http->GET("http://127.0.0.1:$port$path")->get;
        ok($response->code >= 400, "Backslash traversal blocked for $path (status: " . $response->code . ")");
    }

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

subtest 'Security: hidden files (dotfiles) blocked' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new(fail_on_error => 0);
    $loop->add($http);

    # Hidden file access should be blocked
    my @paths = (
        '/.htaccess',
        '/.env',
        '/.git/config',
        '/subdir/.hidden',
    );

    for my $path (@paths) {
        my $response = $http->GET("http://127.0.0.1:$port$path")->get;
        ok($response->code >= 400, "Hidden file blocked for $path (status: " . $response->code . ")");
    }

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

subtest 'Security: symlink escape prevented' => sub {
    # This test creates a symlink outside the root and verifies it's blocked
    # Skip if we can't create symlinks (Windows without admin, etc.)
    my $test_dir = File::Temp::tempdir(CLEANUP => 1);
    my $root_dir = "$test_dir/root";
    my $outside_dir = "$test_dir/outside";

    mkdir $root_dir or die "Cannot create root dir: $!";
    mkdir $outside_dir or die "Cannot create outside dir: $!";

    # Create a file outside the root
    open my $fh, '>', "$outside_dir/secret.txt" or die;
    print $fh "SECRET DATA";
    close $fh;

    # Create a symlink from inside root to outside
    my $symlink_created = eval { symlink("$outside_dir/secret.txt", "$root_dir/escape") };

    skip_all("Cannot create symlinks on this system") unless $symlink_created;

    my $server = create_server(root => $root_dir);
    my $port = $server->port;

    my $http = Net::Async::HTTP->new(fail_on_error => 0);
    $loop->add($http);

    my $response = $http->GET("http://127.0.0.1:$port/escape")->get;

    # Should either 403 (blocked) or 404 (symlink not followed)
    ok($response->code >= 400, "Symlink escape blocked (status: " . $response->code . ")");
    unlike($response->content // '', qr/SECRET DATA/, "Did not expose file outside root");

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

subtest 'Security: URL-encoded traversal blocked' => sub {
    my $server = create_server();
    my $port = $server->port;

    my $http = Net::Async::HTTP->new(fail_on_error => 0);
    $loop->add($http);

    # URL-encoded traversal attempts
    my @paths = (
        '/%2e%2e/%2e%2e/%2e%2e/etc/passwd',        # ..
        '/%252e%252e/%252e%252e/etc/passwd',      # double-encoded
        '/..%252f..%252f..%252fetc/passwd',       # mixed
    );

    for my $path (@paths) {
        my $response = $http->GET("http://127.0.0.1:$port$path")->get;
        ok($response->code >= 400, "URL-encoded traversal blocked for $path (status: " . $response->code . ")");
    }

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: Large file streaming (verifies file response integration)
# =============================================================================
subtest 'Large file streaming' => sub {
    # Create a temp directory with a large file
    # Size is 65KB - just over the 64KB sync_file_threshold to trigger async path
    # Kept small to minimize buffer pressure on constrained CI environments (e.g., FreeBSD)
    my $test_dir = File::Temp::tempdir(CLEANUP => 1);
    my $large_content = "X" x (65 * 1024);  # 65KB file (exceeds 64KB threshold)
    open my $fh, '>', "$test_dir/large.bin" or die;
    print $fh $large_content;
    close $fh;

    my $server = create_server(root => $test_dir);
    my $port = $server->port;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

    my $response = $http->GET("http://127.0.0.1:$port/large.bin")->get;

    is($response->code, 200, 'GET /large.bin returns 200');
    is(length($response->content), length($large_content), 'Large file length matches');
    is($response->content, $large_content, 'Large file content matches');

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: Large file Range request streaming
# =============================================================================
subtest 'Large file Range request' => sub {
    # Size is 65KB - just over the 64KB sync_file_threshold to trigger async path
    my $test_dir = File::Temp::tempdir(CLEANUP => 1);
    my $large_content = "X" x (65 * 1024);  # 65KB file (exceeds 64KB threshold)
    open my $fh, '>', "$test_dir/large.bin" or die;
    print $fh $large_content;
    close $fh;

    my $server = create_server(root => $test_dir);
    my $port = $server->port;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

    # Request bytes 1000-2000
    my $response = $http->GET(
        "http://127.0.0.1:$port/large.bin",
        headers => ['Range' => 'bytes=1000-2000'],
    )->get;

    is($response->code, 206, 'Range request returns 206');
    is(length($response->content), 1001, 'Partial content length correct');
    is($response->content, substr($large_content, 1000, 1001), 'Partial content matches');
    like($response->header('Content-Range'), qr/bytes 1000-2000\/\d+/, 'Has Content-Range header');

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: handle_ranges => 0 ignores Range header
# =============================================================================
subtest 'handle_ranges => 0 ignores Range header' => sub {
    my $server = create_server(app_opts => { handle_ranges => 0 });
    my $port = $server->port;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

    # Send Range header, but should get full file (200) not partial (206)
    my $response = $http->GET(
        "http://127.0.0.1:$port/test.txt",
        headers => ['Range' => 'bytes=0-4'],
    )->get;

    is($response->code, 200, 'With handle_ranges => 0, Range request returns 200 (not 206)');
    ok(!$response->header('Content-Range'), 'No Content-Range header');
    like($response->decoded_content, qr/Hello from PAGI/, 'Full file content returned');

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

# =============================================================================
# Test: handle_ranges => 1 (default) honors Range header
# =============================================================================
subtest 'handle_ranges => 1 (default) honors Range header' => sub {
    my $server = create_server(app_opts => { handle_ranges => 1 });
    my $port = $server->port;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

    my $response = $http->GET(
        "http://127.0.0.1:$port/test.txt",
        headers => ['Range' => 'bytes=0-4'],
    )->get;

    is($response->code, 206, 'With handle_ranges => 1, Range request returns 206');
    is($response->content, 'Hello', 'Returns first 5 bytes');
    like($response->header('Content-Range'), qr/bytes 0-4\/\d+/, 'Has Content-Range header');

    $loop->remove($http);
    $server->shutdown->get;
    $loop->remove($server);
};

done_testing;
