% Copyright (c) 2025 Onomondo ApS & sysmocom - s.f.m.c. GmbH. All rights reserved.
%
% SPDX-License-Identifier: AGPL-3.0-only
%
% Author: Harald Welte <hwelte@sysmocom.de> / sysmocom - s.f.m.c. GmbH
% Author: Philipp Maier <pmaier@sysmocom.de> / sysmocom - s.f.m.c. GmbH
%
% Implementation of the SGP.22 ES9+ Interface Client
%
% Applications are expected to make use of the high-level request_{json,asn1} functions in order to make ES9+
% requests to a SM-DP+.

-module(es9p_client).

-export([request_json/2, request_asn1/2, tryme/0]).

% Depending on whether SSL is enabled or disabled we get a different HTTP prefix.
make_http_prefix() ->
    {ok, Es9pSslDisable} = application:get_env(onomondo_eim, es9p_ssl_disable),
    case Es9pSslDisable of
        true ->
            "http://";
        _ ->
            "https://"
    end.

% send an ES9+ HTTP request in JSON format over HTTP binding
make_req_json(BaseUrl, Function, JsonBody) ->
    % see SGP.22 Section 6.5 for details on HTTP Function Binding in JSON
    Method = post,
    % construct URL from global hostname, static path and function
    URL = list_to_binary(
        string:join(
            [
                make_http_prefix(),
                binary_to_list(BaseUrl),
                "/gsma/rsp2/es9plus/",
                Function
            ],
            ""
        )
    ),
    ReqHeaders = [
        {<<"Content-Type">>, <<"application/json;charset=UTF-8">>},
        {<<"X-Admin-Protocol">>, <<"gsma/rsp/v2.1.0">>}
    ],
    % Add a request header (see also: GSMA SGP.22 6.5.1.3)
    {ok, EimId} = application:get_env(onomondo_eim, eim_id),
    JsonBodyWithHdr = JsonBody#{
        <<"header">> => #{
            <<"functionRequesterIdentifier">> => list_to_binary(EimId),
            <<"functionCallIdentifier">> => list_to_binary(pid_to_list(self()))
        }
    },
    % construct body from encoded json
    ReqBody = jiffy:encode(JsonBodyWithHdr, [force_utf8]),
    % TODO: actually verify the certificate by providing custom root CA Cert
    SslOptions = [{verify, verify_none}],
    Options = [{ssl_options, SslOptions}, with_body],
    logger:debug(
        "Tx ES9+ JSON,~nURL=~p,~nReqHeaders=~p,~nReqBody=~p~n",
        [URL, ReqHeaders, ReqBody]
    ),

    % Perform request
    case hackney:request(Method, URL, ReqHeaders, ReqBody, Options) of
        {ok, StatusCode, RespHeaders, RespBody} ->
            logger:debug(
                "Rx ES9+ JSON,~nURL=~p,~nStatusCode=~p,~nRespHeaders=~p,~nRespBody=~p~n",
                [URL, StatusCode, RespHeaders, RespBody]
            ),
            % TODO: verify RespHeaders: X-Admin-Protocol
            % TODO: verify RespHeaders: Content-Type
            RespBodyDecoded =
                case StatusCode of
                    200 ->
                        case RespBody of
                            <<>> ->
                                % Normally an empty response sent with a status code 204, (see also SGP.22
                                % Section 6.3), but for the sake of compatibility, let's accept it anyway.
                                logger:notice(
                                    "received empty ES9+ message with status code 200 (should be 204),~nURL=~p~n",
                                    [URL]
                                ),
                                <<>>;
                            _ ->
                                jiffy:decode(RespBody, [return_maps])
                        end;
                    _ ->
                        <<>>
                end,
            {ok, StatusCode, RespBodyDecoded};
        _ ->
            logger:error("ES9+ request to ~s failed~n", [URL]),
            {ok, 503, ""}
    end.

