Packages

macula

12.4.0
15.6.0 15.5.0 15.4.0 15.3.0 15.2.0 15.1.0 15.0.0 15.0.0-rc.3 15.0.0-rc.2 15.0.0-rc.1 14.11.0 14.10.0 14.9.0 14.8.2 14.8.1 14.8.0 14.7.0 14.6.0 14.5.0 14.4.0 14.3.0 14.2.1 14.2.0 14.1.0 14.0.0 13.6.0 13.5.0 13.4.0 13.3.0 13.2.2 13.2.1 13.2.0 13.1.0 13.0.1 13.0.0 12.12.0 12.11.1 12.11.0 12.10.0 12.9.1 12.9.0 12.8.0 12.7.0 12.6.0 12.5.1 12.5.0 12.4.0 12.3.0 12.2.1 12.2.0 12.1.0 12.0.0 11.5.0 11.4.0 11.3.1 11.3.0 11.2.0 11.1.0 11.0.0 10.25.0 10.24.0 10.23.0 10.22.0 10.21.0 10.20.3 10.20.2 10.20.0 10.19.2 10.19.1 10.19.0 10.18.0 10.17.0 10.16.0 10.15.0 10.14.5 10.14.4 10.14.2 10.14.1 10.14.0 10.13.2 10.13.1 10.11.0 10.10.2 10.10.1 10.10.0 10.9.1 10.9.0 10.8.0 10.7.0 10.5.8 10.5.7 10.5.6 10.5.5 10.5.4 10.5.3 10.5.2 10.5.1 10.5.0 10.4.0 10.2.0 10.1.1 10.1.0 10.0.2 10.0.1 10.0.0 9.13.8 9.8.2 9.8.1 9.8.0 9.5.0 9.4.0 9.3.1 9.3.0 9.2.0 9.1.1 9.1.0 9.0.0 8.7.0 8.6.0 8.5.0 8.4.1 8.4.0 8.3.0 8.2.0 8.1.0 8.0.2 8.0.1 8.0.0 7.1.0 7.0.0 6.0.0 5.2.2 5.2.1 5.2.0 5.1.0 5.0.0 4.8.0 4.7.1 4.7.0 4.6.0 4.5.0 4.4.10 4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.1 4.3.0 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.1 4.1.0 4.0.0 3.16.0 3.15.3 3.15.2 3.15.1 3.14.0 3.13.0 3.12.1 3.12.0 3.11.1 3.11.0 3.10.3 3.10.2 3.10.1 3.9.0 3.8.0 3.7.0 3.5.0 3.4.0 3.3.0 3.2.0 3.1.0 3.0.0 2.1.1 2.1.0 2.0.0 1.5.2 1.5.1 1.4.30 1.4.29 1.4.28 1.4.27 1.4.26 1.4.25 1.4.24 1.4.23 1.4.22 1.4.21 1.4.20 1.4.19 1.4.18 1.4.17 1.4.16 1.4.15 1.4.14 1.4.13 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.1 1.3.0 1.2.0 1.1.0 1.0.10 1.0.9 1.0.8 1.0.7 1.0.6 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 1.0.0 0.48.6 0.48.5 0.48.4 0.48.3 0.48.2 0.48.1 0.48.0 0.47.1 0.47.0 0.46.3 0.46.1 0.46.0 0.45.3 0.45.2 0.45.1 0.45.0 0.44.2 0.44.1 0.44.0 0.43.3 0.43.2 0.43.1 0.43.0 0.42.9 0.42.8 0.42.7 0.42.6 0.42.5 0.42.4 0.42.3 0.42.2 0.42.1 0.42.0 0.41.1 0.41.0 0.40.1 0.40.0 0.39.9 0.39.8 0.39.7 0.39.6 0.39.5 0.39.4 0.39.3 0.39.2 0.39.1 0.39.0 0.38.8 0.38.7 0.38.6 0.38.5 0.38.4 0.38.3 0.38.2 0.38.1 0.38.0 0.37.7 0.37.6 0.37.5 0.37.4 0.37.3 0.37.2 0.37.1 0.37.0 0.36.6 0.36.5 0.36.4 0.36.3 0.36.2 0.36.1 0.36.0 0.35.4 0.35.3 0.35.2 0.35.1 0.35.0 0.34.1 0.34.0 0.33.1 0.33.0 0.32.5 0.32.4 0.32.3 0.32.2 0.32.1 0.32.0 0.31.9 0.31.8 0.31.7 0.31.6 0.31.5 0.31.4 0.31.3 0.31.2 0.31.1 0.31.0 0.30.10 0.30.9 0.30.8 0.30.7 0.30.6 0.30.5 0.30.4 0.30.3 0.30.2 0.30.1 0.30.0 0.29.0 0.28.3 0.28.2 0.28.1 0.28.0 0.27.1 0.27.0 0.26.1 0.26.0 0.25.6 0.25.5 0.25.4 0.25.3 0.25.2 0.25.1 0.25.0 0.24.6 0.24.5 0.24.4 0.24.3 0.24.2 0.24.1 0.24.0 0.23.3 0.23.2 0.23.1 0.23.0 0.22.12 0.22.11 0.22.10 0.22.9 0.22.8 0.22.7 0.22.6 0.22.5 0.22.4 0.22.3 0.22.2 0.22.1 0.22.0 0.21.7 0.21.6 0.21.5 0.21.4 0.21.2 0.21.1 0.21.0 0.20.25 0.20.24 0.20.23 0.20.22 0.20.21 0.20.20 0.20.19 0.20.18 0.20.17 0.20.16 0.20.15 0.20.14 0.20.13 0.20.12 0.20.11 0.20.10 0.20.9 0.20.8 0.20.7 0.20.6 0.20.5 0.20.3 0.20.2 0.20.1 0.20.0 0.19.2 0.19.1 0.19.0 0.18.1 0.18.0 0.17.4 0.17.3 0.17.2 0.17.1 0.17.0 0.16.6 0.16.5 0.16.4 0.16.3 0.16.2 0.16.1 0.16.0 0.15.1 0.15.0 0.14.3 0.14.2 0.14.1 0.14.0 0.12.6 0.12.5 0.12.3 0.11.3 0.10.2 0.10.1 0.10.0 0.9.2 0.9.1 0.9.0 0.8.25 0.8.24 0.8.23 0.8.22 0.8.21 0.8.20 0.8.19 0.8.18 0.8.17 0.8.16 0.8.15 0.8.14 0.8.13 0.8.12 0.8.11 0.8.10 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.3 0.8.2 0.8.1 0.8.0 0.7.30 0.7.29 0.7.28 0.7.27 0.7.26 0.7.25 0.7.24 0.7.23 0.7.22 0.7.21 0.7.20 0.7.19 0.7.18 0.7.17 0.7.16 0.7.15 0.7.14 0.7.13 0.7.12 0.7.11 0.7.10 0.7.9 0.7.8 0.7.7 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.7 0.6.6 0.6.5 0.6.4 0.6.3 0.6.2 0.6.1 0.6.0 0.5.0 0.4.4 0.4.3 0.4.2 0.4.1 0.4.0 0.3.4 0.3.3 0.3.2 0.3.1

Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise

Current section

Files

Jump to
macula src macula_content_transfer.erl
Raw

src/macula_content_transfer.erl

