%% Copyright (c) 2026, Benoit Chesneau.
%% Licensed under the Apache License, Version 2.0.
%%
%% @doc WebTransport Public API
%%
%% This module provides the public API for WebTransport over HTTP/2 and HTTP/3.
%%
%% == Server Usage ==
%%
%% ```
%% %% Start a WebTransport listener
%% {ok, Listener} = webtransport:start_listener(my_listener, #{
%%     transport => h3,
%%     port => 8443,
%%     certfile => "cert.pem",
%%     keyfile => "key.pem",
%%     handler => my_wt_handler
%% }).
%%
%% %% Stop the listener
%% ok = webtransport:stop_listener(my_listener).
%% '''
%%
%% == Client Usage ==
%%
%% ```
%% %% Connect to a WebTransport server
%% {ok, Session} = webtransport:connect("example.com", 443, "/wt", #{
%%     transport => h3
%% }).
%%
%% %% Open a bidirectional stream
%% {ok, Stream} = webtransport:open_stream(Session, bidi).
%%
%% %% Send data
%% ok = webtransport:send(Session, Stream, <<"Hello">>).
%%
%% %% Send with FIN flag
%% ok = webtransport:send(Session, Stream, <<"World">>, fin).
%%
%% %% Send unreliable datagram
%% ok = webtransport:send_datagram(Session, <<"ping">>).
%%
%% %% Close stream
%% ok = webtransport:close_stream(Session, Stream).
%%
%% %% Close session
%% ok = webtransport:close_session(Session).
%% '''
%%
%% == Handler Behaviour ==
%%
%% See {@link webtransport_handler} for the callback behaviour.
%%
-module(webtransport).

%% Integration API (for embedding in a generic HTTP server)
-export([h3_settings/0, h3_settings/1]).
-export([h2_settings/0, h2_settings/1]).
-export([accept/4]).

%% Server API (convenience: standalone listener)
-export([start_listener/2, stop_listener/1]).
-export([listeners/0, listener_info/1, listener_sockname/1]).
-export([listener_loop/1]).

%% Client API
-export([connect/4, connect/5]).

%% Session API
-export([open_stream/2]).
-export([send/3, send/4]).
-export([send_datagram/2]).
-export([close_stream/2]).
-export([reset_stream/3, stop_sending/3]).
-export([drain_session/1]).
-export([close_session/1, close_session/2, close_session/3]).
-export([session_info/1]).
-export([early_data_accepted/1]).

-ifdef(TEST).
%% Exported for eunit: lets tests drive the h2 CONNECT-stream data loop with
%% fake connection/session pids to assert it self-terminates.
-export([h2_data_loop/3]).
-endif.

%% Types
-type session() :: pid().
-type stream() :: non_neg_integer().
-type listener_name() :: atom().
-type request() :: #{
    path := binary(),
    authority := binary(),
    headers => [{binary(), binary()}]
}.

-type listener_opts() :: #{
    transport := h2 | h3,
    port := inet:port_number(),
    certfile := file:filename(),
    keyfile := file:filename(),
    handler := module(),
    handler_opts => term(),
    max_data => non_neg_integer(),
    max_streams_bidi => non_neg_integer(),
    max_streams_uni => non_neg_integer(),
    %% Bind address / family. `ip' binds the listener socket to a specific
    %% address (IPv4 4-tuple or IPv6 8-tuple); `family' forces `inet' or
    %% `inet6' when no `ip' is given (e.g. the IPv6 wildcard). `socket_opts'
    %% are passed verbatim to the underlying UDP (h3) listener socket.
    ip => inet:ip_address(),
    family => inet | inet6,
    socket_opts => list(),
    %% Per-SNI certificate selection. Invoked per connection with the
    %% ClientHello SNI (RFC 6066 §3); the returned cert/key override the
    %% static `certfile'/`keyfile' for that handshake, letting one listener
    %% present different certificates per hostname. `cert' is a DER binary,
    %% `key' a decoded private-key term, `cert_chain' the DER intermediates.
    %% For h3 it is forwarded to `quic' (>= 1.6.5); for h2 it is adapted to
    %% an `ssl' `sni_fun'. When the callback returns `{error, _}' (or raises)
    %% the static cert is used for h2; for h3 the handshake fails.
    sni_callback => sni_callback(),
    %% `compat_mode' selects which WebTransport HTTP/3 draft the listener
    %% accepts. Default `auto' accepts draft-15 and draft-02 and picks the
    %% matching code path per CONNECT. Pin to `latest' to refuse draft-02
    %% clients or to `legacy_browser_compat' to refuse draft-15 clients.
    compat_mode => latest | legacy_browser_compat | auto
}.

-type sni_callback() :: fun(
    (ServerName :: binary() | undefined) ->
        {ok, #{cert := binary(), key := term(), cert_chain => [binary()]}}
        | {error, term()}
).

-type connect_opts() :: #{
    transport => h2 | h3,
    certfile => file:filename(),
    keyfile => file:filename(),
    cacertfile => file:filename(),
    verify => verify_none | verify_peer,
    headers => [{binary(), binary()}],
    timeout => timeout(),
    %% Address-family / dual-stack controls (h3 only), passed through to the
    %% QUIC client. `family' restricts resolution; `happy_eyeballs' toggles
    %% RFC 8305 v6/v4 racing; `connection_attempt_delay' is the stagger in ms.
    family => inet | inet6 | any,
    happy_eyeballs => boolean(),
    connection_attempt_delay => non_neg_integer(),
    %% 0-RTT: a session ticket captured from a prior connection
    %% (`{webtransport, session_ticket, Ticket}'). Opaque term, passed back to
    %% the QUIC layer as-is. h3 only.
    session_ticket => term(),
    %% Clients pick an explicit compat mode. Default `latest' sends
    %% draft-15 SETTINGS and `:protocol = webtransport-h3'. Use
    %% `legacy_browser_compat' only when talking to a known draft-02 peer
    %% (Chrome / Firefox / quic-go v0.9).
    compat_mode => latest | legacy_browser_compat
}.

-export_type([session/0, stream/0, request/0]).
-export_type([listener_name/0, listener_opts/0, connect_opts/0, sni_callback/0]).

-include("webtransport.hrl").

%% ============================================================================
%% Integration API
%% ============================================================================

%% @doc Return HTTP/3 settings to merge into a quic_h3 server configuration.
%%
%% Use this when embedding WebTransport into an existing HTTP/3 server
%% instead of using `start_listener/2'. The returned map contains:
%%
%% - `settings' -- H3 SETTINGS to advertise (wt_enabled, flow control, etc.)
%% - `stream_type_handler' -- claims WT extension streams (0x41 bidi, 0x54 uni)
%% - `h3_datagram_enabled' -- enables QUIC datagrams
%% - `quic_opts' -- QUIC transport params (max_datagram_frame_size, reset_stream_at)
%% - `connection_handler' -- per-connection setup (creates the WT stream router)
%%
%% Merge the result into your quic_h3 server opts:
%% ```
%% Opts = maps:merge(webtransport:h3_settings(), #{
%%     cert => CertDer, key => PrivateKey,
%%     handler => fun my_handler/5
%% }),
%% quic_h3:start_server(my_server, 443, Opts).
%% '''
-spec h3_settings() -> map().
h3_settings() ->
    h3_settings(#{}).

-spec h3_settings(map()) -> map().
h3_settings(Opts) ->
    CompatMode = maps:get(compat_mode, Opts, auto),
    Settings = case CompatMode of
        auto ->
            maps:merge(wt_h3:default_settings(latest),
                       wt_h3:default_settings(legacy_browser_compat));
        Mode ->
            wt_h3:default_settings(Mode)
    end,
    Claim = wt_stream_type_handler(),
    ConnectionHandler = fun(QuicConnPid) ->
        {ok, Router} = webtransport_h3_router:start(undefined),
        ensure_router_table(),
        %% Key the router by the QUIC connection pid. accept/4 derives the
        %% same key from its H3 connection via quic_h3:get_quic_conn/1, so
        %% it resolves this exact router no matter which process calls
        %% accept/4. quic_h3 runs request handlers in a spawned process and
        %% this callback runs in the listener process, so there is no shared
        %% process dictionary to rely on; the embedder may also dispatch
        %% accept/4 to its own per-request worker.
        ets:insert(webtransport_routers, {QuicConnPid, Router}),
        spawn(fun() -> reap_router(QuicConnPid, Router) end),
        #{
            owner => Router,
            stream_type_handler => Claim,
            h3_datagram_enabled => true
        }
    end,
    #{
        settings => Settings,
        stream_type_handler => Claim,
        h3_datagram_enabled => true,
        quic_opts => #{
            max_datagram_frame_size => 65535,
            reset_stream_at => true
        },
        connection_handler => ConnectionHandler
    }.