% send an ES9+ HTTP request in ASN.1 format over HTTP binding
make_req_asn1(BaseUrl, Asn1Body) ->
    % see SGP.22 Section 6.6 for details on HTTP Function Binding in ASN.1
    Method = post,
    % construct URL from global hostname, static path and function
    URL = list_to_binary(
        string:join([make_http_prefix(), binary_to_list(BaseUrl), "/gsma/rsp2/asn1"], "")
    ),
    ReqHeaders = [
        {<<"Content-Type">>, <<"application/x-gsma-rsp-asn1">>},
        {<<"X-Admin-Protocol">>, <<"gsma/rsp/v2.1.0">>}
    ],
    % construct body from encoded ASN.1
    {ok, ReqBody} = 'RSPDefinitions':encode('RemoteProfileProvisioningRequest', Asn1Body),
    % TODO: actually verify the certificate by providing custom root CA Cert
    SslOptions = [{verify, verify_none}],
    Options = [{ssl_options, SslOptions}, with_body],
    logger:debug(
        "Tx ES9+ ASN.1,~nURL=~p,~nReqHeaders=~p,~nReqBody=~p~n",
        [URL, ReqHeaders, ReqBody]
    ),
    {ok, StatusCode, RespHeaders, RespBody} = hackney:request(
        Method, URL, ReqHeaders, ReqBody, Options
    ),

    % Perform request
    case hackney:request(Method, URL, ReqHeaders, ReqBody, Options) of
        {ok, StatusCode, RespHeaders, RespBody} ->
            logger:debug(
                "Rx ES9+ ASN.1,~nURL=~p,~nStatusCode=~p,~nRespHeaders=~p,~nRespBody=~p~n",
                [URL, StatusCode, RespHeaders, RespBody]
            ),
            % TODO: verify RespHeaders: X-Admin-Protocol
            % TODO: verify RespHeaders: Content-Type
            RespBodyDecoded =
                case StatusCode of
                    200 ->
                        case RespBody of
                            <<>> ->
                                % Normally an empty response sent with a status code 204, (see also SGP.22
                                % Section 6.3), but for the sake of compatibility, let's accept it anyway.
                                logger:notice(
                                    "received empty ES9+ message with status code 200 (should be 204),~nURL=~p~n",
                                    [URL]
                                ),
                                <<>>;
                            _ ->
                                {ok, Asn1Decoded} =
                                    'RSPDefinitions':decode(
                                        'RemoteProfileProvisioningResponse', RespBody
                                    ),
                                Asn1Decoded
                        end;
                    _ ->
                        <<>>
                end,
            {ok, StatusCode, RespBodyDecoded};
        _ ->
            logger:error("ES9+ request to ~s failed~n", [URL]),
            {ok, 503, ""}
    end.

% encode RSP ASN.1 data of given type + base64-encode it
rsp_enc_asn1_b64(TypeName, Data) ->
    {ok, Bin} = 'RSPDefinitions':encode(TypeName, Data),
    base64:encode(Bin).

% base64-decode and RSP ASN.1 decode data of given type
rsp_dec_b64_asn1(TypeName, Data) ->
    {ok, Dec} = 'RSPDefinitions':decode(TypeName, base64:decode(Data)),
    Dec.
pki_dec_b64_asn1(TypeName, Data) ->
    {ok, Dec} = 'PKIX1Explicit88':decode(TypeName, base64:decode(Data)),
    Dec.

% convert from RemoteProfileProvisioningRequest to weird ASN.1-base64-in-JSON
%
% Our philosophy here is to use the maps (ES9) genreated by the asn1ct compiler on the input of the request functions
% and craft the return values so that they use those maps as well. Eventually that means that the return values
% should look like if they were the output of the ASN.1 decoder. This makes sure that we can use the same decoded
% request/response structures for both the JSON and the ASN.1 binding of the ES9+ HTTP interface.