%%%-------------------------------------------------------------------
%%% @doc Addressable content-store put/get, with a real, peer-visible
%%% abort, real pause/resume, and (for chunked content) parallel
%%% multi-stream chunk transfer — the foundation `macula_feeder'/
%%% `macula_download' (and, later, `macula_pusher'/`macula_upload')
%%% build on. See PLAN_PUSH_UPLOAD.md, Phases 1-3.
%%%
%%% `macula:put_content/2'/`get_content/2' are ONE opaque blocking
%%% call each: pick a link, open a dedicated content stream, run the
%%% transfer, close the stream — no handle exists mid-transfer, so
%%% cancelling meant killing whatever process happened to be blocked
%%% in the call. That killed the caller's wait, but never touched the
%%% underlying stream: `macula_station_link' — not the killed process
%%% — owns the stream's `content_stream_bufs'/`content_pending' state,
%%% so a `terminate/2' that never runs (because the blocking call's
%%% own process was `exit(_, kill)'d mid-`gen_server:call') leaked
%%% that state on the link forever, cleaned up only by the eventual
%%% `content_call_timeout' firing against an already-dead caller.
%%%
%%% This module owns the picked link and the open stream itself, in
%%% its own gen_server state, updated as soon as a worker process
%%% resolves them — so `cancel/1,3' can always reach in and tear the
%%% stream down explicitly, from any point in the transfer's
%%% lifecycle, with nothing left to time out.
%%%
%%% == Cancel is a real abort, not a dropped connection ==
%%%
%%% `cancel/3' resets every currently-open content stream's send side
%%% via `macula_quic:reset_stream/2' (through macula_station_link's
%%% abort_content_stream) — a QUIC RESET_STREAM frame the PEER's
%%% own read genuinely observes as `{quic, stream_closed, PeerStream,
%%% {reset, Code}}', not merely a connection that went away. This is
%%% NOT `macula_stream:abort/3' (streaming RPC's abort) — that targets
%%% a `macula_stream' gen_server's own STREAM_ERROR application
%%% framing, and a content-transfer stream is not one of those; it is
%%% a raw QUIC dedicated stream owned directly by `macula_station_link'
%%% (see that module's `open_content_stream/1'). The two "stream"
%%% concepts share a name and nothing else — do not reuse
%%% `macula_stream:abort/3' here.
%%%
%%% == Lifecycle ==
%%%
%%% `start_put/2,3', `start_put_station/4,5', `start_get/2,3',
%%% `start_get_station/4,5' return `{ok, Pid}' immediately; the
%%% resolve/dial sequence runs in a linked worker. `await/1,2' blocks
%%% for the outcome (`{ok, Mcid}' / `{ok, Bytes}' / `{error, Reason}')
%%% — repeatable and from any process; the result is cached once
%%% known. The process does NOT self-terminate on completion (a second
%%% `await/1' after success must still answer) — call `cancel/1' when
%%% done with the handle, whether the transfer succeeded, failed, or
%%% is still in flight; on an already-resolved transfer this is a pure
%%% reap (nothing left to abort).
%%%
%%% == Pause/resume (chunked content only) ==
%%%
%%% Single-block content is one wire round trip — there is no "between
%%% chunks" for it to pause at, so `pause/1' on a single-block transfer
%%% is a harmless no-op (the transfer just runs to completion). For
%%% chunked content, each chunk's own put/get is still ONE uninterrupted
%%% blocking call underneath (pausing mid-chunk would leave a half-sent
%%% block the station can't verify) — what `pause/1' actually controls
%%% is whether the NEXT chunk starts, on EVERY stream, once whichever
%%% chunk is currently in flight on it finishes. `resume/1' re-arms
%%% every stream from exactly its own next un-sent/un-fetched chunk,
%%% never from the start. `cancel/1,3' still works at any point, paused
%%% or not — every stream with a chunk step in flight has it killed and
%%% reset exactly as described above; a stream idle between chunks
%%% (nothing in flight) just gets its stream reset directly.
%%%
%%% == Multi-stream parallel chunk transfer (chunked content only) ==
%%%
%%% Chunks are distributed round-robin (`Index rem StreamCount') across
%%% up to `stream_count' dedicated content streams on the SAME link
%%% (`Opts''s `stream_count' key, default 4, capped at the actual chunk
%%% count so a 2-chunk transfer never opens more than 2 streams) —
%%% each stream runs its own independent chunk-by-chunk loop
%%% concurrently, all driven by this ONE gen_server via
%%% `handle_continue/2' (never by the streams' own worker processes,
%%% which each do exactly one network call and report back). The
%%% manifest is put (or, for a get, its chunks reassembled and
%%% verified) only once every stream has drained its own share. A get
%%% doesn't know the chunk count — and therefore how many streams are
%%% worth opening — until its manifest is fetched, so it starts on the
%%% ONE stream the connect step already opened and expands to more once
%%% the count is known; a put knows upfront and opens every extra
%%% stream immediately. If opening an extra stream fails, this degrades
%%% gracefully to fewer streams rather than failing the transfer — a
%%% single-stream transfer is still a correct, if slower, one. A single
%%% stream's own chunk failing (a genuine `{error, _}', not a crash)
%%% fails the WHOLE transfer, same as a sequential transfer would —
%%% every other stream's in-flight work is killed and every stream
%%% reset before the caller's `await/1,2' sees the error.
%%%
%%% == Link I/O ==
%%%
%%% A transfer picks or dials its link, and opens, calls on, closes and
%%% aborts its content streams, through six functions:
%%% `pick_connected_link/1' and `ensure_station_link/4', the
%%% `macula_client' ones by default, and `open_content_stream/1',
%%% `call_on_stream/6', `close_content_stream/2' and
%%% `abort_content_stream/4', the `macula_station_link' ones. The start
%%% functions take all six in a `link_io' option, to run a transfer on
%%% something else, such as a test's scripted link. A set without one of
%%% them, with a function of another arity or with a key outside the six
%%% is refused with `function_clause', in the caller.
%%%
%%% == Transfer I/O ==
%%%
%%% A feeder or a download starts, awaits and cancels its transfer through
%%% `transfer_io()' functions: `start_put/3', `start_put_station/5',
%%% `start_get/3' and `start_get_station/5', `await/1' and `cancel/1',
%%% this module's own by default. Each takes the ones it calls in a
%%% `transfer_io' start option, checked by `transfer_io/2'.
%%%
%%% == Correlation-id registry ==
%%%
%%% Each transfer mints a `share_id' (`crypto:strong_rand_bytes(16)',
%%% overridable via `Opts''s `share_id' key so a wrapper that already
%%% publishes it in a `sharing.*_started_v1' mesh fact — see
%%% `macula_feeder'/`macula_download' — can keep the same id) and
%%% registers it in `macula_content_transfer_registry', so a caller
%%% that only saw the id in a published fact, not the pid, can still
%%% resolve it to `cancel/1,3'.
%%% @end
%%%-------------------------------------------------------------------
-module(macula_content_transfer).

-include("macula_quic_error_codes.hrl").

-behaviour(gen_server).

-export([start_put/2, start_put/3,
         start_put_station/4, start_put_station/5,
         start_get/2, start_get/3,
         start_get_station/4, start_get_station/5]).
-export([await/1, await/2, cancel/1, cancel/3, pause/1, resume/1, share_id/1]).

%% The check of the transfer functions a feeder or a download is given.
-export([transfer_io/2]).

-export([init/1, handle_call/3, handle_cast/2, handle_info/2, handle_continue/2]).

-ifdef(TEST).
-export([verify_block_hash/2]).
-endif.

-define(CONTENT_REALM,                 <<0:256>>).
-define(CONTENT_PUT_BLOCK_PROC,        <<"_content.put_block">>).
-define(CONTENT_GET_BLOCK_PROC,        <<"_content.get_block">>).
-define(CONTENT_PUT_MANIFEST_PROC,     <<"_content.put_manifest">>).
-define(CONTENT_GET_MANIFEST_PROC,     <<"_content.get_manifest">>).
%% Bigger timeout than DHT records — chunks are 256 KiB and a put
%% writes through the file-backed store on the relay.
-define(CONTENT_BLOCK_TIMEOUT_MS,      15_000).
-define(CONTENT_MANIFEST_TIMEOUT_MS,   5_000).
-define(CONTENT_RETRY_BACKOFF_MS,      200).
%% Default parallelism for a chunked transfer's streams — see the
%% moduledoc's "Multi-stream" section. Overridable per transfer via
%% `Opts''s `stream_count' key; always clamped to at least 1 and at
%% most the actual chunk count regardless of what's requested.
-define(DEFAULT_STREAM_COUNT, 4).

-type kind() :: put | get.
-type dial() :: {pooled, macula:pool()}
              | {station, macula:pool(), macula_client:seed(), pos_integer(), map()}.

%% The functions a transfer reaches its link with, by key; see "Link I/O"
%% in the module doc.
-type link_io() :: #{pick_connected_link => fun((macula:pool()) -> {ok, pid()} | {error, term()}),
                     ensure_station_link => fun((macula:pool(), macula_client:seed(), map(),
                                                 pos_integer()) -> {ok, pid()} | {error, term()}),
                     open_content_stream => fun((pid()) -> {ok, reference()} | {error, term()}),
                     call_on_stream => fun((pid(), reference(), binary(), binary(), term(),
                                            timeout()) -> {ok, term()} | {error, term()}),
                     close_content_stream => fun((pid(), reference()) -> term()),
                     abort_content_stream => fun((pid(), reference(), term(), binary()) -> term())}.

