src/safe_rel.erl

-module(safe_rel).

-export([
    download_version/3, detect_os/0, detect_arch/0, get_safe_binary_path/1
]).
-export([fetch_versions/0, get_latest_compatible_version/1]).
-export([compute_checksum_data/1, verify_checksum/2]).
-export([sort_versions_desc/1]).

-define(DOWNLOAD_URL, "https://safe-releases.s3.eu-central-1.amazonaws.com/").
-define(MANIFEST_URL, ?DOWNLOAD_URL ++ "versions.json").

%%====================================================================
%% Manifest Fetching
%%====================================================================

%% @doc Fetch available versions from S3 manifest.
%% The versions.json format is: {"1.0.0": {"macos-x86_64": "sha256", "linux-x86_64": "sha256"}, ...}
-spec fetch_versions() -> {ok, map()} | {error, term()}.
fetch_versions() ->
    case download_file(?MANIFEST_URL) of
        {ok, JsonBinary} ->
            try
                VersionMap = jsx:decode(JsonBinary),
                {ok, VersionMap}
            catch
                _:Reason ->
                    {error, {manifest_parse_failed, Reason}}
            end;
        {error, Reason} ->
            {error, {manifest_fetch_failed, Reason}}
    end.

%% @doc Get latest version satisfying a constraint, optionally including prereleases.
%% Constraint format: <<"~> 1.3">> (>= 1.3.0, < 2.0.0)
%% or <<"~> 1.3.1">> (>= 1.3.1, < 1.4.0)
-spec get_latest_compatible_version(Constraint :: binary()) ->
    {ok, binary(), map()} | {error, term()}.
get_latest_compatible_version(Constraint) ->
    ConstraintStr = binary_to_list(Constraint),
    %% If the constraint itself pins a prerelease (e.g. "~> 1.5.0-rc-0"),
    %% prereleases must be included — otherwise the required minimum can never be satisfied.
    IncludeRc = constraint_requires_prerelease(ConstraintStr),
    case fetch_versions() of
        {ok, VersionMap} -> find_latest(VersionMap, ConstraintStr, IncludeRc);
        {error, Reason} -> {error, Reason}
    end.

find_latest(VersionMap, ConstraintStr, IncludeRc) ->
    Versions = maps:keys(VersionMap),
    Compatible = [V || V <- Versions, samovar:check(binary_to_list(V), ConstraintStr)],
    Filtered = filter_prereleases(Compatible, IncludeRc),
    case sort_versions_desc(Filtered) of
        [Latest | _] ->
            Checksums = maps:get(Latest, VersionMap),
            {ok, Latest, Checksums};
        [] ->
            {error, no_compatible_version}
    end.

filter_prereleases(Versions, true) -> Versions;
filter_prereleases(Versions, false) -> [V || V <- Versions, not is_prerelease(V)].

%%====================================================================
%% Download Functions
%%====================================================================

%% @doc Download specific SAFE version with checksum verification.
-spec download_version(string(), binary(), map()) -> {ok, string()} | {error, term()}.
download_version(Dir, Version, Checksums) ->
    case {detect_os(), detect_arch()} of
        {"unsupported", _} -> {error, unsupported_platform};
        {_, "unsupported"} -> {error, unsupported_platform};
        {Os, Arch} -> download_for_platform(Dir, Os, Arch, Version, Checksums)
    end.

download_for_platform(Dir, Os, Arch, Version, Checksums) ->
    PlatformKey = list_to_binary(Os ++ "-" ++ Arch),
    try maps:get(PlatformKey, Checksums, undefined) of
        undefined ->
            {error, {no_checksum_for_platform, PlatformKey}};
        ExpectedChecksum ->
            download_extract_cleanup(Dir, Os, Arch, Version, ExpectedChecksum)
    catch
        error:{badmap, _} ->
            {error, {invalid_checksums, Checksums}}
    end.