% GSMA SGP.22, section 6.5.2.6 and section 6.6.2.1
request_json({initiateAuthenticationRequest, InitAuthReq}, BaseUrl) ->
    Json = #{
        <<"euiccChallenge">> => base64:encode(maps:get(euiccChallenge, InitAuthReq)),
        <<"smdpAddress">> => maps:get(smdpAddress, InitAuthReq),
        <<"euiccInfo1">> => rsp_enc_asn1_b64('EUICCInfo1', maps:get(euiccInfo1, InitAuthReq))
    },

    {ok, _HtppStatus, JsonResp} = make_req_json(BaseUrl, "initiateAuthentication", Json),
    Choice =
        case JsonResp of
            #{
                <<"header">> := #{
                    <<"functionExecutionStatus">> := #{<<"status">> := <<"Executed-Success">>}
                }
            } ->
                R = #{
                    transactionId => utils:hex_to_binary(maps:get(<<"transactionId">>, JsonResp)),
                    serverSigned1 => rsp_dec_b64_asn1(
                        'ServerSigned1', maps:get(<<"serverSigned1">>, JsonResp)
                    ),
                    serverSignature1 => base64:decode(maps:get(<<"serverSignature1">>, JsonResp)),
                    euiccCiPKIdToBeUsed => base64:decode(
                        maps:get(<<"euiccCiPKIdToBeUsed">>, JsonResp)
                    ),
                    serverCertificate => pki_dec_b64_asn1(
                        'Certificate',
                        maps:get(
                            <<"serverCertificate">>,
                            JsonResp
                        )
                    )
                },
                {initiateAuthenticationOk, R};
            _ ->
                {initiateAuthenticationError, 127}
            % TODO: At the moment we only react on "Executed-Success", we should also react on the other status
            % codes and return an apropriate tuple (in JSON the concept of xyzOk/xyzError members
            % like we have it in the ASN.1 definition seems not to exist), see also SGP.22, section 6.5.1.4.
        end,
    {initiateAuthenticationResponse, Choice};
% GSMA SGP.22, section 6.5.2.8 and section 6.6.2.2
request_json({authenticateClientRequest, AuthClientReq}, BaseUrl) ->
    Json = #{
        <<"transactionId">> => utils:binary_to_hex(maps:get(transactionId, AuthClientReq)),
        <<"authenticateServerResponse">> => rsp_enc_asn1_b64(
            'AuthenticateServerResponse',
            maps:get(authenticateServerResponse, AuthClientReq)
        )
    },
    {ok, _HtppStatus, JsonResp} = make_req_json(BaseUrl, "authenticateClient", Json),
    Choice =
        case JsonResp of
            #{
                <<"header">> := #{
                    <<"functionExecutionStatus">> := #{<<"status">> := <<"Executed-Success">>}
                }
            } ->
                R = #{
                    transactionId => utils:hex_to_binary(maps:get(<<"transactionId">>, JsonResp)),
                    profileMetaData => rsp_dec_b64_asn1(
                        'StoreMetadataRequest',
                        maps:get(
                            <<"profileMetadata">>,
                            JsonResp
                        )
                    ),
                    smdpSigned2 => rsp_dec_b64_asn1(
                        'SmdpSigned2', maps:get(<<"smdpSigned2">>, JsonResp)
                    ),
                    smdpSignature2 => base64:decode(maps:get(<<"smdpSignature2">>, JsonResp)),
                    smdpCertificate => pki_dec_b64_asn1(
                        'Certificate',
                        maps:get(<<"smdpCertificate">>, JsonResp)
                    )
                },
                {authenticateClientOk, R};
            _ ->
                {authenticateClientError, 127}
            % TODO: (see above)
        end,
    {authenticateClientResponseEs9, Choice};
% GSMA SGP.22, section 6.5.2.7 and section 6.6.2.3
request_json({getBoundProfilePackageRequest, GetBppReq}, BaseUrl) ->
    Json = #{
        <<"transactionId">> => utils:binary_to_hex(maps:get(transactionId, GetBppReq)),
        <<"prepareDownloadResponse">> => rsp_enc_asn1_b64(
            'PrepareDownloadResponse',
            maps:get(prepareDownloadResponse, GetBppReq)
        )
    },
    {ok, _HtppStatus, JsonResp} = make_req_json(BaseUrl, "getBoundProfilePackage", Json),
    Choice =
        case JsonResp of
            #{
                <<"header">> := #{
                    <<"functionExecutionStatus">> := #{<<"status">> := <<"Executed-Success">>}
                }
            } ->
                R = #{
                    transactionId => utils:hex_to_binary(maps:get(<<"transactionId">>, JsonResp)),
                    boundProfilePackage => rsp_dec_b64_asn1(
                        'BoundProfilePackage',
                        maps:get(<<"boundProfilePackage">>, JsonResp)
                    )
                },
                {getBoundProfilePackageOk, R};
            _ ->
                {getBoundProfilePackageError, 127}
            % TODO: (see above)
        end,
    {getBoundProfilePackageResponse, Choice};