%% The functions a feeder or a download starts, awaits and cancels its
%% transfer with, by key; see "Transfer I/O" in the module doc.
-type transfer_io() :: #{start_put => fun((macula:pool(), binary(), map()) -> {ok, pid()}),
                         start_put_station => fun((macula:pool(), macula_client:seed(), binary(),
                                                   pos_integer(), map()) -> {ok, pid()}),
                         start_get => fun((macula:pool(), macula:mcid(), map()) -> {ok, pid()}),
                         start_get_station => fun((macula:pool(), macula_client:seed(),
                                                   macula:mcid(), pos_integer(), map()) ->
                                                      {ok, pid()}),
                         await => fun((pid()) -> {ok, term()} | {error, term()}),
                         cancel => fun((pid()) -> ok)}.

-export_type([link_io/0, transfer_io/0]).

%% One dedicated content stream's own independent chunk-by-chunk queue.
%% `remaining' holds items not yet dispatched (PUT: `{Index, Bytes}';
%% GET: plain `Index'); `in_flight' is the ONE item currently out for
%% its network round trip (or `undefined'), tracked separately rather
%% than left at the head of `remaining' so a step's outcome doesn't
%% need to echo its own item back — the gen_server already knows what
%% it dispatched. A lane is done for good once `remaining = []',
%% `in_flight = undefined', `worker = undefined'.
-record(lane, {
    stream    :: reference(),
    remaining :: [term()],
    in_flight :: term() | undefined,
    worker    :: pid() | undefined
}).

%% Chunked-transfer driving state — `undefined' in `#state.chunk' for a
%% single-block transfer (nothing to drive between chunks) and for a
%% chunked one until its content stream is open.
%%
%% put: `manifest' is known upfront (pure, `macula_manifest:create/1',
%% no network) and so are all `lanes' — every chunk is assigned to one
%% immediately (see `content_link_chunked/4'). `lanes' shrinking to "all
%% empty and idle" (`lane_done/1') triggers the final put_manifest step.
%%
%% get: `manifest' and `lanes' both start `undefined' — fetching the
%% manifest (on the one stream the connect step already opened) IS the
%% first step; only once it's back, and `chunk_count' with it, can
%% `lanes' be set up and chunks distributed. `acc' accumulates fetched
%% chunks keyed by index (not arrival order — different lanes finish in
%% whatever order their own network calls happen to complete in), so
%% reassembly reads it back out in the correct 0..chunk_count-1 order
%% regardless of which lane delivered which chunk first.
-record(chunk, {
    manifest    :: map() | undefined,
    lanes       :: [#lane{}] | undefined,
    chunk_count :: non_neg_integer() | undefined,
    acc         :: #{non_neg_integer() => binary()}
}).

-record(state, {
    kind         :: kind(),
    payload      :: binary(),         % put: Bytes; get: Mcid
    share_id     :: binary(),
    link_io      :: link_io(),
    link_pid     :: pid() | undefined,
    stream       :: reference() | undefined,
    worker       :: pid() | undefined,
    result       :: {ok, term()} | {error, term()} | undefined,
    waiters      :: [gen_server:from()],
    paused       :: boolean(),
    chunk        :: #chunk{} | undefined,
    stream_count :: pos_integer()
}).

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

%% @doc Start an addressable put through the pool's own connected
%% link (whichever `macula_client:pick_connected_link/1' picks).
-spec start_put(macula:pool(), binary()) -> {ok, pid()}.
start_put(Pool, Bytes) -> start_put(Pool, Bytes, #{}).

%% @doc As `start_put/2'. `Opts' may carry `share_id' (binary,
%% overrides the minted default) and `stream_count' (positive integer,
%% default 4 — see the moduledoc's "Multi-stream" section; irrelevant
%% for a single-block transfer). `link_io' gives the functions it reaches
%% its link with (see "Link I/O" above).
-spec start_put(macula:pool(), binary(), map()) -> {ok, pid()}.
start_put(Pool, Bytes, Opts) when is_pid(Pool), is_binary(Bytes), is_map(Opts) ->
    start(put, {pooled, Pool}, Bytes, Opts).

%% @doc As `start_put/2', dialing `Station' directly (reusing a live
%% link or dialing + waiting up to `TimeoutMs' for one) instead of
%% picking from the pool's existing links — the addressable
%% counterpart to `macula:put_content_station/4'.
-spec start_put_station(macula:pool(), macula_client:seed(), binary(),
                        pos_integer()) -> {ok, pid()}.
start_put_station(Pool, Station, Bytes, TimeoutMs) ->
    start_put_station(Pool, Station, Bytes, TimeoutMs, #{}).

%% @doc As `start_put_station/4'. `Opts' may carry `share_id',
%% `stream_count' (see `start_put/3') plus the station this dial must
%% prove, `expected_node_id' (see `macula:put_content_station/5').
%% `pin_tls_cert' and `verify' are read by nothing, and `macula' refuses
%% them; see `macula:trust_options_checked/1'.
-spec start_put_station(macula:pool(), macula_client:seed(), binary(),
                        pos_integer(), map()) -> {ok, pid()}.
start_put_station(Pool, Station, Bytes, TimeoutMs, Opts)
  when is_pid(Pool), is_binary(Bytes), is_integer(TimeoutMs), TimeoutMs > 0,
       is_map(Opts) ->
    LinkOpts = maps:with([expected_node_id], Opts),
    start(put, {station, Pool, Station, TimeoutMs, LinkOpts}, Bytes, Opts).

%% @doc Start an addressable get through the pool's own connected
%% link. See `macula:get_content/2'.
-spec start_get(macula:pool(), macula:mcid()) -> {ok, pid()}.
start_get(Pool, Mcid) -> start_get(Pool, Mcid, #{}).

%% @doc As `start_get/2'. `Opts' may carry `share_id' and
%% `stream_count' (see `start_put/3'). `Mcid' is the SHA-384 id of a
%% single block or of a manifest; an id of any other shape is refused with
%% function_clause, in the caller, and no transfer starts.
-spec start_get(macula:pool(), macula:mcid(), map()) -> {ok, pid()}.
start_get(Pool, <<2, Codec, _:48/binary>> = Mcid, Opts)
  when is_pid(Pool), (Codec =:= 16#55 orelse Codec =:= 16#56), is_map(Opts) ->
    start(get, {pooled, Pool}, Mcid, Opts).

%% @doc As `start_get/2', dialing `Station' directly — the addressable
%% counterpart to `macula:get_content_station/4'.
-spec start_get_station(macula:pool(), macula_client:seed(), macula:mcid(),
                        pos_integer()) -> {ok, pid()}.
start_get_station(Pool, Station, Mcid, TimeoutMs) ->
    start_get_station(Pool, Station, Mcid, TimeoutMs, #{}).

%% @doc As `start_get_station/4'. `Opts' as `start_put_station/5'. An
%% `Mcid' of another shape is refused as by `start_get/3'.
-spec start_get_station(macula:pool(), macula_client:seed(), macula:mcid(),
                        pos_integer(), map()) -> {ok, pid()}.
start_get_station(Pool, Station, <<2, Codec, _:48/binary>> = Mcid, TimeoutMs, Opts)
  when is_pid(Pool), (Codec =:= 16#55 orelse Codec =:= 16#56),
       is_integer(TimeoutMs), TimeoutMs > 0, is_map(Opts) ->
    LinkOpts = maps:with([expected_node_id], Opts),
    start(get, {station, Pool, Station, TimeoutMs, LinkOpts}, Mcid, Opts).

%% @doc Block for the transfer's outcome: `{ok, Mcid}' (put),
%% `{ok, Bytes}' (get), or `{error, Reason}'. Safe to call more than
%% once, from more than one process, before or after the result is
%% known.
-spec await(pid()) -> {ok, term()} | {error, term()}.
await(Pid) -> await(Pid, infinity).

%% @doc As `await/1' with an explicit timeout on THIS call only — a
%% timeout here does not cancel the transfer itself.
-spec await(pid(), timeout()) -> {ok, term()} | {error, term()}.
await(Pid, Timeout) -> gen_server:call(Pid, await, Timeout).

%% @doc As `cancel/3', with the cancelled code (0,
%% `?QUIC_CODE_CANCELLED') and the message `cancelled'.
-spec cancel(pid()) -> ok.
cancel(Pid) -> cancel(Pid, ?QUIC_CODE_CANCELLED, <<"cancelled">>).

%% @doc Cancel `Pid''s transfer and reap the process. Resets every
%% currently-open content stream with `Code' — genuinely peer-visible,
%% see the moduledoc. `Message' is local-only (logged at the link; QUIC
%% RESET_STREAM carries only the numeric code on the wire). If the
%% transfer already resolved (success or failure), this is a pure reap
%% — nothing left to abort. Either way `await/1,2' answers
%% `{error, cancelled}' to anyone still waiting.
-spec cancel(pid(), non_neg_integer(), binary()) -> ok.
cancel(Pid, Code, Message)
  when is_integer(Code), Code >= 0, is_binary(Message) ->
    gen_server:call(Pid, {cancel, Code, Message}).

%% @doc Pause a chunked transfer between chunks, on every stream — each
%% stream's in-flight chunk (if any) still completes, no stream closes,
%% but none of them starts its next chunk until `resume/1'. A no-op on
%% a single-block transfer or one that has already resolved (nothing to
%% pause either way).
-spec pause(pid()) -> ok.
pause(Pid) -> gen_server:call(Pid, pause).

%% @doc Resume a transfer paused via `pause/1' — every stream continues
%% from exactly its own next un-sent/un-fetched chunk, never from the
%% start. A no-op if not actually paused, not chunked, or already
%% resolved.
-spec resume(pid()) -> ok.
resume(Pid) -> gen_server:call(Pid, resume).

%% @doc This transfer's `share_id', for publishing in a mesh fact or
%% looking itself up later via `macula_content_transfer_registry'.
-spec share_id(pid()) -> binary().
share_id(Pid) -> gen_server:call(Pid, share_id).

%% A transfer starts on link functions link_io/2 accepts; any other is
%% refused with function_clause, in the caller.
start(Kind, Dial, Payload, Opts) ->
    LinkIo = link_io(default_link_io(), maps:get(link_io, Opts, undefined)),
    gen_server:start_link(?MODULE, {Kind, Dial, Payload, LinkIo, Opts}, []).

%% The link functions a transfer runs on: `Defaults', or `Given' when it
%% has every key in `Defaults', each function at the arity its key takes,
%% and no key outside `link_io()'. Any other set is refused with
%% function_clause.
link_io(Defaults, undefined) when is_map(Defaults) ->
    Defaults;
link_io(Defaults, Given) when is_map(Defaults), is_map(Given) ->
    ok = maps:foreach(fun link_function/2, Given),
    ok = lists:foreach(fun(Key) -> given_key(Key, Given) end, maps:keys(Defaults)),
    Given.

link_function(pick_connected_link, Fun) when is_function(Fun, 1) -> ok;
link_function(ensure_station_link, Fun) when is_function(Fun, 4) -> ok;
link_function(open_content_stream, Fun) when is_function(Fun, 1) -> ok;
link_function(call_on_stream, Fun) when is_function(Fun, 6) -> ok;
link_function(close_content_stream, Fun) when is_function(Fun, 2) -> ok;
link_function(abort_content_stream, Fun) when is_function(Fun, 4) -> ok.

given_key(Key, Given) when is_map_key(Key, Given) -> ok.

%% @doc The transfer functions a feeder or a download runs on. `Defaults'
%% are the ones it calls, by key, and `Given' the `transfer_io' its caller
%% gave, or `undefined' for none, which gives `Defaults'. A given set has
%% every key in `Defaults', each function at the arity its key takes, and
%% may carry other `transfer_io()' functions; any other is refused with
%% `function_clause', in the calling process.
-spec transfer_io(transfer_io(), transfer_io() | undefined) -> transfer_io().
transfer_io(Defaults, undefined) when is_map(Defaults) ->
    Defaults;
transfer_io(Defaults, Given) when is_map(Defaults), is_map(Given) ->
    ok = maps:foreach(fun transfer_function/2, Given),
    ok = lists:foreach(fun(Key) -> given_key(Key, Given) end, maps:keys(Defaults)),
    Given.

transfer_function(start_put, Fun) when is_function(Fun, 3) -> ok;
transfer_function(start_put_station, Fun) when is_function(Fun, 5) -> ok;
transfer_function(start_get, Fun) when is_function(Fun, 3) -> ok;
transfer_function(start_get_station, Fun) when is_function(Fun, 5) -> ok;
transfer_function(await, Fun) when is_function(Fun, 1) -> ok;
transfer_function(cancel, Fun) when is_function(Fun, 1) -> ok.

default_link_io() ->
    #{pick_connected_link => fun macula_client:pick_connected_link/1,
      ensure_station_link => fun macula_client:ensure_station_link/4,
      open_content_stream => fun macula_station_link:open_content_stream/1,
      call_on_stream => fun macula_station_link:call_on_stream/6,
      close_content_stream => fun macula_station_link:close_content_stream/2,
      abort_content_stream => fun macula_station_link:abort_content_stream/4}.

%%%===================================================================
%%% gen_server callbacks
%%%===================================================================

%% @private
init({Kind, Dial, Payload, LinkIo, Opts}) ->
    process_flag(trap_exit, true),
    ShareId = maps:get(share_id, Opts, crypto:strong_rand_bytes(16)),
    StreamCount = maps:get(stream_count, Opts, ?DEFAULT_STREAM_COUNT),
    ok = macula_content_transfer_registry:register_share(ShareId, self()),
    Self = self(),
    Worker = spawn_link(fun() -> connect_and_run(Self, LinkIo, Kind, Dial, Payload) end),
    {ok, #state{kind = Kind, payload = Payload, share_id = ShareId, link_io = LinkIo,
               worker = Worker, waiters = [], paused = false, chunk = undefined,
               stream_count = StreamCount}}.

%% @private
handle_call(await, From, #state{result = undefined, waiters = Waiters} = State) ->
    {noreply, State#state{waiters = [From | Waiters]}};
handle_call(await, _From, #state{result = Result} = State) ->
    {reply, Result, State};
handle_call(share_id, _From, #state{share_id = Id} = State) ->
    {reply, Id, State};
handle_call(pause, _From, State) ->
    {reply, ok, State#state{paused = true}};
handle_call(resume, _From, #state{paused = true, chunk = Chunk, result = undefined} = State)
        when Chunk =/= undefined ->
    {reply, ok, State#state{paused = false}, {continue, next_step}};
handle_call(resume, _From, State) ->
    {reply, ok, State#state{paused = false}};
handle_call({cancel, _Code, _Message}, _From, #state{result = Result} = State)
        when Result =/= undefined ->
    {stop, normal, ok, State};
handle_call({cancel, Code, Message}, _From,
            #state{worker = Worker, link_io = LinkIo, link_pid = LinkPid, stream = Stream,
                  chunk = Chunk, waiters = Waiters} = State) ->
    kill_worker(Worker),
    kill_lane_workers(Chunk),
    abort_all_streams(LinkIo, LinkPid, Stream, Chunk, Code, Message),
    [gen_server:reply(From, {error, cancelled}) || From <- Waiters],
    {stop, normal, ok, State#state{result = {error, cancelled}, waiters = []}};
handle_call(_Request, _From, State) ->
    {reply, {error, unsupported}, State}.

%% @private
handle_cast(_Msg, State) -> {noreply, State}.

%% @private
%% The connect worker's link/stream — for a single-block transfer it
%% keeps running (unchanged since Phase 1: transfers, closes, reports
%% `content_result' itself). For chunked content it stops here and
%% hands off: this process starts driving the chunk-by-chunk loop(s).
handle_info({content_link, LinkPid, Stream}, #state{kind = Kind, payload = Payload} = State) ->
    NewState = State#state{link_pid = LinkPid, stream = Stream},
    case is_chunked(Kind, Payload) of
        true  -> content_link_chunked(Kind, LinkPid, Stream, Payload, NewState);
        false -> {noreply, NewState}
    end;
%% Single-block path only (chunked finalizes via `finalize/2' instead).
handle_info({content_result, Result}, #state{waiters = Waiters} = State) ->
    [gen_server:reply(From, Result) || From <- Waiters],
    {noreply, State#state{result = Result, waiters = []}};
%% One lane's step outcome — identified by which stream reported it,
%% since (unlike Phase 1-2) more than one step can be in flight at
%% once, one per open stream.
handle_info({lane_step_result, Stream, Outcome}, #state{chunk = #chunk{lanes = Lanes}} = State)
        when Lanes =/= undefined ->
    lane_result(lists:keyfind(Stream, #lane.stream, Lanes), Outcome, State);
%% A single (non-lane) step's outcome — the get-manifest-fetch
%% bootstrap, or the put-manifest finalize. There is only ever one of
%% these in flight at a time.
handle_info({step_result, Outcome}, #state{worker = Worker} = State)
        when is_pid(Worker) ->
    step_result(Outcome, State#state{worker = undefined});
handle_info({'EXIT', Worker, Reason}, #state{result = undefined} = State)
        when Reason =/= normal ->
    exit_from_tracked_worker(is_tracked_worker(Worker, State), Reason, State);
handle_info(_Msg, State) ->
    {noreply, State}.

exit_from_tracked_worker(true, Reason, State) ->
    {stop, {worker_crashed, Reason}, State};
exit_from_tracked_worker(false, _Reason, State) ->
    {noreply, State}.

is_tracked_worker(Worker, #state{worker = Worker}) ->
    true;
is_tracked_worker(Worker, #state{chunk = #chunk{lanes = Lanes}}) when Lanes =/= undefined ->
    lists:keymember(Worker, #lane.worker, Lanes);
is_tracked_worker(_Worker, _State) ->
    false.

%% @private
handle_continue(next_step, #state{paused = true} = State) ->
    {noreply, State};
handle_continue(next_step, #state{chunk = #chunk{lanes = undefined}} = State) ->
    %% Only a get reaches this: its manifest isn't fetched yet, so
    %% there's nothing to distribute across streams yet either.
    dispatch_get_manifest_step(State);
handle_continue(next_step, State) ->
    dispatch_lanes_or_finish(State).

kill_worker(Worker) when is_pid(Worker) ->
    unlink(Worker),
    exit(Worker, kill);
kill_worker(undefined) ->
    ok.

kill_lane_workers(undefined) -> ok;
kill_lane_workers(#chunk{lanes = undefined}) -> ok;
kill_lane_workers(#chunk{lanes = Lanes}) ->
    lists:foreach(fun(#lane{worker = W}) -> kill_worker(W) end, Lanes).

abort_stream_if_open(#{abort_content_stream := Abort}, LinkPid, Stream, Code, Message)
  when is_pid(LinkPid), is_reference(Stream) ->
    Abort(LinkPid, Stream, Code, Message);
abort_stream_if_open(_LinkIo, _LinkPid, _Stream, _Code, _Message) ->
    ok.

abort_all_streams(LinkIo, LinkPid, PrimalStream, undefined, Code, Message) ->
    abort_stream_if_open(LinkIo, LinkPid, PrimalStream, Code, Message);
abort_all_streams(LinkIo, LinkPid, PrimalStream, #chunk{lanes = undefined}, Code, Message) ->
    abort_stream_if_open(LinkIo, LinkPid, PrimalStream, Code, Message);
abort_all_streams(LinkIo, LinkPid, _PrimalStream, #chunk{lanes = Lanes}, Code, Message) ->
    lists:foreach(fun(#lane{stream = S}) ->
        abort_stream_if_open(LinkIo, LinkPid, S, Code, Message)
    end, Lanes).

%%%===================================================================
%%% Connect worker — resolve link, open the FIRST content stream. A
%%% single-block transfer keeps running in this same process; a
%%% chunked one hands off to the gen_server as soon as `content_link'
%%% is sent (see `handle_info/2' above) and this function returns
%%% right after — any additional streams a chunked transfer wants are
%%% opened by the gen_server itself (see `content_link_chunked/4' /
%%% `open_extra_streams/2'), not by this worker.
%%%===================================================================

-spec connect_and_run(pid(), link_io(), kind(), dial(), binary()) -> term().
connect_and_run(Parent, LinkIo, Kind, Dial, Payload) ->
    case connect(LinkIo, Dial) of
        {ok, LinkPid, Stream} ->
            Parent ! {content_link, LinkPid, Stream},
            run_if_single_block(is_chunked(Kind, Payload), Parent, LinkIo, Kind, LinkPid, Stream,
                                Payload);
        {error, _} = E ->
            Parent ! {content_result, E}
    end.

run_if_single_block(true, _Parent, _LinkIo, _Kind, _LinkPid, _Stream, _Payload) ->
    ok;
run_if_single_block(false, Parent, #{close_content_stream := Close} = LinkIo, Kind, LinkPid, Stream,
                    Payload) ->
    Result = transfer(LinkIo, Kind, LinkPid, Stream, Payload),
    try Close(LinkPid, Stream)
    catch _:_ -> ok end,
    Parent ! {content_result, Result}.

connect(#{pick_connected_link := PickConnectedLink} = LinkIo, {pooled, Pool}) ->
    open_on_link(LinkIo, PickConnectedLink(Pool));
connect(#{ensure_station_link := EnsureContentLink} = LinkIo,
        {station, Pool, Station, TimeoutMs, LinkOpts}) ->
    open_on_link(LinkIo, EnsureContentLink(Pool, Station, LinkOpts, TimeoutMs)).

open_on_link(_LinkIo, {error, _} = E) -> E;
open_on_link(#{open_content_stream := OpenContentStream}, {ok, LinkPid}) ->
    stream_opened(OpenContentStream(LinkPid), LinkPid).

stream_opened({ok, Stream}, LinkPid) -> {ok, LinkPid, Stream};
stream_opened({error, _} = E, _LinkPid) -> E.

%% @doc Known upfront, no network needed: put by size against the
%% chunk threshold, get by the MCID's own codec byte.
is_chunked(put, Bytes) -> byte_size(Bytes) > macula_manifest:default_chunk_size();
is_chunked(get, <<2, 16#56, _:48/binary>>) -> true;
is_chunked(get, <<2, 16#55, _:48/binary>>) -> false.

transfer(LinkIo, put, LinkPid, Stream, Bytes) -> put_single_block(LinkIo, LinkPid, Stream, Bytes);
transfer(LinkIo, get, LinkPid, Stream, Mcid)  -> get_single_block(LinkIo, LinkPid, Stream, Mcid).

%%%===================================================================
%%% Single block — one wire round trip, runs entirely in the connect
%%% worker exactly as Phase 1 shipped it. Untouched by Phases 2-3:
%%% there is no "between chunks" or "another stream" for either to mean
%%% anything here.
%%%===================================================================

put_single_block(LinkIo, LinkPid, Stream, Bytes) ->
    MCID = <<2, 16#55, (crypto:hash(sha384, Bytes))/binary>>,
    classify_put_content(put_block(LinkIo, LinkPid, Stream, MCID, Bytes), MCID).

put_block(LinkIo, LinkPid, Stream, MCID, Bytes) ->
    call_on_stream_with_retry(LinkIo, LinkPid, Stream, ?CONTENT_PUT_BLOCK_PROC,
                              #{mcid => MCID, payload => Bytes},
                              ?CONTENT_BLOCK_TIMEOUT_MS).

classify_put_content({ok, ok},                MCID) -> {ok, MCID};
classify_put_content({ok, hash_mismatch},     _MCID) -> {error, hash_mismatch};
classify_put_content({ok, Reply},             _MCID) -> {error, {unexpected_reply, Reply}};
classify_put_content({error, _} = E,          _MCID) -> E.

get_single_block(LinkIo, LinkPid, Stream, MCID) ->
    classify_get_content(get_block(LinkIo, LinkPid, Stream, MCID), MCID).

get_block(LinkIo, LinkPid, Stream, MCID) ->
    call_on_stream_with_retry(LinkIo, LinkPid, Stream, ?CONTENT_GET_BLOCK_PROC,
                              #{mcid => MCID}, ?CONTENT_BLOCK_TIMEOUT_MS).

classify_get_content({ok, not_found}, _MCID)        -> {error, not_found};
classify_get_content({ok, Bin}, MCID) when is_binary(Bin) ->
    verify_block_hash(MCID, Bin);
classify_get_content({ok, Reply}, _MCID)            -> {error, {unexpected_reply, Reply}};
classify_get_content({error, _} = E, _MCID)         -> E.

%% The station verified this block's hash at PUT time; a station
%% fetched FROM (especially via `start_get_station/5', deliberately
%% dialing a caller-chosen peer) is not necessarily the one that stored
%% it, so re-verify client-side rather than trusting whoever answered.
%% Chunked content gets the equivalent check from `macula_manifest:
%% verify/2' over the reassembled whole (see `dispatch_terminal_step/1''s
%% get clause below); single-block content had no client-side check at
%% all before this (fixed pre-Phase-1, carried forward here unchanged
%% — see `macula_content_block_hash_tests').
-spec verify_block_hash(macula:mcid(), binary()) ->
        {ok, binary()} | {error, hash_mismatch | invalid_mcid}.
verify_block_hash(<<2, 16#55, Hash:48/binary>>, Bin) ->
    hash_result(crypto:hash(sha384, Bin) =:= Hash, Bin);
verify_block_hash(_MCID, _Bin) ->
    {error, invalid_mcid}.

hash_result(true, Bin)   -> {ok, Bin};
hash_result(false, _Bin) -> {error, hash_mismatch}.

%%%===================================================================
%%% Chunked — driven step by step by THIS process (the gen_server), not
%%% the connect worker. A get starts on the ONE stream connect already
%%% opened (fetching the manifest — see `dispatch_get_manifest_step/1')
%%% since the chunk count isn't known before that; a put knows its
%%% chunk count upfront and sets up every stream immediately (see
%%% `content_link_chunked/4'). Once streams — "lanes" — exist, each
%%% runs its own independent chunk queue: `dispatch_lanes_or_finish/1'
%%% starts a worker for any idle lane that still has work, one short-
%%% lived worker per single network call, so `cancel/3' can always
%%% kill whichever ones are in flight and `pause'/`resume' (checked
%%% right here, uniformly, whether there's one lane or several) gate
%%% every lane the same way. Once every lane is empty and idle, the one
%%% remaining step — put the manifest, or (pure, no network)
%%% reassemble+verify a get — runs and finalizes the transfer.
%%% PLAN_PUSH_UPLOAD.md Phases 2-3.
%%%===================================================================

content_link_chunked(put, LinkPid, Stream, Bytes,
                     #state{link_io = LinkIo, stream_count = DesiredN} = State) ->
    {ok, Manifest, Chunks} = macula_manifest:create(Bytes),
    Lanes = setup_put_lanes(LinkIo, LinkPid, Stream, DesiredN, Chunks),
    Chunk = #chunk{manifest = Manifest, lanes = Lanes, chunk_count = undefined, acc = #{}},
    {noreply, State#state{worker = undefined, chunk = Chunk}, {continue, next_step}};
content_link_chunked(get, _LinkPid, _Stream, _Mcid, State) ->
    %% Manifest not known yet — fetching it (on the one stream we have
    %% so far) IS the first step; lanes get set up once it's back (see
    %% `get_manifest_result/2').
    Chunk = #chunk{manifest = undefined, lanes = undefined, chunk_count = undefined, acc = #{}},
    {noreply, State#state{chunk = Chunk}, {continue, next_step}}.

setup_put_lanes(LinkIo, LinkPid, Stream0, DesiredN, Chunks) ->
    N = max(1, min(DesiredN, length(Chunks))),
    Streams = [Stream0 | open_extra_streams(LinkIo, LinkPid, N - 1)],
    Items = lists:zip(lists:seq(0, length(Chunks) - 1), Chunks),
    distribute_lanes(Streams, Items, fun({Index, _Bytes}) -> Index end).

setup_get_lanes(LinkIo, LinkPid, Stream0, DesiredN, ChunkCount) ->
    N = max(1, min(DesiredN, ChunkCount)),
    Streams = [Stream0 | open_extra_streams(LinkIo, LinkPid, N - 1)],
    Items = lists:seq(0, ChunkCount - 1),
    distribute_lanes(Streams, Items, fun(Index) -> Index end).

%% Opening an extra stream is a local QUIC operation on an already-live
%% connection (allocate a stream id, no peer round trip) — fast and
%% ordinarily infallible, but if one DOES fail this degrades to fewer
%% streams rather than failing the whole transfer over it.
open_extra_streams(_LinkIo, _LinkPid, N) when N =< 0 -> [];
open_extra_streams(LinkIo, LinkPid, N) ->
    lists:filtermap(fun(_) -> try_open_content_stream(LinkIo, LinkPid) end, lists:seq(1, N)).

try_open_content_stream(#{open_content_stream := OpenContentStream}, LinkPid) ->
    case OpenContentStream(LinkPid) of
        {ok, S} -> {true, S};
        {error, _} -> false
    end.

%% Round-robin `Items' across `Streams' by `KeyFun(Item) rem length(Streams)',
%% each lane's own queue kept in ascending original order.
distribute_lanes(Streams, Items, KeyFun) ->
    NumStreams = length(Streams),
    Grouped = lists:foldl(fun(Item, Acc) -> group_by_lane(Item, KeyFun, NumStreams, Acc) end,
                           #{}, Items),
    [#lane{stream = S, remaining = lists:reverse(maps:get(I, Grouped, [])),
          in_flight = undefined, worker = undefined}
     || {I, S} <- lists:zip(lists:seq(0, NumStreams - 1), Streams)].

group_by_lane(Item, KeyFun, NumStreams, Acc) ->
    LaneIdx = KeyFun(Item) rem NumStreams,
    maps:update_with(LaneIdx, fun(L) -> [Item | L] end, [Item], Acc).

dispatch_get_manifest_step(#state{kind = get, payload = Mcid} = State) ->
    start_single_step(State, fun(Self, LinkIo, LinkPid, Stream) ->
        step_get_manifest(Self, LinkIo, LinkPid, Stream, Mcid)
    end).

%% For every lane with no worker currently in flight and work left,
%% start one step (one network call) on it. Idempotent to call
%% redundantly — a lane already busy, or already empty, is a no-op —
%% which is what lets every lane's own completion just re-trigger this
%% uniformly rather than needing to know about any other lane's state.
dispatch_lanes_or_finish(#state{chunk = #chunk{lanes = Lanes} = Chunk} = State) ->
    NewLanes = [maybe_start_lane(State, L) || L <- Lanes],
    NewState = State#state{chunk = Chunk#chunk{lanes = NewLanes}},
    finish_if_all_lanes_done(NewState).

maybe_start_lane(_State, #lane{worker = W} = Lane) when is_pid(W) ->
    Lane;
maybe_start_lane(_State, #lane{remaining = []} = Lane) ->
    Lane;
maybe_start_lane(#state{kind = Kind, link_io = LinkIo, link_pid = LinkPid,
                        chunk = #chunk{manifest = Manifest}},
                 #lane{stream = Stream, remaining = [Item | Rest]} = Lane) ->
    Self = self(),
    Worker = spawn_link(fun() ->
                                run_lane_step(Self, LinkIo, Kind, LinkPid, Stream, Manifest, Item)
                        end),
    Lane#lane{remaining = Rest, in_flight = Item, worker = Worker}.

run_lane_step(Self, LinkIo, put, LinkPid, Stream, Manifest, {Index, Bytes}) ->
    {ok, ChunkMcid} = macula_manifest:chunk_mcid(Manifest, Index),
    Put = put_block(LinkIo, LinkPid, Stream, ChunkMcid, Bytes),
    Outcome = put_chunk_outcome(classify_put_content(Put, ChunkMcid)),
    Self ! {lane_step_result, Stream, Outcome};
run_lane_step(Self, LinkIo, get, LinkPid, Stream, Manifest, Index) ->
    {ok, ChunkMcid} = macula_manifest:chunk_mcid(Manifest, Index),
    Outcome = classify_get_content(get_block(LinkIo, LinkPid, Stream, ChunkMcid), ChunkMcid),
    Self ! {lane_step_result, Stream, Outcome}.

put_chunk_outcome({ok, _})       -> ok;
put_chunk_outcome({error, _} = E) -> E.

finish_if_all_lanes_done(#state{chunk = #chunk{lanes = Lanes}} = State) ->
    case lists:all(fun lane_done/1, Lanes) of
        true  -> dispatch_terminal_step(State);
        false -> {noreply, State}
    end.

lane_done(#lane{remaining = [], in_flight = undefined, worker = undefined}) -> true;
lane_done(_) -> false.

dispatch_terminal_step(#state{kind = put, chunk = #chunk{manifest = Manifest}} = State) ->
    start_single_step(State, fun(Self, LinkIo, LinkPid, Stream) ->
        step_put_manifest(Self, LinkIo, LinkPid, Stream, Manifest)
    end);
dispatch_terminal_step(#state{kind = get,
                              chunk = #chunk{manifest = Manifest, chunk_count = N, acc = Acc}} = State) ->
    Reassembled = iolist_to_binary([maps:get(I, Acc) || I <- lists:seq(0, N - 1)]),
    finalize(State, verify_result(macula_manifest:verify(Manifest, Reassembled), Reassembled)).

%% A non-lane step: the get-manifest-fetch bootstrap, or the
%% put-manifest finalize — both use the transfer's own primal stream
%% directly (for a chunked put that's also lane 0's stream; for a get
%% it's the stream the connect step opened, before any lanes exist).
start_single_step(#state{link_io = LinkIo, link_pid = LinkPid, stream = Stream} = State,
                  StepFun) ->
    Self = self(),
    Worker = spawn_link(fun() -> StepFun(Self, LinkIo, LinkPid, Stream) end),
    {noreply, State#state{worker = Worker}}.

step_put_manifest(Self, LinkIo, LinkPid, Stream, #{mcid := MCID} = Manifest) ->
    Outcome = classify_put_manifest(
      call_on_stream_with_retry(LinkIo, LinkPid, Stream, ?CONTENT_PUT_MANIFEST_PROC,
                                #{manifest => Manifest}, ?CONTENT_MANIFEST_TIMEOUT_MS),
      MCID),
    Self ! {step_result, Outcome}.

classify_put_manifest({ok, ok},      MCID) -> {ok, MCID};
classify_put_manifest({ok, Reply},  _MCID) -> {error, {unexpected_reply, Reply}};
classify_put_manifest({error, _} = E, _MCID) -> E.

step_get_manifest(Self, LinkIo, LinkPid, Stream, Mcid) ->
    Outcome = bind_manifest(
                classify_get_manifest_step(
                  call_on_stream_with_retry(LinkIo, LinkPid, Stream, ?CONTENT_GET_MANIFEST_PROC,
                                            #{mcid => Mcid}, ?CONTENT_MANIFEST_TIMEOUT_MS)),
                Mcid),
    Self ! {step_result, Outcome}.

classify_get_manifest_step({ok, not_found})          -> {error, not_found};
classify_get_manifest_step({ok, Wire}) when is_map(Wire) -> macula_manifest:from_wire(Wire);
classify_get_manifest_step({ok, Reply})              -> {error, {unexpected_reply, Reply}};
classify_get_manifest_step({error, _} = E)            -> E.

%% The manifest is used only if it describes the content asked for: its
%% MCID, recomputed from its canonical fields, must be `Mcid'. Every chunk
%% is then fetched against this manifest and the whole is checked against
%% its size and root hash, so the bytes returned are the bytes `Mcid' names.
bind_manifest({ok, Manifest}, Mcid) ->
    bound_manifest(macula_manifest:verify_mcid(Manifest, Mcid), Manifest);
bind_manifest({error, _} = E, _Mcid) ->
    E.

bound_manifest(ok, Manifest)              -> {ok, Manifest};
bound_manifest({error, _} = E, _Manifest) -> E.

%% The ONLY non-lane step for a get is the manifest fetch; for a put
%% it's the manifest put, which always finalizes (success or failure)
%% since it's the transfer's last step by construction.
step_result(Outcome, #state{kind = get} = State) ->
    get_manifest_result(Outcome, State);
step_result(Outcome, #state{kind = put} = State) ->
    finalize(State, Outcome).

get_manifest_result({ok, Manifest}, #state{link_io = LinkIo, link_pid = LinkPid,
                                           stream = Stream0,
                                           stream_count = DesiredN} = State) ->
    #{chunk_count := N} = Manifest,
    Lanes = setup_get_lanes(LinkIo, LinkPid, Stream0, DesiredN, N),
    NewChunk = #chunk{manifest = Manifest, lanes = Lanes, chunk_count = N, acc = #{}},
    {noreply, State#state{chunk = NewChunk}, {continue, next_step}};
get_manifest_result({error, _} = E, State) ->
    finalize(State, E).

%% A lane's step outcome. `false' (no matching lane) is a stray/stale
%% message — lanes live for the whole chunk phase, so this should never
%% happen in practice; treated as a defensive no-op rather than a crash.
lane_result(false, _Outcome, State) ->
    {noreply, State};
lane_result(#lane{} = Lane, {error, _} = E, State) ->
    fail_chunked(State, Lane, E);
lane_result(#lane{in_flight = Item} = Lane, Outcome, #state{kind = Kind, chunk = Chunk} = State) ->
    {NewLane, NewChunk} = apply_lane_success(Kind, Lane, Item, Outcome, Chunk),
    FinalChunk = replace_lane(NewChunk, NewLane),
    {noreply, State#state{chunk = FinalChunk}, {continue, next_step}}.

apply_lane_success(put, Lane, _Item, ok, Chunk) ->
    {Lane#lane{in_flight = undefined, worker = undefined}, Chunk};
apply_lane_success(get, Lane, Index, {ok, Bin}, #chunk{acc = Acc} = Chunk) ->
    {Lane#lane{in_flight = undefined, worker = undefined}, Chunk#chunk{acc = Acc#{Index => Bin}}}.

replace_lane(#chunk{lanes = Lanes} = Chunk, #lane{stream = S} = NewLane) ->
    Chunk#chunk{lanes = lists:keyreplace(S, #lane.stream, Lanes, NewLane)}.

%% One stream's chunk genuinely failed (not crashed) — the whole
%% transfer fails with it. Every OTHER lane's in-flight worker (if any)
%% is killed; every lane's stream, including the failed one's, is
%% closed by `finalize/2' below exactly as a successful chunked
%% transfer's would be.
fail_chunked(#state{chunk = #chunk{lanes = Lanes}} = State, FailedLane, Error) ->
    OtherLanes = [L || L <- Lanes, L#lane.stream =/= FailedLane#lane.stream],
    lists:foreach(fun(#lane{worker = W}) -> kill_worker(W) end, OtherLanes),
    finalize(State, Error).

%% Chunked terminal outcome — mirrors `content_result''s job for the
%% single-block path, but the close happens here instead of in a
%% worker (a chunked transfer's step workers never held a stream open
%% past their own one call).
finalize(#state{link_io = LinkIo, link_pid = LinkPid, stream = Stream, chunk = Chunk,
                waiters = Waiters} = State, Outcome) ->
    close_all_streams(LinkIo, LinkPid, Stream, Chunk),
    [gen_server:reply(From, Outcome) || From <- Waiters],
    {noreply, State#state{result = Outcome, waiters = [], worker = undefined}}.

close_all_streams(LinkIo, LinkPid, PrimalStream, undefined) ->
    close_stream_safely(LinkIo, LinkPid, PrimalStream);
close_all_streams(LinkIo, LinkPid, PrimalStream, #chunk{lanes = undefined}) ->
    close_stream_safely(LinkIo, LinkPid, PrimalStream);
close_all_streams(LinkIo, LinkPid, _PrimalStream, #chunk{lanes = Lanes}) ->
    lists:foreach(fun(#lane{stream = S}) -> close_stream_safely(LinkIo, LinkPid, S) end, Lanes).

close_stream_safely(#{close_content_stream := Close}, LinkPid, Stream)
  when is_pid(LinkPid), is_reference(Stream) ->
    try Close(LinkPid, Stream)
    catch _:_ -> ok end;
close_stream_safely(_LinkIo, _LinkPid, _Stream) ->
    ok.

verify_result(ok, Reassembled)      -> {ok, Reassembled};
verify_result({error, _} = E, _Bin) -> E.

%%%===================================================================
%%% Retry — a `_content.*' CALL on the transfer's pinned dedicated
%%% stream, retried on a BOLT#4 error whose OWN retry policy says to.
%%% A port of macula:call_on_stream_with_retry/5,6, on the transfer's own
%%% call_on_stream function.
%%%===================================================================

call_on_stream_with_retry(#{call_on_stream := CallOnStream}, LinkPid, Stream, Procedure, Payload,
                          TimeoutMs) ->
    Call = fun() ->
                   CallOnStream(LinkPid, Stream, ?CONTENT_REALM, Procedure, Payload, TimeoutMs)
           end,
    call_with_retry(Call, 3).

call_with_retry(Call, AttemptsLeft) ->
    retry_stream_result(Call(), Call, AttemptsLeft).

retry_stream_result({error, {call_error, Code, _Name}} = E, Call, AttemptsLeft)
        when AttemptsLeft > 1 ->
    retry_stream_if_retryable(macula_bolt4:is_retryable(Code), E, Call, AttemptsLeft);
retry_stream_result(Result, _Call, _AttemptsLeft) ->
    Result.

retry_stream_if_retryable(true, _E, Call, AttemptsLeft) ->
    timer:sleep(?CONTENT_RETRY_BACKOFF_MS),
    call_with_retry(Call, AttemptsLeft - 1);
retry_stream_if_retryable(false, E, _Call, _AttemptsLeft) ->
    E.