download_extract_cleanup(Dir, Os, Arch, Version, ExpectedChecksum) ->
    case do_download_with_checksum(Dir, Os, Arch, Version, ExpectedChecksum) of
        {ok, FilePath} -> extract_and_cleanup(Dir, FilePath);
        {error, _} = Err -> Err
    end.

extract_and_cleanup(Dir, FilePath) ->
    case un_tar(FilePath) of
        {ok, _DestDir} ->
            ok = remove_file(FilePath),
            BinaryPath = get_safe_binary_path(Dir),
            ok = file:change_mode(BinaryPath, 8#755),
            {ok, BinaryPath};
        {error, _} = Err ->
            Err
    end.

-spec detect_os() -> string().
detect_os() ->
    case os:type() of
        {unix, linux} ->
            "linux";
        {unix, darwin} ->
            "macos";
        _ ->
            "unsupported"
    end.

-spec detect_arch() -> string().
detect_arch() ->
    SysArch = erlang:system_info(system_architecture),
    [Head | _] = string:split(SysArch, "-"),
    normalise_arch(Head).

% typically Intel/AMD
normalise_arch("x86_64") -> "x86_64";
% Debian/Ubuntu use "amd64" for x86_64
normalise_arch("amd64") -> "x86_64";
% ARM64 architectures, including Apple Silicon (we transform for x86_64 because
% SAFE uses a universal binary that runs natively on both Intel and Apple Silicon Macs)
normalise_arch("aarch64") -> "x86_64";
normalise_arch("arm64") -> "x86_64";
normalise_arch(_) -> "unsupported".

%%====================================================================
%% Checksum Functions
%%====================================================================

%% @doc Compute SHA256 checksum of binary data, returns lowercase hex binary.
-spec compute_checksum_data(binary()) -> binary().
compute_checksum_data(Data) ->
    Hash = crypto:hash(sha256, Data),
    list_to_binary(binary_to_hex(Hash)).

%% Helper: Convert binary hash to hex string
binary_to_hex(Bin) ->
    lists:flatten([io_lib:format("~2.16.0b", [B]) || <<B>> <= Bin]).

%% @doc Verify checksum matches expected value (case-insensitive, trimmed).
-spec verify_checksum(binary(), binary()) -> boolean().
verify_checksum(Actual, Expected) ->
    NormalizedActual = string:lowercase(string:trim(Actual)),
    NormalizedExpected = string:lowercase(string:trim(Expected)),
    NormalizedActual =:= NormalizedExpected.

%%====================================================================
%% Internal Download Functions
%%====================================================================

%% @doc Download binary and verify its SHA256 checksum.
-spec do_download_with_checksum(string(), string(), string(), binary(), binary()) ->
    {ok, string()} | {error, term()}.
do_download_with_checksum(Dir, Os, Arch, Version, ExpectedChecksum) ->
    VersionStr = binary_to_list(Version),
    FileName = lists:flatten(io_lib:format("safe-~s-~s-~s.tar.gz", [VersionStr, Os, Arch])),
    TarUrl = ?DOWNLOAD_URL ++ VersionStr ++ "/" ++ FileName,
    DestPath = filename:join([download_dir(Dir), FileName]),
    ok = filelib:ensure_dir(DestPath),
    case download_file(TarUrl) of
        {ok, BinaryData} ->
            verify_and_write(DestPath, BinaryData, ExpectedChecksum);
        {error, Reason} ->
            file:delete(DestPath),
            {error, {download_failed, TarUrl, Reason}}
    end.

verify_and_write(DestPath, BinaryData, ExpectedChecksum) ->
    ActualChecksum = compute_checksum_data(BinaryData),
    case verify_checksum(ActualChecksum, ExpectedChecksum) of
        true ->
            write_binary_file(DestPath, BinaryData);
        false ->
            {error, {checksum_mismatch, #{expected => ExpectedChecksum, actual => ActualChecksum}}}
    end.

write_binary_file(DestPath, BinaryData) ->
    case file:write_file(DestPath, BinaryData, [raw]) of
        ok ->
            {ok, DestPath};
        {error, E} ->
            file:delete(DestPath),
            {error, {write_error, E}}
    end.

%% @doc Download file from URL
-spec download_file(string()) -> {ok, binary()} | {error, term()}.
download_file(Url) ->
    application:ensure_all_started(hackney),
    #{host := Host} = uri_string:parse(Url),
    HostStr = unicode:characters_to_list(Host),
    SslOpts = [
        {verify, verify_peer},
        {depth, 3},
        {cacerts, certifi:cacerts()},
        {server_name_indication, HostStr},
        {customize_hostname_check, [
            {match_fun, public_key:pkix_verify_hostname_match_fun(https)}
        ]}
    ],
    case
        hackney:get(Url, [], <<>>, [
            with_body,
            {pool, false},
            {ssl_options, SslOpts},
            {connect_timeout, 10000},
            {recv_timeout, 60000}
        ])
    of
        {ok, 200, _Headers, Body} ->
            {ok, iolist_to_binary(Body)};
        {ok, Code, _Headers, _Body} ->
            {error, {http_error, Code, <<>>}};
        {error, Reason} ->
            {error, {request_failed, Reason}}
    end.

-spec un_tar(string()) -> {ok, string()} | {error, {untar_failed, term()}}.
un_tar(FilePath) ->
    DestDir = ensure_string(filename:dirname(FilePath)),
    case erl_tar:extract(FilePath, [compressed, {cwd, DestDir}, keep_old_files]) of
        ok ->
            {ok, DestDir};
        {error, Reason} ->
            file:delete(FilePath),
            {error, {untar_failed, Reason}}
    end.

%% Internal helper to ensure we have a string (not binary)
-spec ensure_string(file:filename_all()) -> string().
ensure_string(Path) when is_binary(Path) ->
    binary_to_list(Path);
ensure_string(Path) when is_list(Path) ->
    Path.

-spec remove_file(string()) -> ok | {error, {delete_failed, any()}}.
remove_file(FilePath) ->
    case file:delete(FilePath) of
        ok ->
            ok;
        {error, Reason} ->
            {error, {delete_failed, Reason}}
    end.

download_dir(Dir) ->
    filename:join([Dir, "_build", "safe"]).

%%====================================================================
%% Semver Helpers
%%====================================================================

%% @doc Returns true if the constraint string itself pins a prerelease version
%% (e.g. "~> 1.5.0-rc-0"), meaning prereleases must be included in the search.
-spec constraint_requires_prerelease(string()) -> boolean().
constraint_requires_prerelease(ConstraintStr) ->
    %% Strip leading operator ("~> ", ">= ", etc.) to get the version part,
    %% then check whether that version is itself a prerelease.
    VersionPart = string:trim(re:replace(ConstraintStr, "^[~><=! ]+", "", [{return, list}])),
    case samovar:prerelease(VersionPart) of
        [] -> false;
        {error, _} -> false;
        _ -> true
    end.

%% @doc Returns true if version contains a prerelease tag (e.g. &lt;&lt;"1.4.0-rc1"&gt;&gt;).
-spec is_prerelease(binary()) -> boolean().
is_prerelease(Version) ->
    case samovar:prerelease(binary_to_list(Version)) of
        [] -> false;
        {error, _} -> false;
        _ -> true
    end.

%% @doc Sort versions descending (highest first) using semver comparison.
-spec sort_versions_desc([binary()]) -> [binary()].
sort_versions_desc(Versions) ->
    lists:sort(
        fun(A, B) ->
            try
                AParsed = ec_semver:parse(binary_to_list(A)),
                BParsed = ec_semver:parse(binary_to_list(B)),
                ec_semver:gt(AParsed, BParsed)
            catch
                _:_ -> A > B
            end
        end,
        Versions
    ).

%% @doc Get path to safe binary
get_safe_binary_path(ProjectDir) ->
    filename:join([download_dir(ProjectDir), "safe"]).