%%% -*- erlang -*-
%%%
%%% This file is part of hackney released under the Apache 2 license.
%%% See the NOTICE for more information.
%%%
%%% Copyright (c) 2026 Benoît Chesneau <benoitc@pm.me>
%%%
%%% @doc gen_statem process for a single full-duplex HTTP/2 stream
%%% (gRPC-style bidirectional streaming).
%%%
%%% This module mirrors {@link hackney_wt} / {@link hackney_ws}: an
%%% application gets a pid and uses `send'/`recv' (passive) or active-mode
%%% messages. It owns one dedicated HTTP/2 connection (negotiated via ALPN)
%%% and opens one stream on it whose events are routed here directly by the
%%% `h2' library's per-stream handler mechanism, so the request body and the
%%% response can be sent and read interleaved on the same stream.
%%%
%%% == Send / receive ==
%%%
%%% `send/2,3' writes a DATA frame (optionally with END_STREAM via `fin').
%%% `send_trailers/2' closes the send side with trailing HEADERS (gRPC
%%% carries grpc-status there). `recv/1,2' returns the next inbound message:
%%% `{response, Status, Headers}', `{data, Data}', `{trailers, Trailers}' or
%%% `done' (the peer ended the stream). After `done', recv returns
%%% `{error, closed}'.
%%%
%%% == Delivery modes ==
%%%
%%% Passive (default): messages are buffered and read with `recv'. Active
%%% (`{active, true|once}'): every message is forwarded to the owner as
%%% `{hackney_h2, Pid, Msg}', and errors as `{hackney_h2_error, Pid, Reason}'.
%%%
%%% == Flow control ==
%%%
%%% With `{flow_control, manual}' the receive window is only replenished by
%%% `consume/2', so a slow reader applies backpressure to the peer. The
%%% default `auto' replenishes automatically.
%%%
%%% States: idle (not yet connected), connected (ready), closed (stream/conn
%%% ended, buffered data still drainable).
-module(hackney_h2_stream).
-behaviour(gen_statem).

%% API
-export([
    start_link/1,
    connect/1, connect/2,
    send/2, send/3,
    send_trailers/2,
    recv/1, recv/2,
    consume/2,
    setopts/2,
    controlling_process/2,
    close/1
]).

%% gen_statem callbacks
-export([init/1, callback_mode/0, terminate/3, code_change/4]).

%% State functions
-export([idle/3, connected/3, closed/3]).

-define(CONNECT_TIMEOUT, 8000).
-define(RECV_TIMEOUT, infinity).
%% Bound the decoded bytes buffered in the passive recv queue so a hostile
%% peer cannot drive the client to OOM; complements HTTP/2 flow control.
-define(DEFAULT_MAX_RECV_BUFFER, 16#4000000). %% 64 MiB

%% A normalized inbound message handed to recv/2 or, in active mode, the owner.
-type h2_msg() :: {response, non_neg_integer(), list()}
                | {data, binary()}
                | {trailers, list()}
                | done.

-export_type([h2_msg/0]).

-record(h2s_data, {
    %% Stream owner (linked via start_link, trap_exit handles death)
    owner :: pid(),

    %% Request identity used to open the stream
    method :: binary(),
    host :: string() | binary(),
    port :: inet:port_number(),
    transport :: module(),
    path :: binary(),
    headers = [] :: list(),

    %% Connection setup
    connect_options = [] :: list(),
    ssl_options = [] :: list(),
    connect_timeout = ?CONNECT_TIMEOUT :: timeout(),
    recv_timeout = ?RECV_TIMEOUT :: timeout(),
    flow_control = auto :: auto | manual,
    active = false :: false | true | once,

    %% Underlying hackney_conn (dedicated, owned here) and the h2 stream
    conn_pid :: pid() | undefined,
    conn_mon :: reference() | undefined,
    h2_conn :: pid() | undefined,
    stream_id :: pos_integer() | undefined,

    %% Passive recv buffering
    recv_q = queue:new() :: queue:queue(),
    recv_from :: gen_statem:from() | undefined,
    recv_bytes = 0 :: non_neg_integer(),
    max_recv_buffer = ?DEFAULT_MAX_RECV_BUFFER :: non_neg_integer() | infinity,

    %% Peer END_STREAM seen (no more inbound after the queue drains)
    ended = false :: boolean(),
    closed_reason :: term()
}).

%%====================================================================
%% API
%%====================================================================

%% @doc Start a stream process. See hackney:h2_open/3,4 for option docs.
-spec start_link(map()) -> {ok, pid()} | {error, term()}.
start_link(Opts) when is_map(Opts) ->
    gen_statem:start_link(?MODULE, [self(), Opts], []).

%% @doc Establish the connection and open the stream. Blocks until ready.
-spec connect(pid()) -> ok | {error, term()}.
connect(Pid) ->
    connect(Pid, ?CONNECT_TIMEOUT).

-spec connect(pid(), timeout()) -> ok | {error, term()}.
connect(Pid, Timeout) ->
    gen_statem:call(Pid, {connect, Timeout}, infinity).

%% @doc Send a DATA frame on the stream (no END_STREAM).
-spec send(pid(), iodata()) -> ok | {error, term()}.
send(Pid, Data) ->
    send(Pid, Data, nofin).

%% @doc Send a DATA frame, optionally half-closing the send side (`fin').
-spec send(pid(), iodata(), fin | nofin) -> ok | {error, term()}.
send(Pid, Data, Fin) when Fin =:= fin; Fin =:= nofin ->
    gen_statem:call(Pid, {send, Data, Fin}).

%% @doc Send trailing HEADERS, half-closing the send side (gRPC trailers).
-spec send_trailers(pid(), list()) -> ok | {error, term()}.
send_trailers(Pid, Trailers) ->
    gen_statem:call(Pid, {send_trailers, Trailers}).

%% @doc Receive the next inbound message (passive mode only).
-spec recv(pid()) -> {ok, h2_msg()} | {error, term()}.
recv(Pid) ->
    gen_statem:call(Pid, {recv, default}, infinity).

-spec recv(pid(), timeout()) -> {ok, h2_msg()} | {error, term()}.
recv(Pid, Timeout) ->
    gen_statem:call(Pid, {recv, Timeout}, infinity).

%% @doc Acknowledge N consumed bytes (manual flow control only).
-spec consume(pid(), non_neg_integer()) -> ok | {error, term()}.
consume(Pid, NBytes) when is_integer(NBytes), NBytes >= 0 ->
    gen_statem:call(Pid, {consume, NBytes}).

%% @doc Set options. Supported: [{active, true|false|once}].
-spec setopts(pid(), list()) -> ok | {error, term()}.
setopts(Pid, Opts) ->
    gen_statem:call(Pid, {setopts, Opts}).

%% @doc Assign a new controlling process.
-spec controlling_process(pid(), pid()) -> ok | {error, term()}.
controlling_process(Pid, NewOwner) ->
    gen_statem:call(Pid, {controlling_process, NewOwner}).

%% @doc Cancel the stream and tear down the connection.
-spec close(pid()) -> ok.
close(Pid) ->
    gen_statem:cast(Pid, close).

%%====================================================================
%% gen_statem callbacks
%%====================================================================

%% @private
callback_mode() ->
    [state_functions, state_enter].

%% @private
init([Owner, Opts]) ->
    process_flag(trap_exit, true),
    Data = #h2s_data{
        owner = Owner,
        method = maps:get(method, Opts, <<"POST">>),
        host = maps:get(host, Opts),
        port = maps:get(port, Opts),
        transport = maps:get(transport, Opts),
        path = maps:get(path, Opts, <<"/">>),
        headers = maps:get(headers, Opts, []),
        connect_options = maps:get(connect_options, Opts, []),
        ssl_options = maps:get(ssl_options, Opts, []),
        connect_timeout = maps:get(connect_timeout, Opts, ?CONNECT_TIMEOUT),
        recv_timeout = maps:get(recv_timeout, Opts, ?RECV_TIMEOUT),
        flow_control = maps:get(flow_control, Opts, auto),
        active = maps:get(active, Opts, false),
        max_recv_buffer = maps:get(max_recv_buffer, Opts, ?DEFAULT_MAX_RECV_BUFFER)
    },
    {ok, idle, Data}.