%% @doc Return HTTP/2 settings to merge into an h2 server configuration.
%%
%% ```
%% Opts = maps:merge(webtransport:h2_settings(), #{
%%     cert => "cert.pem", key => "key.pem",
%%     handler => fun my_handler/5,
%%     enable_connect_protocol => true
%% }),
%% h2:start_server(443, Opts).
%% '''
-spec h2_settings() -> map().
h2_settings() ->
    h2_settings(#{}).

-spec h2_settings(map()) -> map().
h2_settings(Opts) ->
    #{
        enable_connect_protocol => true,
        settings => #{
            enable_connect_protocol => 1,
            wt_initial_max_data =>
                maps:get(max_data, Opts, ?DEFAULT_MAX_DATA),
            wt_initial_max_streams_bidi =>
                maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
            wt_initial_max_streams_uni =>
                maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI)
        }
    }.

%% @doc Accept a WebTransport session on an incoming CONNECT request.
%%
%% Call this from your HTTP request handler when you want a particular
%% CONNECT request to become a WebTransport session. The function validates
%% headers, starts the session gen_statem, registers it as the stream
%% handler (like `quic_h3:set_stream_handler/3'), sends 200, and returns
%% `{ok, Session}'.
%%
%% ```
%% my_handler(Conn, StreamId, <<"CONNECT">>, <<"/wt">>, Headers) ->
%%     webtransport:accept(Conn, StreamId, Headers, #{
%%         transport => h3,
%%         handler => my_wt_handler,
%%         handler_opts => #{owner => self()}
%%     });
%% my_handler(Conn, StreamId, <<"GET">>, _Path, _Headers) ->
%%     quic_h3:send_response(Conn, StreamId, 200, []),
%%     quic_h3:send_data(Conn, StreamId, <<"hello">>, true).
%% '''
-spec accept(pid(), non_neg_integer(), [{binary(), binary()}], map()) ->
    {ok, session()} | {error, term()}.
accept(Conn, StreamId, Headers, Opts) ->
    Transport = maps:get(transport, Opts, h3),
    Handler = maps:get(handler, Opts, undefined),
    HandlerOpts = maps:get(handler_opts, Opts, #{}),
    Method = proplists:get_value(<<":method">>, Headers, <<>>),
    Path = proplists:get_value(<<":path">>, Headers, <<"/">>),
    case {Handler, Method} of
        {undefined, _} ->
            {error, missing_handler};
        {_, <<"CONNECT">>} ->
            case run_origin_check(Handler, Headers, HandlerOpts) of
                accept ->
                    do_accept(Transport, Conn, StreamId, Path,
                              Headers, Handler, HandlerOpts, Opts);
                {reject, Status, Reason} ->
                    send_reject(Transport, Conn, StreamId, Status, Reason),
                    {error, {rejected, Status}}
            end;
        {_, _} ->
            {error, not_connect_method}
    end.

do_accept(h3, H3Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts) ->
    CompatMode = maps:get(compat_mode, Opts, auto),
    case classify_h3_connect(Headers, CompatMode) of
        {ok, ClientMode} ->
            Router = get_or_create_router(H3Conn),
            Opts1 = Opts#{compat_mode => ClientMode},
            do_accept_h3(H3Conn, StreamId, Path, Headers,
                         Handler, HandlerOpts, Opts1, Router);
        {error, Reason} ->
            send_reject(h3, H3Conn, StreamId, 400,
                        iolist_to_binary(io_lib:format("~p", [Reason]))),
            {error, Reason}
    end;
do_accept(h2, Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts) ->
    case is_webtransport_request(Headers) of
        true ->
            do_accept_h2(Conn, StreamId, Path, Headers,
                         Handler, HandlerOpts, Opts);
        false ->
            send_reject(h2, Conn, StreamId, 400, <<"Bad Request">>),
            {error, not_webtransport}
    end.

do_accept_h3(H3Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts, Router) ->
    TransportState = webtransport_h3:new(H3Conn, StreamId, Router),
    Authority = proplists:get_value(<<":authority">>, Headers, <<>>),
    Request = #{path => Path, authority => Authority, headers => Headers},
    SessionOpts = maps:merge(HandlerOpts, #{
        request => Request,
        is_server => true,
        handler_opts => HandlerOpts,
        max_data => maps:get(max_data, Opts, ?DEFAULT_MAX_DATA),
        max_streams_bidi => maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
        max_streams_uni => maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI)
    }),
    case webtransport_session:start_link(h3, TransportState, Handler, SessionOpts) of
        {ok, Session} ->
            quic_h3:set_stream_handler(H3Conn, StreamId, Session),
            webtransport_h3_router:register_session(Router, StreamId, Session),
            quic_h3:send_response(H3Conn, StreamId, 200, []),
            {ok, Session};
        {error, Reason} ->
            send_reject(h3, H3Conn, StreamId, 500,
                        iolist_to_binary(io_lib:format("~p", [Reason]))),
            {error, Reason}
    end.

do_accept_h2(Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts) ->
    h2:send_response(Conn, StreamId, 200, []),
    TransportState = webtransport_h2:new(Conn, StreamId),
    Authority = proplists:get_value(<<":authority">>, Headers, <<>>),
    Request = #{path => Path, authority => Authority, headers => Headers},
    ServerDefaults = #{
        u  => maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI),
        bl => maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
        br => maps:get(max_data, Opts, ?DEFAULT_MAX_DATA)
    },
    Negotiated = case proplists:get_value(<<"webtransport-init">>, Headers) of
        undefined -> ServerDefaults;
        InitBin ->
            case wt_h2_init:parse(InitBin) of
                {ok, Init} -> wt_h2_init:apply_greater_of(Init, ServerDefaults);
                {error, _} -> ServerDefaults
            end
    end,
    SessionOpts = maps:merge(HandlerOpts, #{
        request => Request,
        is_server => true,
        handler_opts => HandlerOpts,
        max_data => maps:get(br, Negotiated, ?DEFAULT_MAX_DATA),
        max_streams_bidi => maps:get(bl, Negotiated, ?DEFAULT_MAX_STREAMS_BIDI),
        max_streams_uni => maps:get(u, Negotiated, ?DEFAULT_MAX_STREAMS_UNI)
    }),
    case webtransport_session:start_link(h2, TransportState, Handler, SessionOpts) of
        {ok, Session} ->
            LoopPid = spawn(fun() -> h2_data_loop(Conn, StreamId, Session) end),
            _ = h2:set_stream_handler(Conn, StreamId, LoopPid),
            {ok, Session};
        {error, Reason} ->
            h2:send_data(Conn, StreamId,
                         iolist_to_binary(io_lib:format("~p", [Reason])), true),
            {error, Reason}
    end.

send_reject(h3, H3Conn, StreamId, Status, Body) ->
    quic_h3:send_response(H3Conn, StreamId, Status, []),
    quic_h3:send_data(H3Conn, StreamId, Body, true);
send_reject(h2, Conn, StreamId, Status, Body) ->
    h2:send_response(Conn, StreamId, Status, []),
    h2:send_data(Conn, StreamId, Body, true).

%% Find the WT stream router for an H3 connection.
%%
%% The router demuxes extension streams (uni 0x54, bidi 0x41) to sessions.
%% It is keyed by the QUIC connection pid: the `h3_settings/1'
%% connection_handler registers it under the pid it is handed, and accept/4
%% derives the same pid from its H3 connection via `quic_h3:get_quic_conn/1'.
%% Both agree on one router per connection without a shared process
%% dictionary, so accept/4 works from any process (e.g. an embedder's
%% per-request worker).
get_or_create_router(H3Conn) ->
    ensure_router_table(),
    Key = router_key(H3Conn),
    case ets:lookup(webtransport_routers, Key) of
        [{_, Router}] ->
            case is_process_alive(Router) of
                true -> Router;
                false ->
                    ets:delete(webtransport_routers, Key),
                    create_and_register_router(Key)
            end;
        [] ->
            %% No connection_handler ran for this connection (the server was
            %% wired without h3_settings/1). Create a router so the session
            %% still starts; lacking the connection_handler's `owner' it
            %% won't receive peer-opened streams, but that is a caller-side
            %% misconfiguration.
            create_and_register_router(Key)
    end.

%% The registry key shared by the connection_handler and accept/4: the QUIC
%% connection pid behind the H3 connection. The connection_handler is handed
%% this same pid directly, so both sides resolve one router per connection.
router_key(H3Conn) ->
    quic_h3:get_quic_conn(H3Conn).

create_and_register_router(Key) ->
    {ok, Router} = webtransport_h3_router:start(undefined),
    ets:insert(webtransport_routers, {Key, Router}),
    Router.

%% Drop a per-connection router's registry row and stop the router when its
%% QUIC connection ends, so a long-lived embedder doesn't accumulate stale
%% rows or idle router processes. Runs in a throwaway process: the
%% connection_handler that spawns it runs inside the quic listener, which
%% must not block or monitor on its behalf. The {Key, Router} match means a
%% later connection that reuses the pid (and overwrote the row) is left
%% untouched.
reap_router(QuicConnPid, Router) ->
    Ref = erlang:monitor(process, QuicConnPid),
    receive
        {'DOWN', Ref, process, _, _} ->
            ets:match_delete(webtransport_routers, {QuicConnPid, Router}),
            try gen_server:stop(Router, normal, 5000) catch _:_ -> ok end,
            ok
    end.

ensure_router_table() ->
    case ets:whereis(webtransport_routers) of
        undefined ->
            try
                ets:new(webtransport_routers, [named_table, public, set])
            catch
                error:badarg -> ok  %% race: another process created it
            end;
        _ ->
            ok
    end.

%% ============================================================================
%% Server API
%% ============================================================================

%% @doc Start a WebTransport listener.
%%
%% Options:
%% - `transport' - Required. Either `h2' (HTTP/2) or `h3' (HTTP/3)
%% - `port' - Required. Port to listen on
%% - `certfile' - Required. Path to TLS certificate
%% - `keyfile' - Required. Path to TLS private key
%% - `handler' - Required. Module implementing webtransport_handler behaviour
%% - `handler_opts' - Optional. Initial options passed to handler:init/2
%% - `max_data' - Optional. Initial max session data (default 1MB)
%% - `max_streams_bidi' - Optional. Initial max bidi streams (default 100)
%% - `max_streams_uni' - Optional. Initial max uni streams (default 100)
%%
-spec start_listener(listener_name(), listener_opts()) ->
    {ok, pid()} | {error, term()}.
start_listener(Name, #{transport := Transport} = Opts) when is_atom(Name) ->
    case validate_listener_opts(Opts) of
        ok ->
            case Transport of
                h2 -> start_h2_listener(Name, Opts);
                h3 -> start_h3_listener(Name, Opts)
            end;
        {error, _} = Err ->
            Err
    end.

%% @doc Stop a WebTransport listener.
-spec stop_listener(listener_name()) -> ok | {error, term()}.
stop_listener(Name) when is_atom(Name) ->
    case whereis(Name) of
        undefined ->
            {error, not_found};
        Pid ->
            case persistent_term:get({webtransport_listener, Name}, undefined) of
                undefined ->
                    {error, not_found};
                #{transport := h2, server_ref := ServerRef} ->
                    h2:stop_server(ServerRef),
                    persistent_term:erase({webtransport_listener, Name}),
                    exit(Pid, shutdown),
                    ok;
                #{transport := h3, server_ref := ServerRef} ->
                    quic_h3:stop_server(ServerRef),
                    persistent_term:erase({webtransport_listener, Name}),
                    exit(Pid, shutdown),
                    ok
            end
    end.

%% @doc List all active listeners.
-spec listeners() -> [listener_name()].
listeners() ->
    %% Get all persistent terms that match our pattern
    [Name || {{webtransport_listener, Name}, _} <- persistent_term:get()].

%% @doc Get information about a listener.
%%
%% The `sockname' field is the bound `{Ip, Port}'. For h3 it is resolved live
%% from the QUIC socket (correct even when bound with port 0 or `inet6'). For
%% h2 it is best-effort (requested address + bound port), since the h2 library
%% does not expose the bound IP.
-spec listener_info(listener_name()) -> {ok, map()} | {error, not_found}.
listener_info(Name) ->
    case persistent_term:get({webtransport_listener, Name}, undefined) of
        undefined ->
            {error, not_found};
        Info ->
            Base = maps:without([server_ref], Info),
            WithSockname = case listener_sockname(Name) of
                {ok, Sockname} -> Base#{sockname => Sockname};
                {error, _} -> Base
            end,
            {ok, WithSockname}
    end.

%% @doc Get the bound `{Ip, Port}' of a listener.
%%
%% For h3, resolved live from the QUIC listener socket. For h2 this is
%% best-effort: the requested bind address (defaulting to the family wildcard)
%% paired with the actual bound port, because the h2 library exposes only the
%% port, not the bound IP.
-spec listener_sockname(listener_name()) ->
    {ok, {inet:ip_address(), inet:port_number()}} | {error, term()}.
listener_sockname(Name) ->
    case persistent_term:get({webtransport_listener, Name}, undefined) of
        undefined ->
            {error, not_found};
        #{transport := h3} ->
            quic:get_server_sockname(Name);
        #{transport := h2, server_ref := ServerRef} = Info ->
            Ip = maps:get(bind_ip, Info, {0, 0, 0, 0}),
            {ok, {Ip, h2:server_port(ServerRef)}}
    end.

%% ============================================================================
%% Client API
%% ============================================================================

%% @doc Connect to a WebTransport server.
%%
%% Options:
%% - `transport' - Optional. Either `h2' or `h3' (default: h3)
%% - `cacertfile' - Optional. CA certificate file for verification
%% - `verify' - Optional. `verify_none' or `verify_peer' (default: verify_peer)
%% - `headers' - Optional. Extra headers to send in CONNECT request
%% - `timeout' - Optional. Connection timeout in ms (default: 30000)
%%
-spec connect(Host, Port, Path, Opts) -> {ok, session()} | {error, term()} when
    Host :: string() | binary() | inet:ip_address(),
    Port :: inet:port_number(),
    Path :: binary(),
    Opts :: connect_opts().
connect(Host, Port, Path, Opts) ->
    connect(Host, Port, Path, Opts, undefined).

%% @doc Connect to a WebTransport server with a custom handler.
-spec connect(Host, Port, Path, Opts, Handler) -> {ok, session()} | {error, term()} when
    Host :: string() | binary() | inet:ip_address(),
    Port :: inet:port_number(),
    Path :: binary(),
    Opts :: connect_opts(),
    Handler :: module() | undefined.
connect(Host, Port, Path, Opts, Handler) ->
    Transport = maps:get(transport, Opts, h3),
    case Transport of
        h2 -> connect_h2(Host, Port, Path, Opts, Handler);
        h3 -> connect_h3(Host, Port, Path, Opts, Handler)
    end.

%% ============================================================================
%% Session API
%% ============================================================================

%% @doc Open a new stream on the session.
-spec open_stream(session(), bidi | uni) -> {ok, stream()} | {error, term()}.
open_stream(Session, Type) when Type =:= bidi; Type =:= uni ->
    webtransport_session:open_stream(Session, Type).

%% @doc Send data on a stream.
-spec send(session(), stream(), iodata()) -> ok | {error, term()}.
send(Session, Stream, Data) ->
    webtransport_session:send(Session, Stream, Data, false).

%% @doc Send data on a stream with optional FIN flag.
-spec send(session(), stream(), iodata(), fin | nofin) -> ok | {error, term()}.
send(Session, Stream, Data, fin) ->
    webtransport_session:send(Session, Stream, Data, true);
send(Session, Stream, Data, nofin) ->
    webtransport_session:send(Session, Stream, Data, false).

%% @doc Send an unreliable datagram.
-spec send_datagram(session(), iodata()) -> ok | {error, term()}.
send_datagram(Session, Data) ->
    webtransport_session:send_datagram(Session, Data).

%% @doc Close a stream gracefully (send FIN).
-spec close_stream(session(), stream()) -> ok | {error, term()}.
close_stream(Session, Stream) ->
    webtransport_session:close_stream(Session, Stream).

%% @doc Abruptly terminate a stream with an error code.
-spec reset_stream(session(), stream(), non_neg_integer()) -> ok | {error, term()}.
reset_stream(Session, Stream, ErrorCode) ->
    webtransport_session:reset_stream(Session, Stream, ErrorCode).

%% @doc Request that the peer stop sending on a stream.
-spec stop_sending(session(), stream(), non_neg_integer()) -> ok | {error, term()}.
stop_sending(Session, Stream, ErrorCode) ->
    webtransport_session:stop_sending(Session, Stream, ErrorCode).

%% @doc Signal that no new streams will be created.
-spec drain_session(session()) -> ok.
drain_session(Session) ->
    webtransport_session:drain(Session).

%% @doc Close the session gracefully.
-spec close_session(session()) -> ok.
close_session(Session) ->
    close_session(Session, 0, <<>>).

%% @doc Close the session with an error code.
-spec close_session(session(), non_neg_integer()) -> ok.
close_session(Session, ErrorCode) ->
    close_session(Session, ErrorCode, <<>>).

%% @doc Close the session with an error code and reason.
%% draft-14 §4.6 / draft-15 §5: Reason must be at most 1024 UTF-8 bytes.
-spec close_session(session(), non_neg_integer(), binary()) -> ok | {error, reason_too_long}.
close_session(_Session, _ErrorCode, Reason) when byte_size(Reason) > 1024 ->
    {error, reason_too_long};
close_session(Session, ErrorCode, Reason) ->
    webtransport_session:close(Session, ErrorCode, Reason).

%% @doc Get session information.
-spec session_info(session()) -> {ok, map()} | {error, term()}.
session_info(Session) ->
    webtransport_session:get_info(Session).

%% @doc Report whether the session's connection negotiated 0-RTT early data.
%%
%% Returns `true', `false', or `unknown' for h3 sessions (RFC 9001 §4.6), and
%% `not_supported' for h2. This is connection-level: it reflects whether the
%% QUIC connection used 0-RTT, regardless of whether the WebTransport CONNECT
%% itself rode as early data.
-spec early_data_accepted(session()) -> boolean() | unknown | not_supported.
early_data_accepted(Session) ->
    webtransport_session:early_data_accepted(Session).

%% ============================================================================
%% Internal Functions - Listener Setup
%% ============================================================================

validate_listener_opts(Opts) ->
    Required = [transport, port, certfile, keyfile, handler],
    Missing = [K || K <- Required, not maps:is_key(K, Opts)],
    case Missing of
        [] -> ok;
        _ -> {error, {missing_options, Missing}}
    end.

%% Build the `extra_socket_opts' list passed to quic for the h3 listener.
%% `quic_listener' infers the socket family from this list (the `inet6' atom
%% or an IPv6 `ip' tuple), so we just assemble the right entries:
%%   - `family => inet6' or an 8-tuple `ip' selects IPv6
%%   - `ip' adds `{ip, Addr}'
%%   - user `socket_opts' are appended verbatim
build_extra_socket_opts(Opts) ->
    Ip = maps:get(ip, Opts, undefined),
    Family = maps:get(family, Opts, inet),
    UserOpts = maps:get(socket_opts, Opts, []),
    Inet6 = (Family =:= inet6) orelse is_ipv6_addr(Ip),
    FamilyOpts = case Inet6 of
        true -> [inet6];
        false -> []
    end,
    IpOpts = case Ip of
        undefined -> [];
        _ -> [{ip, Ip}]
    end,
    FamilyOpts ++ IpOpts ++ UserOpts.

is_ipv6_addr({_, _, _, _, _, _, _, _}) -> true;
is_ipv6_addr(_) -> false.

%% Map our `ip' / `family' listener opts onto the `ip' / `inet6' keys that
%% h2:start_server understands.
add_h2_bind_opts(ServerOpts, Opts) ->
    Ip = maps:get(ip, Opts, undefined),
    Inet6 = (maps:get(family, Opts, inet) =:= inet6) orelse is_ipv6_addr(Ip),
    WithIp = case Ip of
        undefined -> ServerOpts;
        _ -> ServerOpts#{ip => Ip}
    end,
    case Inet6 of
        true -> WithIp#{inet6 => true};
        false -> WithIp
    end.

%% Forward a `sni_callback' into the h3 `quic_opts' map so it reaches
%% `quic:start_server' (quic >= 1.6.5 selects the cert per ClientHello SNI).
maybe_put_sni(Opts, QuicOpts) ->
    case maps:find(sni_callback, Opts) of
        {ok, Fun} -> QuicOpts#{sni_callback => Fun};
        error -> QuicOpts
    end.

%% Adapt a quic-style `sni_callback' to an `ssl' `sni_fun' for the h2 path.
%% `ssl' invokes `sni_fun' only when the ClientHello carries an SNI, so a
%% client with no SNI keeps the static certfile/keyfile.
add_h2_sni_opts(ServerOpts, Opts) ->
    case maps:find(sni_callback, Opts) of
        {ok, Fun} ->
            SniFun = sni_fun_from_callback(Fun),
            UserSslOpts = maps:get(ssl_opts, ServerOpts, []),
            ServerOpts#{ssl_opts => [{sni_fun, SniFun} | UserSslOpts]};
        error ->
            ServerOpts
    end.

%% Wrap the quic-style callback as an `ssl' `sni_fun'. `ssl' passes the
%% ServerName as a charlist; we hand the user a binary. On `{ok, _}' we
%% return the leaf+chain and re-encode the decoded key to the `{Tag, DER}'
%% form `ssl' expects via a `certs_keys' entry. Any other result (or a
%% raised error) aborts the handshake, matching h3's `handshake_failure'.
sni_fun_from_callback(Fun) ->
    fun(ServerName) ->
        ServerNameBin = case ServerName of
            undefined -> undefined;
            _ when is_list(ServerName) -> list_to_binary(ServerName);
            _ when is_binary(ServerName) -> ServerName
        end,
        case Fun(ServerNameBin) of
            {ok, #{cert := Cert, key := Key} = CertMap} when is_binary(Cert) ->
                Chain = maps:get(cert_chain, CertMap, []),
                KeyDer = {element(1, Key), public_key:der_encode(element(1, Key), Key)},
                [{certs_keys, [#{cert => [Cert | Chain], key => KeyDer}]}];
            Other ->
                error({sni_callback_rejected, ServerNameBin, Other})
        end
    end.

%% Resolved default bind address for the stored listener record, used by
%% `listener_sockname/1' / `listener_info/1' when no explicit `ip' was given.
default_bind_ip(Opts) ->
    case maps:get(ip, Opts, undefined) of
        undefined ->
            case (maps:get(family, Opts, inet) =:= inet6) of
                true -> {0, 0, 0, 0, 0, 0, 0, 0};
                false -> {0, 0, 0, 0}
            end;
        Ip ->
            Ip
    end.

start_h2_listener(Name, Opts) ->
    #{
        port := Port,
        certfile := CertFile,
        keyfile := KeyFile,
        handler := Handler
    } = Opts,

    HandlerOpts = maps:get(handler_opts, Opts, #{}),

    %% Create wrapper handler for H2
    H2Handler = make_h2_handler(Handler, HandlerOpts, Opts),

    WtSettings = #{
        enable_connect_protocol => 1,
        wt_initial_max_data =>
            maps:get(max_data, Opts, ?DEFAULT_MAX_DATA),
        wt_initial_max_streams_bidi =>
            maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
        wt_initial_max_streams_uni =>
            maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI)
    },
    ServerOpts0 = #{
        cert => CertFile,
        key => KeyFile,
        handler => H2Handler,
        enable_connect_protocol => true,
        settings => WtSettings
    },
    %% h2:start_server reads `ip' / `inet6' from its server-opts map. Map our
    %% `ip' / `family' listener opts onto those keys.
    ServerOpts1 = add_h2_bind_opts(ServerOpts0, Opts),
    %% Per-SNI cert selection: adapt the quic-style `sni_callback' to an `ssl'
    %% `sni_fun' and pass it through `ssl_opts' (h2 merges these on top of its
    %% defaults). When no callback is set the static certfile/keyfile is used.
    ServerOpts = add_h2_sni_opts(ServerOpts1, Opts),

    %% h2:start_server spawn_link's acceptor/manager processes to its caller.
    %% Delegating to a persistent owner process keeps those links on a
    %% long-lived pid so the listener survives after the caller returns.
    case start_h2_server_owner(Name, Port, Handler, ServerOpts) of
        {ok, OwnerPid, ServerRef} ->
            persistent_term:put({webtransport_listener, Name}, #{
                transport => h2,
                port => Port,
                bind_ip => default_bind_ip(Opts),
                handler => Handler,
                server_ref => ServerRef
            }),
            {ok, OwnerPid};
        {error, Reason} ->
            {error, Reason}
    end.

start_h2_server_owner(Name, Port, _Handler, ServerOpts) ->
    Parent = self(),
    Ref = make_ref(),
    OwnerPid = spawn(fun() -> h2_owner_init(Parent, Ref, Name, Port, ServerOpts) end),
    receive
        {Ref, {ok, ServerRef}} ->
            {ok, OwnerPid, ServerRef};
        {Ref, {error, Reason}} ->
            {error, Reason}
    after 5000 ->
        exit(OwnerPid, kill),
        {error, h2_listener_start_timeout}
    end.

h2_owner_init(Parent, Ref, Name, Port, ServerOpts) ->
    case h2:start_server(Port, ServerOpts) of
        {ok, ServerRef} ->
            try register(Name, self()) catch _:_ -> ok end,
            Parent ! {Ref, {ok, ServerRef}},
            h2_owner_loop(Name, ServerRef);
        {error, Reason} ->
            Parent ! {Ref, {error, Reason}}
    end.

h2_owner_loop(Name, ServerRef) ->
    receive
        {stop, _From} ->
            h2:stop_server(ServerRef),
            persistent_term:erase({webtransport_listener, Name}),
            ok;
        _ ->
            h2_owner_loop(Name, ServerRef)
    end.

start_h3_listener(Name, Opts) ->
    #{
        port := Port,
        certfile := CertFile,
        keyfile := KeyFile,
        handler := Handler
    } = Opts,

    HandlerOpts = maps:get(handler_opts, Opts, #{}),

    %% Read and decode certificate and key
    case read_cert_and_key(CertFile, KeyFile) of
        {ok, CertDer, PrivateKey} ->
            Claim = wt_stream_type_handler(),

            ConnectionHandler = fun(_QuicConnPid) ->
                {ok, Router} = webtransport_h3_router:start(undefined),
                #{
                    owner => Router,
                    handler => make_h3_handler(Handler, HandlerOpts, Opts, Router),
                    stream_type_handler => Claim,
                    h3_datagram_enabled => true
                }
            end,

            ServerCompatMode = maps:get(compat_mode, Opts, auto),
            %% `auto' advertises both latest (draft-15) AND legacy
            %% (draft-02) setting keys. H3 SETTINGS are connection-
            %% scoped and sent before any CONNECT, so the server cannot
            %% know the client's draft at SETTINGS time. Peers MUST
            %% ignore unknown settings (RFC 9114 §7.2.4.1), so the
            %% merged shape is wire-safe. Pinning to a specific mode
            %% sends only that mode's settings.
            AdvertisedSettings = case ServerCompatMode of
                auto ->
                    maps:merge(wt_h3:default_settings(latest),
                               wt_h3:default_settings(legacy_browser_compat));
                Other ->
                    wt_h3:default_settings(Other)
            end,
            ServerOpts = #{
                cert => CertDer,
                key => PrivateKey,
                handler => make_h3_handler(Handler, HandlerOpts, Opts, undefined),
                settings => AdvertisedSettings,
                %% `sni_callback' rides inside `quic_opts' so it reaches
                %% `quic:start_server'; `quic_h3' only lifts cert/key from
                %% the top level. quic selects the cert per ClientHello SNI.
                quic_opts => maybe_put_sni(Opts, #{
                    max_datagram_frame_size => 65535,
                    reset_stream_at => true,
                    extra_socket_opts => build_extra_socket_opts(Opts)
                }),
                stream_type_handler => Claim,
                connection_handler => ConnectionHandler
            },

            case quic_h3:start_server(Name, Port, ServerOpts) of
                {ok, ServerRef} ->
                    Pid = spawn(?MODULE, listener_loop, [Name]),
                    register(Name, Pid),
                    persistent_term:put({webtransport_listener, Name}, #{
                        transport => h3,
                        port => Port,
                        bind_ip => default_bind_ip(Opts),
                        handler => Handler,
                        server_ref => ServerRef
                    }),
                    {ok, Pid};
                {error, Reason} ->
                    {error, Reason}
            end;
        {error, Reason} ->
            {error, Reason}
    end.

listener_loop(Name) ->
    receive
        {stop, _From} ->
            persistent_term:erase({webtransport_listener, Name}),
            ok;
        _ ->
            listener_loop(Name)
    end.

%% ============================================================================
%% Internal Functions - Request Handling
%% ============================================================================

handle_h2_request(Conn, StreamId, <<"CONNECT">>, Path, Headers, Handler, HandlerOpts, Opts) ->
    case is_webtransport_request(Headers) of
        true ->
            case run_origin_check(Handler, Headers, HandlerOpts) of
                accept ->
                    accept_h2_session(Conn, StreamId, Path, Headers,
                                      Handler, HandlerOpts, Opts);
                {reject, Status, Reason} ->
                    h2:send_response(Conn, StreamId, Status, []),
                    h2:send_data(Conn, StreamId, Reason, true)
            end;
        false ->
            h2:send_response(Conn, StreamId, 400, []),
            h2:send_data(Conn, StreamId, <<"Bad Request">>, true)
    end;

handle_h2_request(Conn, StreamId, _Method, _Path, _Headers, _Handler, _HandlerOpts, _Opts) ->
    h2:send_response(Conn, StreamId, 405, []),
    h2:send_data(Conn, StreamId, <<"Method Not Allowed">>, true).

handle_h3_request(H3Conn, StreamId, <<"CONNECT">>, Path, Headers,
                  Handler, HandlerOpts, Opts, Router) ->
    ListenerMode = maps:get(compat_mode, Opts, auto),
    case classify_h3_connect(Headers, ListenerMode) of
        {ok, ClientMode} ->
            case run_origin_check(Handler, Headers, HandlerOpts) of
                accept ->
                    Opts1 = Opts#{compat_mode => ClientMode},
                    accept_h3_session(H3Conn, StreamId, Path, Headers,
                                      Handler, HandlerOpts, Opts1, Router);
                {reject, Status, Reason} ->
                    quic_h3:send_response(H3Conn, StreamId, Status, []),
                    quic_h3:send_data(H3Conn, StreamId, Reason, true)
            end;
        {error, Reason} ->
            Body = iolist_to_binary(io_lib:format("Bad Request: ~p", [Reason])),
            quic_h3:send_response(H3Conn, StreamId, 400, []),
            quic_h3:send_data(H3Conn, StreamId, Body, true)
    end;

handle_h3_request(H3Conn, StreamId, _Method, _Path, _Headers,
                  _Handler, _HandlerOpts, _Opts, _Router) ->
    quic_h3:send_response(H3Conn, StreamId, 405, []),
    quic_h3:send_data(H3Conn, StreamId, <<"Method Not Allowed">>, true).

accept_h2_session(Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts) ->
    h2:send_response(Conn, StreamId, 200, []),
    TransportState = webtransport_h2:new(Conn, StreamId),
    Authority = proplists:get_value(<<":authority">>, Headers, <<>>),
    Request = #{
        path => Path,
        authority => Authority,
        headers => Headers
    },
    %% Parse WebTransport-Init from the client's CONNECT request and
    %% apply the greater-of rule (draft-14 §4.3.2) against our defaults.
    ServerDefaults = #{
        u  => maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI),
        bl => maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
        br => maps:get(max_data, Opts, ?DEFAULT_MAX_DATA)
    },
    Negotiated = case proplists:get_value(<<"webtransport-init">>, Headers) of
        undefined -> ServerDefaults;
        InitBin ->
            case wt_h2_init:parse(InitBin) of
                {ok, Init} -> wt_h2_init:apply_greater_of(Init, ServerDefaults);
                {error, _} -> ServerDefaults
            end
    end,
    SessionOpts = maps:merge(HandlerOpts, #{
        request => Request,
        is_server => true,
        handler_opts => HandlerOpts,
        max_data => maps:get(br, Negotiated, ?DEFAULT_MAX_DATA),
        max_streams_bidi => maps:get(bl, Negotiated, ?DEFAULT_MAX_STREAMS_BIDI),
        max_streams_uni => maps:get(u, Negotiated, ?DEFAULT_MAX_STREAMS_UNI)
    }),
    case webtransport_session:start_link(h2, TransportState, Handler, SessionOpts) of
        {ok, Session} ->
            LoopPid = spawn(fun() -> h2_data_loop(Conn, StreamId, Session) end),
            _ = h2:set_stream_handler(Conn, StreamId, LoopPid);
        {error, Reason} ->
            h2:send_response(Conn, StreamId, 500, []),
            h2:send_data(Conn, StreamId,
                         iolist_to_binary(io_lib:format("~p", [Reason])), true)
    end.

accept_h3_session(H3Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts, Router) ->
    TransportState = webtransport_h3:new(H3Conn, StreamId, Router),
    Authority = proplists:get_value(<<":authority">>, Headers, <<>>),
    Request = #{
        path => Path,
        authority => Authority,
        headers => Headers
    },
    SessionOpts = maps:merge(HandlerOpts, #{
        request => Request,
        is_server => true,
        handler_opts => HandlerOpts,
        max_data => maps:get(max_data, Opts, ?DEFAULT_MAX_DATA),
        max_streams_bidi => maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
        max_streams_uni => maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI)
    }),
    %% Register the session with the router BEFORE the 200 goes out.
    %% Otherwise the client sees 200, opens extension streams, and the
    %% stream_type_data arrives at the router while sessions is still
    %% empty. The router then silently drops the data.
    case webtransport_session:start_link(h3, TransportState, Handler, SessionOpts) of
        {ok, Session} ->
            quic_h3:set_stream_handler(H3Conn, StreamId, Session),
            case Router of
                undefined -> ok;
                _ -> webtransport_h3_router:register_session(Router, StreamId, Session)
            end,
            quic_h3:send_response(H3Conn, StreamId, 200, []),
            ok;
        {error, Reason} ->
            quic_h3:send_response(H3Conn, StreamId, 500, []),
            quic_h3:send_data(H3Conn, StreamId,
                              iolist_to_binary(io_lib:format("~p", [Reason])), true)
    end.

%% Defaults to accept when the handler does not export origin_check/2.
run_origin_check(Handler, Headers, Opts) ->
    _ = code:ensure_loaded(Handler),
    case erlang:function_exported(Handler, origin_check, 2) of
        true ->
            try Handler:origin_check(Headers, Opts) of
                accept -> accept;
                {reject, Status, Reason}
                  when is_integer(Status), Status >= 400, Status < 600,
                       is_binary(Reason) ->
                    {reject, Status, Reason};
                Other ->
                    logger:warning("invalid origin_check/2 result: ~p", [Other]),
                    accept
            catch
                Kind:Err:Stk ->
                    logger:warning("origin_check/2 crashed ~p:~p ~p", [Kind, Err, Stk]),
                    {reject, 500, <<"origin check failed">>}
            end;
        false ->
            %% No custom origin_check/2 callback. The drafts (h3 §3.2,
            %% h2 §3.2) require: "the server MUST verify the Origin header
            %% to ensure that the specified origin is allowed". If the
            %% request carries an Origin header (browser client), reject
            %% by default so servers cannot accidentally skip verification.
            %% Non-browser requests (no Origin header) are accepted.
            case proplists:get_value(<<"origin">>, Headers) of
                undefined -> accept;
                _ -> {reject, 403, <<"origin not allowed">>}
            end
    end.

is_webtransport_request(Headers) ->
    case proplists:get_value(<<":protocol">>, Headers) of
        <<"webtransport">> -> true;
        _ -> false
    end.

%% Classify an incoming h3 CONNECT request against the listener's
%% compat_mode. Returns `{ok, ClientMode}' when the request is well-formed
%% and allowed, or `{error, Reason}' when it is malformed or disallowed.
classify_h3_connect(Headers, ListenerMode) ->
    case wt_h3:detect_compat_mode(Headers) of
        {ok, ClientMode} ->
            case allowed_by_listener(ClientMode, ListenerMode) of
                true -> {ok, ClientMode};
                false -> {error, {compat_mode_refused, ClientMode}}
            end;
        {error, _} = Err ->
            Err
    end.

allowed_by_listener(_, auto) -> true;
allowed_by_listener(Mode, Mode) -> true;
allowed_by_listener(_, _) -> false.

%% ============================================================================
%% Internal Functions - Data Loops
%% ============================================================================

h2_data_loop(Conn, StreamId, Session) ->
    %% Monitor both ends so the loop can never orphan: if the h2 connection
    %% dies without a `closed' message, or the session goes away, we exit
    %% instead of blocking in `receive' forever.
    ConnRef = erlang:monitor(process, Conn),
    SessionRef = erlang:monitor(process, Session),
    h2_data_loop(Conn, StreamId, Session, ConnRef, SessionRef).

h2_data_loop(Conn, StreamId, Session, ConnRef, SessionRef) ->
    receive
        {h2, Conn, {data, StreamId, Data, _IsFin}} ->
            %% Decode capsules and dispatch
            case webtransport_h2:decode_capsules(Data) of
                {ok, Capsules, _Rest} ->
                    lists:foreach(fun(Capsule) ->
                        dispatch_h2_capsule(Session, Capsule)
                    end, Capsules);
                {error, Reason} ->
                    %% Malformed capsule framing on the CONNECT stream is
                    %% a protocol violation. Close the session.
                    logger:warning("h2 capsule decode error: ~p", [Reason]),
                    webtransport_session:close(Session, 0, <<"malformed capsule">>)
            end,
            h2_data_loop(Conn, StreamId, Session, ConnRef, SessionRef);
        {h2, Conn, {stream_reset, StreamId, _ErrorCode}} ->
            cleanup_data_loop(ConnRef, SessionRef),
            webtransport_session:close(Session, 0, <<"stream reset">>);
        {h2, Conn, closed} ->
            cleanup_data_loop(ConnRef, SessionRef),
            webtransport_session:close(Session, 0, <<"connection closed">>);
        {'DOWN', ConnRef, process, Conn, _Reason} ->
            erlang:demonitor(SessionRef, [flush]),
            webtransport_session:close(Session, 0, <<"connection closed">>);
        {'DOWN', SessionRef, process, Session, _Reason} ->
            %% Session is gone; nothing left to forward to. Exit so we don't
            %% leak this process for the life of the h2 connection.
            erlang:demonitor(ConnRef, [flush]),
            ok;
        _ ->
            h2_data_loop(Conn, StreamId, Session, ConnRef, SessionRef)
    end.

cleanup_data_loop(ConnRef, SessionRef) ->
    erlang:demonitor(ConnRef, [flush]),
    erlang:demonitor(SessionRef, [flush]).

dispatch_h2_capsule(Session, {wt_stream, WtStreamId, Data}) ->
    %% h2 multiplexes WT streams over CONNECT; peer-opened streams don't get
    %% a separate open signal, so we seed the session's stream map on first
    %% data (handle_stream_opened is idempotent). Direction follows the QUIC
    %% stream-id rule (bit 1 = 0 bidi, 1 = uni) per draft-14.
    webtransport_session:handle_stream_opened(Session, WtStreamId,
                                              webtransport_stream:stream_type(WtStreamId)),
    webtransport_session:handle_stream_data(Session, WtStreamId, Data, false);
dispatch_h2_capsule(Session, {wt_stream_fin, WtStreamId, Data}) ->
    webtransport_session:handle_stream_opened(Session, WtStreamId,
                                              webtransport_stream:stream_type(WtStreamId)),
    webtransport_session:handle_stream_data(Session, WtStreamId, Data, true);
dispatch_h2_capsule(Session, {datagram, Data}) ->
    webtransport_session:handle_datagram_data(Session, Data);
dispatch_h2_capsule(Session, {reset_stream, WtStreamId, ErrorCode}) ->
    webtransport_session:handle_stream_closed(Session, WtStreamId, {reset, ErrorCode});
dispatch_h2_capsule(Session, {stop_sending, WtStreamId, ErrorCode}) ->
    webtransport_session:handle_stream_closed(Session, WtStreamId, {stop_sending, ErrorCode});
dispatch_h2_capsule(Session, Capsule) ->
    webtransport_session:handle_capsule(Session, Capsule).

%% ============================================================================
%% Internal Functions - Client Connection
%% ============================================================================

connect_h2(Host, Port, Path, Opts, Handler) ->
    Caller = self(),
    UserHandlerOpts = maps:get(handler_opts, Opts, #{}),
    HandlerOpts = maps:merge(#{owner => Caller}, UserHandlerOpts),
    Opts1 = Opts#{ssl_opts => build_h2_ssl_opts(Opts)},
    case webtransport_h2:connect(Host, Port, Path, Opts1) of
        {ok, TransportState} ->
            %% Default handler for client
            ActualHandler = case Handler of
                undefined -> webtransport_client_handler;
                _ -> Handler
            end,
            Request = #{
                path => Path,
                authority => authority(Host, Port),
                headers => maps:get(headers, Opts, [])
            },
            SessionOpts = #{
                request => Request,
                is_server => false,
                handler_opts => HandlerOpts
            },
            case webtransport_session:start_link(h2, TransportState, ActualHandler, SessionOpts) of
                {ok, Session} ->
                    H2Conn = webtransport_h2:h2_conn(TransportState),
                    StreamId = webtransport_h2:connect_stream_id(TransportState),
                    LoopPid = spawn(fun() -> h2_data_loop(H2Conn, StreamId, Session) end),
                    _ = h2:set_stream_handler(H2Conn, StreamId, LoopPid),
                    {ok, Session};
                {error, _} = Err ->
                    Err
            end;
        {error, Reason} ->
            {error, Reason}
    end.

connect_h3(Host, Port, Path, Opts0, Handler) ->
    Caller = self(),
    UserHandlerOpts = maps:get(handler_opts, Opts0, #{}),
    HandlerOpts = maps:merge(#{owner => Caller}, UserHandlerOpts),
    Opts = Opts0#{handler_opts => HandlerOpts},
    Authority = authority(Host, Port),
    {ok, Router} = webtransport_h3_router:start_link(Caller),
    H3ConnOpts = build_h3_connect_opts(Opts),
    case webtransport_h3_router:client_connect(Router, Host, Port, H3ConnOpts) of
        {ok, H3Conn} ->
            h3_validate_and_request(H3Conn, Authority, Path, Opts, Handler, Router);
        {error, Reason} ->
            %% No H3 connection was established, so the router never started
            %% monitoring one and would otherwise linger (it traps exits, so
            %% the caller link won't reap it). Stop it explicitly.
            _ = gen_server:stop(Router, normal, 5000),
            {error, Reason}
    end.

build_h2_ssl_opts(Opts) ->
    Base = maps:get(ssl_opts, Opts, []),
    WithVerify =
        case maps:get(verify, Opts, verify_peer) of
            verify_none -> [{verify, verify_none} | Base];
            verify_peer -> [{verify, verify_peer} | Base]
        end,
    WithCACerts =
        case {maps:find(cacertfile, Opts), maps:find(cacerts, Opts)} of
            {{ok, CAFile}, _} ->
                case read_cacerts_file(CAFile) of
                    {ok, CACerts} -> [{cacerts, CACerts} | WithVerify];
                    _ -> WithVerify
                end;
            {_, {ok, CACerts}} -> [{cacerts, CACerts} | WithVerify];
            _ -> WithVerify
        end,
    WithCert =
        case {maps:find(certfile, Opts), maps:find(cert, Opts)} of
            {{ok, CertFile}, _} -> [{certfile, CertFile} | WithCACerts];
            {_, {ok, CertDer}} -> [{cert, CertDer} | WithCACerts];
            _ -> WithCACerts
        end,
    case {maps:find(keyfile, Opts), maps:find(key, Opts)} of
        {{ok, KeyFile}, _} -> [{keyfile, KeyFile} | WithCert];
        {_, {ok, KeyTerm}} -> [{key, KeyTerm} | WithCert];
        _ -> WithCert
    end.

build_h3_connect_opts(Opts) ->
    %% Client picks a compat_mode explicitly; default `latest' (draft-15).
    %% Clients must not auto-probe the server, so the default is a single
    %% disjoint handshake shape.
    CompatMode = case maps:get(compat_mode, Opts, latest) of
        legacy_browser_compat -> legacy_browser_compat;
        _ -> latest
    end,
    WTSettings = wt_h3:default_settings(CompatMode),
    BaseOpts = #{
        settings => WTSettings,
        sync => true,
        connect_timeout => maps:get(timeout, Opts, 30000),
        verify => maps:get(verify, Opts, verify_peer),
        quic_opts => build_client_quic_opts(Opts),
        h3_datagram_enabled => true,
        stream_type_handler => fun
            (uni,  _, 16#54) -> claim;
            (bidi, _, 16#41) -> claim;
            (_, _, _) -> ignore
        end
    },
    %% Handle client certificate if provided
    WithCert = case {maps:find(certfile, Opts), maps:find(cert, Opts)} of
        {{ok, CertFile}, _} ->
            case read_cert_file(CertFile) of
                {ok, CertDer} -> BaseOpts#{cert => CertDer};
                _ -> BaseOpts
            end;
        {_, {ok, CertDer}} ->
            BaseOpts#{cert => CertDer};
        _ ->
            BaseOpts
    end,
    %% Handle client key if provided
    WithKey = case {maps:find(keyfile, Opts), maps:find(key, Opts)} of
        {{ok, KeyFile}, _} ->
            case read_key_file(KeyFile) of
                {ok, KeyTerm} -> WithCert#{key => KeyTerm};
                _ -> WithCert
            end;
        {_, {ok, KeyTerm}} ->
            WithCert#{key => KeyTerm};
        _ ->
            WithCert
    end,
    %% Handle CA certificates if provided
    case {maps:find(cacertfile, Opts), maps:find(cacerts, Opts)} of
        {{ok, CAFile}, _} ->
            case read_cacerts_file(CAFile) of
                {ok, CACerts} -> WithKey#{cacerts => CACerts};
                _ -> WithKey
            end;
        {_, {ok, CACerts}} ->
            WithKey#{cacerts => CACerts};
        _ ->
            WithKey
    end.

%% Build the `quic_opts' map for a client h3 connect. Threads the optional
%% IPv6 / Happy Eyeballs controls and a stored 0-RTT `session_ticket' through
%% to quic:connect/4 alongside the WebTransport transport defaults.
build_client_quic_opts(Opts) ->
    Base = #{
        max_datagram_frame_size => 65535,
        reset_stream_at => true
    },
    PassThrough = [family, happy_eyeballs, connection_attempt_delay, session_ticket],
    lists:foldl(fun(Key, Acc) ->
        case maps:find(Key, Opts) of
            {ok, Value} -> Acc#{Key => Value};
            error -> Acc
        end
    end, Base, PassThrough).

%% Format an HTTP `:authority' from a host (string/binary/IP tuple) and port.
%% IPv6 literals and 8-tuple addresses are bracketed per RFC 3986.
authority(Host, Port) when is_tuple(Host) ->
    authority(inet:ntoa(Host), Port);
authority(Host, Port) when is_list(Host) ->
    authority(list_to_binary(Host), Port);
authority(Host, Port) when is_binary(Host) ->
    PortBin = integer_to_binary(Port),
    case is_ipv6_literal(Host) of
        true -> <<"[", Host/binary, "]:", PortBin/binary>>;
        false -> <<Host/binary, ":", PortBin/binary>>
    end.

%% An IPv6 literal contains a colon and no brackets (a bare host or IPv4
%% literal never does). Already-bracketed input is left as-is.
is_ipv6_literal(<<"[", _/binary>>) -> false;
is_ipv6_literal(Host) -> binary:match(Host, <<":">>) =/= nomatch.

h3_validate_and_request(H3Conn, Authority, Path, Opts, Handler, Router) ->
    CompatMode = maps:get(compat_mode, Opts, latest),
    QuicConn = quic_h3:get_quic_conn(H3Conn),
    case wt_h3:validate_wt_support(H3Conn, QuicConn, CompatMode) of
        ok ->
            h3_send_connect(H3Conn, Authority, Path, Opts, Handler, Router, CompatMode);
        {error, Reason} ->
            quic_h3:close(H3Conn),
            {error, Reason}
    end.

h3_send_connect(H3Conn, Authority, Path, Opts, Handler, Router, CompatMode) ->
    Headers = maps:get(headers, Opts, []),
    case wt_h3:request_session(H3Conn, Authority, Path, Headers, CompatMode) of
        {ok, SessionId} ->
            h3_await_response(H3Conn, SessionId, Authority, Path, Opts, Handler, Router);
        {error, Reason} ->
            quic_h3:close(H3Conn),
            {error, Reason}
    end.

h3_await_response(H3Conn, SessionId, Authority, Path, Opts, Handler, Router) ->
    Timeout = maps:get(timeout, Opts, 30000),
    receive
        {quic_h3, H3Conn, {response, SessionId, Status, Headers}} when Status >= 200, Status < 300 ->
            h3_start_session(H3Conn, SessionId, Authority, Path, Headers, Opts, Handler, Router);
        {quic_h3, H3Conn, {response, SessionId, Status, _Headers}} ->
            quic_h3:close(H3Conn),
            {error, {http_error, Status}};
        {quic_h3, H3Conn, closed} ->
            {error, connection_closed}
    after Timeout ->
        quic_h3:close(H3Conn),
        {error, timeout}
    end.

h3_start_session(H3Conn, SessionId, Authority, Path, Headers, Opts, Handler, Router) ->
    TransportState = webtransport_h3:new(H3Conn, SessionId, Router),
    ActualHandler = case Handler of
        undefined -> webtransport_client_handler;
        _ -> Handler
    end,
    Request = #{
        path => Path,
        authority => Authority,
        headers => Headers
    },
    HandlerOpts = maps:get(handler_opts, Opts, #{}),
    SessionOpts = #{
        request => Request,
        is_server => false,
        handler_opts => HandlerOpts
    },
    case webtransport_session:start_link(h3, TransportState, ActualHandler, SessionOpts) of
        {ok, Session} ->
            webtransport_h3_router:register_session(Router, SessionId, Session),
            {ok, Session};
        {error, _} = Err ->
            %% Session failed to start after a successful CONNECT. Closing the
            %% H3 connection makes the router stop via its h3_conn monitor, so
            %% neither the connection nor the router is left dangling.
            quic_h3:close(H3Conn),
            Err
    end.

%% ============================================================================
%% Handler Factories
%% ============================================================================

make_h2_handler(Handler, HandlerOpts, Opts) ->
    fun(Conn, StreamId, Method, Path, Headers) ->
        handle_h2_request(Conn, StreamId, Method, Path, Headers, Handler, HandlerOpts, Opts)
    end.

make_h3_handler(Handler, HandlerOpts, Opts, Router) ->
    fun(H3Conn, StreamId, Method, Path, Headers) ->
        handle_h3_request(H3Conn, StreamId, Method, Path, Headers,
                          Handler, HandlerOpts, Opts, Router)
    end.

wt_stream_type_handler() ->
    fun
        (uni,  _StreamId, 16#54) -> claim;
        (bidi, _StreamId, 16#41) -> claim;
        (_, _, _) -> ignore
    end.

%% ============================================================================
%% Certificate/Key Helpers
%% ============================================================================

%% @private Read and decode certificate and private key files.
read_cert_and_key(CertFile, KeyFile) ->
    case {file:read_file(CertFile), file:read_file(KeyFile)} of
        {{ok, CertPem}, {ok, KeyPem}} ->
            case public_key:pem_decode(CertPem) of
                [{_, CertDer, _} | _] ->
                    case decode_private_key(KeyPem) of
                        {ok, PrivateKey} ->
                            {ok, CertDer, PrivateKey};
                        {error, Reason} ->
                            {error, {key_decode_failed, Reason}}
                    end;
                [] ->
                    {error, invalid_certificate}
            end;
        {{error, CertErr}, _} ->
            {error, {cert_read_failed, CertErr}};
        {_, {error, KeyErr}} ->
            {error, {key_read_failed, KeyErr}}
    end.

%% @private Decode a PEM-encoded private key.
decode_private_key(PemData) ->
    case public_key:pem_decode(PemData) of
        [{Type, Der, not_encrypted}] ->
            decode_key_entry(Type, Der);
        [{Type, Der, _Cipher}] ->
            decode_key_entry(Type, Der);
        _ ->
            {error, invalid_private_key}
    end.

decode_key_entry('RSAPrivateKey', Der) ->
    {ok, public_key:der_decode('RSAPrivateKey', Der)};
decode_key_entry('ECPrivateKey', Der) ->
    {ok, public_key:der_decode('ECPrivateKey', Der)};
decode_key_entry('PrivateKeyInfo', Der) ->
    {ok, public_key:der_decode('PrivateKeyInfo', Der)};
decode_key_entry(Type, _Der) ->
    {error, {unsupported_key_type, Type}}.

%% @private Read a single certificate from a PEM file.
read_cert_file(CertFile) ->
    case file:read_file(CertFile) of
        {ok, PemData} ->
            case public_key:pem_decode(PemData) of
                [{_, CertDer, _} | _] -> {ok, CertDer};
                [] -> {error, invalid_certificate}
            end;
        {error, Reason} ->
            {error, {cert_read_failed, Reason}}
    end.

%% @private Read a private key from a PEM file.
read_key_file(KeyFile) ->
    case file:read_file(KeyFile) of
        {ok, PemData} ->
            decode_private_key(PemData);
        {error, Reason} ->
            {error, {key_read_failed, Reason}}
    end.

%% @private Read CA certificates from a PEM file.
read_cacerts_file(CAFile) ->
    case file:read_file(CAFile) of
        {ok, PemData} ->
            Certs = [Der || {_, Der, _} <- public_key:pem_decode(PemData)],
            case Certs of
                [] -> {error, no_certificates};
                _ -> {ok, Certs}
            end;
        {error, Reason} ->
            {error, {cacerts_read_failed, Reason}}
    end.
