package PAGI::Endpoint::WebSocket;

use strict;
use warnings;

use Future::AsyncAwait;
use Carp qw(croak);
use Module::Load qw(load);
use Scalar::Util qw(weaken);
use JSON::MaybeXS ();


# Factory class method - override in subclass for customization
sub websocket_class { 'PAGI::WebSocket' }

# Encoding: 'text', 'bytes', or 'json'
sub encoding { 'text' }

# Ping interval in seconds (0 = disabled)
# Override in subclass to enable server-side keepalive
sub ping_interval { 0 }

sub to_app {
    my ($class) = @_;
    my $ws_class = $class->websocket_class;
    load($ws_class);

    return async sub {
        my ($scope, $receive, $send) = @_;

        my $type = $scope->{type} // '';
        croak "Expected websocket scope, got '$type'" unless $type eq 'websocket';

        my $endpoint = $class->new;
        my $ws = $ws_class->new($scope, $receive, $send);

        await $endpoint->handle($ws, $scope, $send);
    };
}

sub new {
    my ($class, %args) = @_;
    return bless \%args, $class;
}

async sub handle {
    my ($self, $ws, $scope, $send) = @_;

    # Call on_connect if defined
    if ($self->can('on_connect')) {
        await $self->on_connect($ws);
    } else {
        # Default: accept the connection
        await $ws->accept;
    }

    # Register disconnect callback
    my $ping_timer;
    my $connected = 1;

    if ($self->can('on_disconnect')) {
        $ws->on_close(sub {
            my ($code, $reason) = @_;
            $connected = 0;
            $self->on_disconnect($ws, $code, $reason);
        });
    } else {
        $ws->on_close(sub {
            $connected = 0;
        });
    }

    # Set up ping timer if configured
    my $ping_interval = $self->ping_interval;
    if ($ping_interval > 0) {
        require IO::Async::Loop;
        require IO::Async::Timer::Periodic;

        my $loop = IO::Async::Loop->new;  # Singleton

        my $weak_send = $send;
        weaken($weak_send);

        $ping_timer = IO::Async::Timer::Periodic->new(
            interval => $ping_interval,
            on_tick  => sub {
                return unless $connected && $weak_send;
                eval {
                    $weak_send->({
                        type => 'websocket.send',
                        text => JSON::MaybeXS::encode_json({ type => 'ping', ts => time() }),
                    });
                };
            },
        );
        $loop->add($ping_timer);
        $ping_timer->start;
    }

    # Handle messages based on encoding
    eval {
        if ($self->can('on_receive')) {
            my $encoding = $self->encoding;

            if ($encoding eq 'json') {
                await $ws->each_json(async sub {
                    my ($data) = @_;
                    await $self->on_receive($ws, $data);
                });
            } elsif ($encoding eq 'bytes') {
                await $ws->each_bytes(async sub {
                    my ($data) = @_;
                    await $self->on_receive($ws, $data);
                });
            } else {
                # Default: text
                await $ws->each_text(async sub {
                    my ($data) = @_;
                    await $self->on_receive($ws, $data);
                });
            }
        } else {
            # No on_receive, just wait for disconnect
            await $ws->run;
        }
    };
    my $error = $@;

    # Cleanup ping timer
    if ($ping_timer) {
        $ping_timer->stop;
        if (my $loop = $scope->{pagi}{loop}) {
            $loop->remove($ping_timer);
        }
    }

    die $error if $error;
}

1;

__END__

=head1 NAME

PAGI::Endpoint::WebSocket - Class-based WebSocket endpoint handler

=head1 SYNOPSIS

    package MyApp::Chat;
    use parent 'PAGI::Endpoint::WebSocket';
    use Future::AsyncAwait;

    sub encoding { 'json' }  # or 'text', 'bytes'
    sub ping_interval { 25 } # Send ping every 25 seconds

    async sub on_connect {
        my ($self, $ws) = @_;
        await $ws->accept;
        await $ws->send_json({ type => 'welcome' });
    }

    async sub on_receive {
        my ($self, $ws, $data) = @_;
        # $data is auto-decoded from JSON (per encoding above)
        # For sending, explicitly choose: send_json, send_text, send_bytes
        await $ws->send_json({ type => 'echo', message => $data });
    }

    sub on_disconnect {
        my ($self, $ws, $code) = @_;
        cleanup_user($ws->stash->{user_id});
    }

    # Use with PAGI server
    my $app = MyApp::Chat->to_app;