%% @private
terminate(_Reason, _State, #h2s_data{conn_pid = undefined}) ->
    ok;
terminate(_Reason, _State, #h2s_data{conn_pid = ConnPid}) ->
    stop_conn(ConnPid),
    ok.

%% @private
code_change(_OldVsn, State, Data, _Extra) ->
    {ok, State, Data}.

%%====================================================================
%% State: idle
%%====================================================================

idle(enter, _OldState, _Data) ->
    keep_state_and_data;

idle({call, From}, {connect, Timeout}, Data) ->
    case establish(Timeout, Data) of
        {ok, Data1} ->
            {next_state, connected, Data1, [{reply, From, ok}]};
        {error, Reason} ->
            {stop_and_reply, normal, [{reply, From, {error, Reason}}]}
    end;

idle({call, From}, _Request, _Data) ->
    {keep_state_and_data, [{reply, From, {error, not_connected}}]};

idle(info, {'EXIT', Owner, _Reason}, #h2s_data{owner = Owner}) ->
    {stop, normal};

idle(_, _, _) ->
    keep_state_and_data.

%%====================================================================
%% State: connected
%%====================================================================

connected(enter, _OldState, _Data) ->
    keep_state_and_data;

%% --- send ------------------------------------------------------------
connected({call, From}, {send, SData, Fin},
          #h2s_data{h2_conn = H2Conn, stream_id = Sid}) ->
    EndStream = (Fin =:= fin),
    Reply = h2_call(fun() ->
        h2_connection:send_data(H2Conn, Sid, iolist_to_binary(SData), EndStream)
    end),
    {keep_state_and_data, [{reply, From, Reply}]};

connected({call, From}, {send_trailers, Trailers},
          #h2s_data{h2_conn = H2Conn, stream_id = Sid}) ->
    Reply = h2_call(fun() -> h2_connection:send_trailers(H2Conn, Sid, Trailers) end),
    {keep_state_and_data, [{reply, From, Reply}]};

connected({call, From}, {consume, NBytes},
          #h2s_data{h2_conn = H2Conn, stream_id = Sid}) ->
    Reply = h2_call(fun() -> h2_connection:consume(H2Conn, Sid, NBytes) end),
    {keep_state_and_data, [{reply, From, Reply}]};

%% --- recv ------------------------------------------------------------
connected({call, From}, {recv, _Timeout}, #h2s_data{active = Active})
  when Active =/= false ->
    {keep_state_and_data, [{reply, From, {error, {active_mode, Active}}}]};

connected({call, From}, {recv, _Timeout}, #h2s_data{recv_from = RF})
  when RF =/= undefined ->
    {keep_state_and_data, [{reply, From, {error, recv_busy}}]};

connected({call, From}, {recv, Timeout0}, Data) ->
    Timeout = recv_timeout(Timeout0, Data),
    case dequeue(Data) of
        {value, Msg, Data1} ->
            {keep_state, Data1, [{reply, From, {ok, Msg}}]};
        empty ->
            case Data#h2s_data.ended of
                true ->
                    {keep_state_and_data, [{reply, From, {error, closed}}]};
                false ->
                    {keep_state, Data#h2s_data{recv_from = From}, recv_timer(Timeout)}
            end
    end;

%% --- options ---------------------------------------------------------
connected({call, From}, {setopts, Opts}, Data) ->
    case proplists:get_value(active, Opts) of
        undefined ->
            {keep_state_and_data, [{reply, From, ok}]};
        NewActive when NewActive =:= true; NewActive =:= false; NewActive =:= once ->
            {keep_state, apply_active(NewActive, Data), [{reply, From, ok}]};
        _ ->
            {keep_state_and_data, [{reply, From, {error, badarg}}]}
    end;

connected({call, From}, {controlling_process, NewOwner}, #h2s_data{owner = OldOwner} = Data) ->
    unlink(OldOwner),
    link(NewOwner),
    {keep_state, Data#h2s_data{owner = NewOwner}, [{reply, From, ok}]};

connected({call, From}, _Request, _Data) ->
    {keep_state_and_data, [{reply, From, {error, badrequest}}]};

%% --- close -----------------------------------------------------------
connected(cast, close, Data) ->
    cancel_stream_safe(Data),
    stop_conn(Data#h2s_data.conn_pid),
    {next_state, closed, Data#h2s_data{closed_reason = closed}};

%% --- recv timeout ----------------------------------------------------
connected({timeout, recv}, recv, #h2s_data{recv_from = From} = Data)
  when From =/= undefined ->
    {keep_state, Data#h2s_data{recv_from = undefined}, [{reply, From, {error, timeout}}]};
connected({timeout, recv}, recv, _Data) ->
    keep_state_and_data;

%% --- stream events from the h2 connection ----------------------------
connected(info, {h2, H2Conn, Event}, #h2s_data{h2_conn = H2Conn} = Data) ->
    handle_h2_event(Event, Data);

%% The hackney_conn owning the h2 connection died.
connected(info, {'DOWN', Mon, process, _Pid, Reason}, #h2s_data{conn_mon = Mon} = Data) ->
    stream_ended(down_reason(Reason), Data#h2s_data{conn_pid = undefined, conn_mon = undefined});

connected(info, {'EXIT', Owner, _Reason}, #h2s_data{owner = Owner} = Data) ->
    cancel_stream_safe(Data),
    stop_conn(Data#h2s_data.conn_pid),
    {stop, normal};

connected(_, _, _) ->
    keep_state_and_data.

%%====================================================================
%% State: closed
%%====================================================================

closed(enter, _OldState, _Data) ->
    keep_state_and_data;

%% Drain whatever was buffered before the stream/connection ended.
closed({call, From}, {recv, _Timeout}, Data) ->
    case dequeue(Data) of
        {value, Msg, Data1} ->
            {keep_state, Data1, [{reply, From, {ok, Msg}}]};
        empty ->
            {keep_state_and_data, [{reply, From, {error, closed_reason(Data)}}]}
    end;

%% Once the stream has ended there is no receive window left to manage, so
%% consume is a harmless no-op (a slow reader may still be draining + acking
%% buffered chunks after the peer's END_STREAM arrived).
closed({call, From}, {consume, _NBytes}, _Data) ->
    {keep_state_and_data, [{reply, From, ok}]};

closed({call, From}, {controlling_process, NewOwner}, #h2s_data{owner = OldOwner} = Data) ->
    unlink(OldOwner),
    link(NewOwner),
    {keep_state, Data#h2s_data{owner = NewOwner}, [{reply, From, ok}]};

closed({call, From}, _Request, _Data) ->
    {keep_state_and_data, [{reply, From, {error, closed}}]};

closed(cast, close, _Data) ->
    keep_state_and_data;

closed(info, {'EXIT', Owner, _Reason}, #h2s_data{owner = Owner}) ->
    {stop, normal};

closed(_, _, _) ->
    keep_state_and_data.

%%====================================================================
%% Internal: connection + stream setup
%%====================================================================

%% @private Open a dedicated HTTP/2 connection and one handler-routed stream.
establish(Timeout, Data) ->
    #h2s_data{host = Host, port = Port, transport = Transport,
              connect_options = COpts, ssl_options = SslOpts,
              recv_timeout = RT, method = Method, path = Path,
              headers = Headers, flow_control = FC} = Data,
    ConnOpts = #{
        host => Host,
        port => Port,
        transport => Transport,
        connect_timeout => Timeout,
        recv_timeout => RT,
        connect_options => [{protocols, [http2]} | COpts],
        ssl_options => SslOpts
    },
    case hackney_conn_sup:start_conn(ConnOpts) of
        {ok, ConnPid} ->
            case hackney_conn:connect(ConnPid, Timeout) of
                ok ->
                    case hackney_conn:get_protocol(ConnPid) of
                        http2 ->
                            open_stream(ConnPid, Method, Path, Headers, FC, Data);
                        Other ->
                            stop_conn(ConnPid),
                            {error, {protocol_not_negotiated, Other}}
                    end;
                {error, Reason} ->
                    stop_conn(ConnPid),
                    {error, Reason}
            end;
        {error, Reason} ->
            {error, Reason}
    end.

open_stream(ConnPid, Method, Path, Headers, FC, Data) ->
    case hackney_conn:open_h2_stream(ConnPid, Method, Path, Headers, self(),
                                     #{flow_control => FC}) of
        {ok, H2Conn, StreamId} ->
            Mon = erlang:monitor(process, ConnPid),
            {ok, Data#h2s_data{conn_pid = ConnPid, conn_mon = Mon,
                               h2_conn = H2Conn, stream_id = StreamId}};
        {error, Reason} ->
            stop_conn(ConnPid),
            {error, Reason}
    end.

%%====================================================================
%% Internal: inbound stream events
%%====================================================================

%% @private Map an {h2, Conn, Event} stream event onto recv messages.
handle_h2_event({response, _Sid, Status, Headers}, Data) ->
    deliver({response, Status, Headers}, Data);
handle_h2_event({informational, _Sid, _Status, _Headers}, Data) ->
    {keep_state, Data};
handle_h2_event({data, _Sid, Body, false}, Data) ->
    case Body of
        <<>> -> {keep_state, Data};
        _ -> deliver({data, Body}, Data)
    end;
handle_h2_event({data, _Sid, Body, true}, Data) ->
    Data1 = case Body of
        <<>> -> Data;
        _ -> deliver_acc({data, Body}, Data)
    end,
    finish(Data1);
handle_h2_event({trailers, _Sid, Trailers}, Data) ->
    Data1 = deliver_acc({trailers, Trailers}, Data),
    finish(Data1);
handle_h2_event({stream_reset, _Sid, ErrorCode}, Data) ->
    stream_ended({stream_error, ErrorCode}, Data);
handle_h2_event({goaway, _LastSid, _ErrorCode}, #h2s_data{ended = true}) ->
    keep_state_and_data;
handle_h2_event({goaway, _LastSid, ErrorCode}, Data) ->
    stream_ended({goaway, ErrorCode}, Data);
handle_h2_event({closed, Reason}, Data) ->
    stream_ended(down_reason(Reason), Data);
handle_h2_event(_Other, Data) ->
    {keep_state, Data}.

%% @private Deliver a message, returning the gen_statem transition.
deliver(Msg, Data) ->
    {keep_state, deliver_acc(Msg, Data)}.

%% @private Deliver/buffer a message and return the updated #h2s_data only,
%% so callers can chain (e.g. data then done).
deliver_acc(Msg, #h2s_data{active = Active} = Data) when Active =/= false ->
    _ = (Data#h2s_data.owner) ! {hackney_h2, self(), Msg},
    case Active of
        once -> Data#h2s_data{active = false};
        true -> Data
    end;
deliver_acc(Msg, #h2s_data{recv_from = From} = Data) when From =/= undefined ->
    gen_statem:reply(From, {ok, Msg}),
    %% A pending {timeout, recv} may still fire; it is a no-op once recv_from
    %% is cleared (see the connected/closed {timeout, recv} clauses).
    Data#h2s_data{recv_from = undefined};
deliver_acc(Msg, Data) ->
    enqueue(Msg, Data).

%% @private Mark the stream ended and surface a terminal `done', then move to
%% closed (buffered data stays drainable).
finish(Data0) ->
    Data1 = deliver_acc(done, Data0#h2s_data{ended = true}),
    {next_state, closed, Data1#h2s_data{closed_reason = closed}}.

%% @private The stream/connection ended abnormally: fail a waiting reader,
%% notify the owner in active mode, move to closed.
stream_ended(Reason, #h2s_data{recv_from = From} = Data) when From =/= undefined ->
    _ = maybe_notify_error(Reason, Data),
    {next_state, closed,
     Data#h2s_data{recv_from = undefined, ended = true, closed_reason = Reason},
     [{reply, From, {error, Reason}}]};
stream_ended(Reason, Data) ->
    _ = maybe_notify_error(Reason, Data),
    {next_state, closed, Data#h2s_data{ended = true, closed_reason = Reason}}.

maybe_notify_error(_Reason, #h2s_data{active = false}) ->
    ok;
maybe_notify_error(closed, #h2s_data{owner = Owner}) ->
    Owner ! {hackney_h2, self(), {closed, closed}};
maybe_notify_error(Reason, #h2s_data{owner = Owner}) ->
    Owner ! {hackney_h2_error, self(), Reason}.

%%====================================================================
%% Internal: passive buffering
%%====================================================================

enqueue(Msg, #h2s_data{recv_q = Q} = Data) ->
    case add_bytes(msg_size(Msg), Data) of
        {ok, Data1} ->
            Data1#h2s_data{recv_q = queue:in(Msg, Q)};
        overflow ->
            %% Tear the connection down; recv surfaces the overflow reason.
            cancel_stream_safe(Data),
            stop_conn(Data#h2s_data.conn_pid),
            Data#h2s_data{ended = true, closed_reason = recv_buffer_overflow}
    end.

dequeue(#h2s_data{recv_q = Q} = Data) ->
    case queue:out(Q) of
        {{value, Msg}, Q1} ->
            {value, Msg, sub_bytes(msg_size(Msg), Data#h2s_data{recv_q = Q1})};
        {empty, _} ->
            empty
    end.

add_bytes(_Size, #h2s_data{max_recv_buffer = infinity} = Data) ->
    {ok, Data};
add_bytes(Size, #h2s_data{recv_bytes = B, max_recv_buffer = Max} = Data) ->
    case B + Size > Max of
        true -> overflow;
        false -> {ok, Data#h2s_data{recv_bytes = B + Size}}
    end.

sub_bytes(Size, #h2s_data{recv_bytes = B} = Data) ->
    Data#h2s_data{recv_bytes = max(0, B - Size)}.

msg_size({data, B}) -> byte_size(B);
msg_size(_) -> 0.

%% @private Apply a new active mode, flushing buffered messages on the way.
apply_active(false, Data) ->
    Data#h2s_data{active = false};
apply_active(true, #h2s_data{owner = Owner, recv_q = Q} = Data) ->
    lists:foreach(fun(Msg) -> Owner ! {hackney_h2, self(), Msg} end, queue:to_list(Q)),
    Data#h2s_data{active = true, recv_q = queue:new(), recv_bytes = 0};
apply_active(once, #h2s_data{owner = Owner} = Data) ->
    case dequeue(Data) of
        {value, Msg, Data1} ->
            Owner ! {hackney_h2, self(), Msg},
            Data1#h2s_data{active = false};
        empty ->
            Data#h2s_data{active = once}
    end.

%%====================================================================
%% Internal: helpers
%%====================================================================

recv_timer(infinity) -> [];
recv_timer(Timeout) -> [{{timeout, recv}, Timeout, recv}].

recv_timeout(default, #h2s_data{recv_timeout = T}) -> T;
recv_timeout(T, _Data) -> T.

closed_reason(#h2s_data{closed_reason = undefined}) -> closed;
closed_reason(#h2s_data{closed_reason = R}) -> R.

down_reason(normal) -> closed;
down_reason(shutdown) -> closed;
down_reason({shutdown, _}) -> closed;
down_reason(Reason) -> {closed, Reason}.

%% @private Run an h2_connection call, normalising a dead-connection exit.
h2_call(Fun) ->
    try Fun()
    catch
        exit:{ExitReason, _} -> {error, {closed, ExitReason}};
        exit:ExitReason      -> {error, {closed, ExitReason}}
    end.

cancel_stream_safe(#h2s_data{h2_conn = undefined}) ->
    ok;
cancel_stream_safe(#h2s_data{h2_conn = H2Conn, stream_id = Sid}) ->
    _ = try h2_connection:cancel_stream(H2Conn, Sid) catch _:_ -> ok end,
    ok.

stop_conn(undefined) ->
    ok;
stop_conn(ConnPid) ->
    try hackney_conn:stop(ConnPid) catch _:_ -> ok end,
    ok.