% GSMA SGP.22, section 6.5.2.10 and section 6.6.2.5
request_json({cancelSessionRequestEs9, CancelSessReq}, BaseUrl) ->
    Json = #{
        <<"transactionId">> => utils:binary_to_hex(maps:get(transactionId, CancelSessReq)),
        <<"cancelSessionResponse">> => rsp_enc_asn1_b64(
            'CancelSessionResponse',
            maps:get(cancelSessionResponse, CancelSessReq)
        )
    },
    {ok, _HtppStatus, JsonResp} = make_req_json(BaseUrl, "cancelSession", Json),
    Choice =
        case JsonResp of
            #{
                <<"header">> := #{
                    <<"functionExecutionStatus">> := #{<<"status">> := <<"Executed-Success">>}
                }
            } ->
                % This function has no output data
                {cancelSessionOk, none};
            _ ->
                {cancelSessionError, 127}
            % TODO: (see above)
        end,
    {cancelSessionResponseEs9, Choice};
% GSMA SGP.22, section 6.5.2.9 and section 6.6.2.4
request_json({handleNotification, HandleNotifReq}, BaseUrl) ->
    Json = #{
        <<"pendingNotification">> => rsp_enc_asn1_b64(
            'PendingNotification',
            maps:get(pendingNotification, HandleNotifReq)
        )
    },
    {ok, HtppStatus, _JsonResp} = make_req_json(BaseUrl, "handleNotification", Json),
    % There is no response defined for this function (see also SGP.22, section 5.6.4), so we send just forward an
    % an empty tuple.
    case HtppStatus of
        204 ->
            % SGP.22 Section 6.3: "A normal notification function execution status (MEP Notification)
            % SHALL be indicated by the HTTP status code '204' (No Content) with an empty HTTP response body"
            {};
        200 ->
            % Normally an SMDP+ HTTP server should not return an empty response with status code 200, but for the sake
            % of compatibility we will accept it anyway.
            {};
        _ ->
            error
    end.

% issue a RemoteProfileProvisioningRequest in its ASN.1 form
% (See also SGP.22 6.6.2.1 - 6.6.2.5)
request_asn1(RemPpr, BaseUrl) ->
    {ok, _HttpStatus, Asn1Resp} = make_req_asn1(BaseUrl, RemPpr),
    % TODO: check HTTP status, we may also need special handling for handleNotification, which has no response data.
    % In any case, this function is currently place holder, since this eIM currently only supports ES9+ JSON bindings
    % (esipa_asn1_handler currently only calls request_json).
    Asn1Resp.

tryme() ->
    Req =
        {initiateAuthenticationRequest, #{
            euiccChallenge =>
                <<98, 247, 104, 103, 113, 183, 83, 201, 207, 31, 83, 25, 64, 67, 206, 171>>,
            euiccInfo1 =>
                #{
                    euiccCiPKIdListForSigning =>
                        [
                            <<245, 65, 114, 189, 249, 138, 149, 214, 92, 190, 184, 138, 56, 161,
                                193, 29, 128, 10, 133, 195>>,
                            <<192, 188, 112, 186, 54, 146, 157, 67, 180, 103, 255, 87, 87, 5, 48,
                                229, 122, 184, 252, 216>>
                        ],
                    euiccCiPKIdListForVerification =>
                        [
                            <<245, 65, 114, 189, 249, 138, 149, 214, 92, 190, 184, 138, 56, 161,
                                193, 29, 128, 10, 133, 195>>,
                            <<192, 188, 112, 186, 54, 146, 157, 67, 180, 103, 255, 87, 87, 5, 48,
                                229, 122, 184, 252, 216>>
                        ],
                    svn => <<2, 3, 0>>
                },
            smdpAddress => <<"127.0.0.1">>
        }},
    request_json(Req, <<"127.0.0.1:4430">>).