=head1 DESCRIPTION

PAGI::Endpoint::WebSocket provides a Starlette-inspired class-based
approach to handling WebSocket connections with lifecycle hooks.

=head2 Connection Keepalive

WebSocket connections may be closed by proxies, load balancers, or NAT
devices after periods of inactivity (typically 30-60 seconds). To prevent
this, enable server-side ping by overriding C<ping_interval>:

    sub ping_interval { 25 }  # Send ping every 25 seconds

The server sends JSON messages C<< { type => 'ping', ts => <timestamp> } >>
at the specified interval. Clients can optionally respond with
C<< { type => 'pong' } >> to confirm receipt, though this is not required
for keepalive purposes.

=head1 LIFECYCLE METHODS

=head2 on_connect

    async sub on_connect {
        my ($self, $ws) = @_;
        await $ws->accept;
    }

Called when a client connects. You should call C<< $ws->accept >>
to accept the connection. If not defined, connection is auto-accepted.

=head2 on_receive

    async sub on_receive {
        my ($self, $ws, $data) = @_;
        await $ws->send_text("Got: $data");
    }

Called for each message received. The C<$data> format depends on
the C<encoding()> setting.

=head2 on_disconnect

    sub on_disconnect {
        my ($self, $ws, $code, $reason) = @_;
        # Cleanup
    }

Called when connection closes. This is synchronous (not async).

=head1 CLASS METHODS

=head2 encoding

    sub encoding { 'json' }  # 'text', 'bytes', or 'json'

Controls how B<incoming> messages are decoded before being passed to
C<on_receive>. This does B<not> affect outgoing messages - you always
explicitly choose the send method (C<send_json>, C<send_text>, C<send_bytes>).

=over 4

=item C<text> - Messages passed as strings (default)

=item C<bytes> - Messages passed as raw bytes

=item C<json> - Messages automatically decoded from JSON to Perl data structures

=back

B<Example - JSON encoding:>

    package MyEndpoint;
    use parent 'PAGI::Endpoint::WebSocket';

    sub encoding { 'json' }  # Incoming messages auto-decoded from JSON

    async sub on_receive {
        my ($self, $ws, $data) = @_;
        # $data is already a Perl hashref/arrayref (decoded from JSON)
        my $name = $data->{name};

        # For sending, you still explicitly choose the method:
        await $ws->send_json({ greeting => "Hello, $name" });
        await $ws->send_text("Raw text message");
    }

B<Example - Text encoding:>

    sub encoding { 'text' }  # Incoming messages as raw strings

    async sub on_receive {
        my ($self, $ws, $text) = @_;
        # $text is a plain string, decode JSON yourself if needed
        my $data = JSON::MaybeXS::decode_json($text);
        await $ws->send_text("Echo: $text");
    }

This follows the same pattern as L<Starlette's WebSocketEndpoint|https://www.starlette.io/endpoints/>.

=head2 ping_interval

    sub ping_interval { 25 }  # seconds, 0 = disabled (default)

Seconds between server-initiated ping messages. Set to a positive value
to enable keepalive pings that prevent proxy/NAT timeouts on idle
connections.

When enabled, the server sends JSON messages at the specified interval:

    { "type": "ping", "ts": 1703275200 }

Common values:

=over 4

=item C<25> - Safe for most proxies (30s timeout common)

=item C<55> - Safe for aggressive proxies (60s timeout)

=item C<0> - Disabled (default) - connection may timeout if idle

=back

B<Note:> Requires the PAGI event loop to be available in the scope
(automatically provided by PAGI::Server).

=head2 websocket_class

    sub websocket_class { 'PAGI::WebSocket' }

Override to use a custom WebSocket wrapper.

=head2 to_app

    my $app = MyEndpoint->to_app;

Returns a PAGI-compatible async coderef.

=head1 SEE ALSO

L<PAGI::WebSocket>, L<PAGI::Endpoint::HTTP>, L<PAGI::Endpoint::SSE>

=cut
