// KeyLockr SSO/AppData reference client using only public KPS primitives.
//
// Wire format:
//
//	frame = msgpack({ kps: {id?, app_ver?, box, n, raw?}, seal })
//	box  = nacl.box(msgpack({header, body}), n, peerEncPk, selfEncSk)
//	seal = nacl.sign(sha256(canonical_msgpack(kps)), selfSignSk)
//
// Both peers hash MessagePack independently, so kps map keys must be sorted.
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/gorilla/websocket"
	"github.com/vmihailenco/msgpack/v5"
	"golang.org/x/crypto/nacl/box"
	"golang.org/x/crypto/nacl/sign"
)

const (
	BaseURL       = "https://api.keylockr.app/v3"
	WSURL         = "wss://api.keylockr.app/v3/ws"
	ServiceKeyURL = "https://my.keylockr.app/svc_pk"
	exampleAppVer = "keylockr-sso-go/1"
	maxServerAge  = 10 * time.Minute
	maxServerLead = 3 * time.Minute

	AppAuthResultTooLargeCode = "app_auth_result_too_large"
)

type APIError struct {
	Code      string
	Message   string
	RequestIP string
}

func (e *APIError) Error() string {
	suffix := ""
	if e.RequestIP != "" {
		suffix = " (request_ip=" + e.RequestIP + ")"
	}
	if e.Message == "" {
		return e.Code + suffix
	}
	return e.Code + ": " + e.Message + suffix
}

type HandshakeOptions struct {
	AppTag          string
	Name            string
	AppData         bool
	AccessPoint     bool
	RequestedScopes []string // nil means full AP access when AccessPoint is true.
}

func (o HandshakeOptions) Capabilities() []string {
	out := []string{"sso"}
	if o.AppData {
		out = append(out, "app_data")
	}
	if o.AccessPoint {
		out = append(out, "ap")
	}
	return out
}

type APAccess struct {
	FullAccess bool
	Scopes     []string
}

type AuthResult struct {
	TmpID           string
	AppID           string
	SafeID          string
	Capabilities    []string
	UserNickname    string
	UserEncPk       []byte
	APAccess        *APAccess
	AccountKeyForAP []byte
	DataFileKey     []byte
	DataPlain       []byte
	DataEncrypted   []byte
	DataDeferred    bool // true when the 12 KiB completion omits AppData content
	Version         string
}

// Client owns one device keypair. Persist the private keys securely if app_id
// must survive process restarts; never copy them to another device.
type Client struct {
	signPk *[32]byte
	signSk *[64]byte
	encPk  *[32]byte
	encSk  *[32]byte

	srvEncPk  *[32]byte
	srvSignPk *[32]byte
}

func NewClient() *Client {
	signPk, signSk, err := sign.GenerateKey(rand.Reader)
	must(err)
	encPk, encSk, err := box.GenerateKey(rand.Reader)
	must(err)
	return &Client{signPk: signPk, signSk: signSk, encPk: encPk, encSk: encSk}
}

// MessagePack must be canonical only where the KPS seal hashes it.
func packCanonical(v any) []byte {
	var buf bytes.Buffer
	enc := msgpack.NewEncoder(&buf)
	enc.SetSortMapKeys(true)
	must(enc.Encode(v))
	return buf.Bytes()
}

func pack(v any) []byte {
	b, err := msgpack.Marshal(v)
	must(err)
	return b
}

func unpackMap(b []byte) (map[string]any, error) {
	var out map[string]any
	if err := msgpack.Unmarshal(b, &out); err != nil {
		return nil, err
	}
	if out == nil {
		return nil, fmt.Errorf("MessagePack value is not a map")
	}
	return out, nil
}

func (c *Client) FetchServerKeys() error {
	encB64, err := httpGet(BaseURL + "/key_enc")
	if err != nil {
		return err
	}
	signB64, err := httpGet(BaseURL + "/key_sign")
	if err != nil {
		return err
	}
	enc, err := decodePublicKey(encB64)
	if err != nil {
		return fmt.Errorf("server encryption key: %w", err)
	}
	sgn, err := decodePublicKey(signB64)
	if err != nil {
		return fmt.Errorf("server signing key: %w", err)
	}
	c.srvEncPk = (*[32]byte)(enc)
	c.srvSignPk = (*[32]byte)(sgn)
	return nil
}

func decodePublicKey(value []byte) ([]byte, error) {
	key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(value)))
	if err != nil {
		return nil, err
	}
	if len(key) != 32 {
		return nil, fmt.Errorf("expected 32 bytes, got %d", len(key))
	}
	return key, nil
}

func (c *Client) Handshake(options HandshakeOptions) (string, error) {
	body := map[string]any{
		"sign_pk": c.signPk[:],
		"enc_pk":  c.encPk[:],
		"app_tag": options.AppTag,
	}
	if options.Name != "" {
		body["name"] = options.Name
	}
	if options.AppData {
		body["app_data"] = true
	}
	if options.AccessPoint {
		body["ap"] = true
	}
	if options.RequestedScopes != nil {
		body["requested_scopes"] = options.RequestedScopes
	}

	resp, err := httpPost(
		BaseURL+"/handshake",
		"application/octet-stream",
		"application/octet-stream",
		pack(body),
	)
	if err != nil {
		return "", err
	}
	out, err := unpackMap(resp)
	if err != nil {
		return "", fmt.Errorf("decode handshake response: %w", err)
	}
	if err := respErr(out); err != nil {
		return "", err
	}
	tmpID := asString(out["tmp_id"])
	if tmpID == "" {
		return "", fmt.Errorf("handshake response has no tmp_id")
	}
	return tmpID, nil
}

type authSocket interface {
	Close() error
	ReadMessage() (messageType int, p []byte, err error)
	SetReadDeadline(t time.Time) error
}

// WaitForAuth calls onReady after the WebSocket upgrade succeeds, then accepts
// only an authorization result with the exact requested capabilities.
func (c *Client) WaitForAuth(
	tmpID string,
	expectedOptions HandshakeOptions,
	timeout time.Duration,
	onReady func(),
) (*AuthResult, error) {
	sig := c.websocketSignature("tmp", tmpID)
	conn, _, err := websocket.DefaultDialer.Dial(
		WSURL+"?sig="+url.QueryEscape(sig),
		nil,
	)
	if err != nil {
		return nil, fmt.Errorf("WebSocket dial: %w", err)
	}
	defer conn.Close()
	return c.waitForAuthConn(conn, tmpID, expectedOptions, timeout, onReady)
}

func (c *Client) waitForAuthConn(
	conn authSocket,
	expectedTmpID string,
	expectedOptions HandshakeOptions,
	timeout time.Duration,
	onReady func(),
) (*AuthResult, error) {
	if onReady == nil {
		return nil, fmt.Errorf("onReady callback is required")
	}
	if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
		return nil, fmt.Errorf("WebSocket deadline: %w", err)
	}
	onReady()

	for {
		messageType, frame, err := conn.ReadMessage()
		if err != nil {
			return nil, fmt.Errorf("WebSocket read: %w", err)
		}
		if messageType != websocket.BinaryMessage {
			continue
		}
		action, body, err := c.OpenKPS(frame)
		if err != nil {
			return nil, err
		}
		if action == "app_auth_result" {
			return parseAuthResult(body, expectedTmpID, expectedOptions)
		}
	}
}

// ParseAuthorizationCallback validates the opaque result appended by a native Safe.
// The same app_auth_result parser is shared with the temporary WebSocket path.
func (c *Client) ParseAuthorizationCallback(
	callbackURL string,
	expectedTmpID string,
	expectedOptions HandshakeOptions,
) (*AuthResult, error) {
	parsed, err := url.Parse(callbackURL)
	if err != nil || !parsed.IsAbs() {
		return nil, fmt.Errorf("callback must be an absolute URI")
	}
	query, err := url.ParseQuery(parsed.RawQuery)
	if err != nil {
		return nil, fmt.Errorf("parse callback query: %w", err)
	}
	values := query["keylockr_result"]
	if len(values) != 1 || values[0] == "" {
		return nil, fmt.Errorf("callback must contain exactly one keylockr_result")
	}
	frame, err := base64.RawURLEncoding.DecodeString(values[0])
	if err != nil {
		return nil, fmt.Errorf("decode keylockr_result: %w", err)
	}
	action, body, err := c.OpenKPS(frame)
	if err != nil {
		return nil, err
	}
	if action != "app_auth_result" {
		return nil, fmt.Errorf("unexpected callback KPS action %q", action)
	}
	return parseAuthResult(body, expectedTmpID, expectedOptions)
}

func parseAuthResult(
	body map[string]any,
	expectedTmpID string,
	expectedOptions HandshakeOptions,
) (*AuthResult, error) {
	tmpID := asString(body["tmp_id"])
	if expectedTmpID == "" || tmpID != expectedTmpID {
		return nil, fmt.Errorf("authorization result tmp_id mismatch")
	}
	status := asString(body["status"])
	if status != "done" {
		if status == "error" {
			code := asString(body["code"])
			if code == "" {
				return nil, fmt.Errorf("authorization error result has no code")
			}
			return nil, &APIError{Code: code, Message: asString(body["message"])}
		}
		return nil, fmt.Errorf("authorization status is not done")
	}
	appID := asString(body["app_id"])
	safeID := asString(body["safe_id"])
	if appID == "" || safeID == "" {
		return nil, fmt.Errorf("authorization result is missing app_id or safe_id")
	}
	capabilities, ok := stringSlice(body["capabilities"])
	expectedCapabilities := expectedOptions.Capabilities()
	if !ok || !equalStrings(capabilities, expectedCapabilities) {
		return nil, fmt.Errorf(
			"capability mismatch: got %v, expected %v",
			capabilities,
			expectedCapabilities,
		)
	}

	dataDeferred := false
	if rawDeferred, present := body["data_deferred"]; present {
		var ok bool
		dataDeferred, ok = rawDeferred.(bool)
		if !ok || !dataDeferred {
			return nil, fmt.Errorf("invalid data_deferred flag")
		}
	}
	result := &AuthResult{
		TmpID:           tmpID,
		AppID:           appID,
		SafeID:          safeID,
		Capabilities:    capabilities,
		UserNickname:    asString(body["user_nickname"]),
		UserEncPk:       toBytes(body["user_enc_pk"]),
		AccountKeyForAP: toBytes(body["account_key_for_ap"]),
		DataFileKey:     toBytes(body["data_filekey"]),
		DataPlain:       toBytes(body["data_plain"]),
		DataEncrypted:   toBytes(body["data_encrypted"]),
		DataDeferred:    dataDeferred,
		Version:         asString(body["ver"]),
	}
	if access, ok := body["ap_access"].(map[string]any); ok {
		scopes, valid := stringSlice(access["scopes"])
		if !valid {
			return nil, fmt.Errorf("invalid ap_access scopes")
		}
		result.APAccess = &APAccess{
			FullAccess: asBool(access["full_access"]),
			Scopes:     scopes,
		}
	}
	if containsString(capabilities, "ap") {
		if result.APAccess == nil || len(result.AccountKeyForAP) == 0 || len(result.UserEncPk) != 32 {
			return nil, fmt.Errorf("AP authorization result is incomplete")
		}
		if expectedOptions.RequestedScopes == nil {
			if !result.APAccess.FullAccess || len(result.APAccess.Scopes) != 0 {
				return nil, fmt.Errorf("full AP grant does not match the request")
			}
		} else if result.APAccess.FullAccess ||
			!equalStrings(result.APAccess.Scopes, expectedOptions.RequestedScopes) {
			return nil, fmt.Errorf("restricted AP grant does not match the request")
		}
	} else if result.APAccess != nil || len(result.AccountKeyForAP) != 0 {
		return nil, fmt.Errorf("unexpected AP fields in non-AP result")
	}
	if containsString(capabilities, "app_data") {
		if result.Version == "" {
			return nil, fmt.Errorf("AppData authorization result has no version")
		}
		if result.DataDeferred {
			if len(result.DataFileKey) == 0 {
				return nil, fmt.Errorf("deferred AppData result has no filekey")
			}
			if _, ok := body["data_plain"]; ok {
				return nil, fmt.Errorf("deferred AppData result contains data_plain")
			}
			if _, ok := body["data_encrypted"]; ok {
				return nil, fmt.Errorf("deferred AppData result contains data_encrypted")
			}
		}
	} else if len(result.DataFileKey) != 0 || len(result.DataPlain) != 0 ||
		len(result.DataEncrypted) != 0 || result.DataDeferred || result.Version != "" {
		return nil, fmt.Errorf("unexpected AppData fields in non-AppData result")
	}
	return result, nil
}

func (c *Client) websocketSignature(idType, id string) string {
	message := fmt.Sprintf("%s.%s.%d", idType, id, time.Now().Unix())
	signed := sign.Sign(nil, []byte(message), c.signSk)
	return base64.RawURLEncoding.EncodeToString(signed)
}

func (c *Client) SealKPS(identity, action string, body map[string]any) ([]byte, error) {
	if c.srvEncPk == nil {
		return nil, fmt.Errorf("server encryption key is not loaded")
	}
	var nonce [24]byte
	if _, err := rand.Read(nonce[:]); err != nil {
		return nil, err
	}
	inner := pack(map[string]any{
		"header": map[string]any{
			"ts": time.Now().Unix(),
			"to": action,
		},
		"body": body,
	})
	ciphertext := box.Seal(nil, inner, &nonce, c.srvEncPk, c.encSk)
	kps := map[string]any{
		"id":      identity,
		"app_ver": exampleAppVer,
		"box":     ciphertext,
		"n":       nonce[:],
	}
	hash := sha256.Sum256(packCanonical(kps))
	seal := sign.Sign(nil, hash[:], c.signSk)
	return pack(map[string]any{"kps": kps, "seal": seal}), nil
}

func (c *Client) PostKPS(
	identity string,
	action string,
	body map[string]any,
) (map[string]any, error) {
	frame, err := c.SealKPS(identity, action, body)
	if err != nil {
		return nil, err
	}
	resp, err := httpPost(
		BaseURL+"/",
		"application/octet-stream",
		"application/octet-stream",
		frame,
	)
	if err != nil {
		return nil, err
	}
	responseAction, responseBody, err := c.OpenKPS(resp)
	if err != nil {
		return responseBody, err
	}
	if responseAction != action {
		return nil, fmt.Errorf(
			"unexpected KPS response action %q for %q",
			responseAction,
			action,
		)
	}
	return responseBody, nil
}

func (c *Client) OpenKPS(frame []byte) (action string, body map[string]any, err error) {
	if c.srvEncPk == nil || c.srvSignPk == nil {
		return "", nil, fmt.Errorf("server keys are not loaded")
	}
	root, err := unpackMap(frame)
	if err != nil {
		return "", nil, fmt.Errorf("decode KPS frame: %w", err)
	}
	if err := respErr(root); err != nil {
		return "", root, err
	}
	kps, ok := root["kps"].(map[string]any)
	if !ok || kps == nil {
		return "", nil, fmt.Errorf("KPS frame has no kps map")
	}
	seal := toBytes(root["seal"])
	if len(seal) <= sign.Overhead {
		return "", nil, fmt.Errorf("KPS frame has an invalid seal")
	}
	openedHash, ok := sign.Open(nil, seal, c.srvSignPk)
	if !ok {
		return "", nil, fmt.Errorf("KPS seal signature is invalid")
	}
	wantHash := sha256.Sum256(packCanonical(kps))
	if !bytes.Equal(openedHash, wantHash[:]) {
		return "", nil, fmt.Errorf("KPS seal hash mismatch")
	}

	nonceBytes := toBytes(kps["n"])
	if len(nonceBytes) != 24 {
		return "", nil, fmt.Errorf("KPS nonce must be 24 bytes")
	}
	var nonce [24]byte
	copy(nonce[:], nonceBytes)
	plaintext, ok := box.Open(
		nil,
		toBytes(kps["box"]),
		&nonce,
		c.srvEncPk,
		c.encSk,
	)
	if !ok {
		return "", nil, fmt.Errorf("KPS box decryption failed")
	}
	inner, err := unpackMap(plaintext)
	if err != nil {
		return "", nil, fmt.Errorf("decode KPS inner box: %w", err)
	}
	header, ok := inner["header"].(map[string]any)
	if !ok {
		return "", nil, fmt.Errorf("KPS inner box has no header")
	}
	if err := validateServerTimestamp(header["ts"], time.Now()); err != nil {
		return "", nil, err
	}
	body, ok = inner["body"].(map[string]any)
	if !ok {
		return "", nil, fmt.Errorf("KPS inner box has no body")
	}
	action = asString(header["from"])
	if err := restoreRawFields(body, kps["raw"]); err != nil {
		return action, body, err
	}
	if err := respErr(body); err != nil {
		return action, body, err
	}
	return action, body, nil
}

func validateServerTimestamp(value any, now time.Time) error {
	seconds, ok := asInt(value)
	if !ok {
		return fmt.Errorf("KPS header has no valid Unix-second timestamp")
	}
	timestamp := time.Unix(int64(seconds), 0)
	if timestamp.Before(now.Add(-maxServerAge)) || timestamp.After(now.Add(maxServerLead)) {
		return fmt.Errorf("KPS timestamp is outside the accepted window")
	}
	return nil
}

func restoreRawFields(body map[string]any, value any) error {
	if value == nil {
		return nil
	}
	rawVals, ok := value.([]any)
	if !ok {
		return fmt.Errorf("KPS raw field is not an array")
	}
	raw := make([][]byte, len(rawVals))
	for i, item := range rawVals {
		raw[i] = toBytes(item)
		if raw[i] == nil {
			return fmt.Errorf("KPS raw[%d] is not binary", i)
		}
	}
	return restoreRawMap(body, raw)
}

func restoreRawMap(value map[string]any, raw [][]byte) error {
	for key, item := range value {
		if strings.HasSuffix(key, "__") {
			index, ok := asInt(item)
			if !ok || index < 0 || index >= len(raw) {
				return fmt.Errorf("KPS raw index for %q is invalid", key)
			}
			delete(value, key)
			value[strings.TrimSuffix(key, "__")] = raw[index]
			continue
		}
		switch nested := item.(type) {
		case map[string]any:
			if err := restoreRawMap(nested, raw); err != nil {
				return err
			}
		case []any:
			for _, element := range nested {
				if child, ok := element.(map[string]any); ok {
					if err := restoreRawMap(child, raw); err != nil {
						return err
					}
				}
			}
		}
	}
	return nil
}

func (c *Client) SignPkB64() string {
	return base64.StdEncoding.EncodeToString(c.signPk[:])
}

func (c *Client) RequestAccountKey(appID string) ([]byte, bool, error) {
	body, err := c.PostKPS("app."+appID, "app_req_account_key", map[string]any{})
	if err != nil {
		return nil, false, err
	}
	if asString(body["status"]) == "not_available" {
		return nil, false, nil
	}
	accountKey, err := c.OpenAccountKey(
		toBytes(body["account_key_for_ap"]),
		toBytes(body["safe_enc_pk"]),
	)
	if err != nil {
		return nil, false, err
	}
	return accountKey, true, nil
}

func (c *Client) UpdateAppName(appID, name string) error {
	if strings.TrimSpace(name) == "" {
		return fmt.Errorf("app_update requires a non-empty name")
	}
	_, err := c.PostKPS("app."+appID, "app_update", map[string]any{
		"name": strings.TrimSpace(name),
	})
	return err
}

func (c *Client) DeleteApp(appID string) error {
	body, err := c.PostKPS("app."+appID, "app_del", map[string]any{})
	if err != nil {
		return err
	}
	if asString(body["id_deleted"]) != appID {
		return fmt.Errorf("app_del response did not confirm the current app_id")
	}
	return nil
}

type VerifyResult struct {
	Valid    bool
	Nickname string
}

func AppVerify(appTag, appID, safeID, signPkB64 string) (*VerifyResult, error) {
	requestBody, err := json.Marshal(map[string]string{
		"app_tag": appTag,
		"app_id":  appID,
		"safe_id": safeID,
		"sign_pk": signPkB64,
	})
	if err != nil {
		return nil, err
	}
	resp, err := httpPost(BaseURL+"/app_verify", "application/json", "application/json", requestBody)
	if err != nil {
		return nil, err
	}
	out, err := decodeJSONMap(resp)
	if err != nil {
		return nil, err
	}
	if err := respErr(out); err != nil {
		return nil, err
	}
	valid, _ := out["valid"].(bool)
	return &VerifyResult{Valid: valid, Nickname: asString(out["nickname"])}, nil
}

type UserPublicKeyResult struct {
	Found  bool
	SignPk []byte
}

func SSOUserPublicKey(appTag, safeID string) (*UserPublicKeyResult, error) {
	requestBody, err := json.Marshal(map[string]string{
		"app_tag": appTag,
		"safe_id": safeID,
	})
	if err != nil {
		return nil, err
	}
	resp, err := httpPost(
		BaseURL+"/sso_user_pubkey",
		"application/json",
		"application/json",
		requestBody,
	)
	if err != nil {
		return nil, err
	}
	out, err := decodeJSONMap(resp)
	if err != nil {
		return nil, err
	}
	if err := respErr(out); err != nil {
		return nil, err
	}
	found, _ := out["found"].(bool)
	result := &UserPublicKeyResult{Found: found}
	if !found {
		return result, nil
	}
	key, err := base64.StdEncoding.DecodeString(asString(out["sign_pk"]))
	if err != nil || len(key) != 32 {
		return nil, fmt.Errorf("sso_user_pubkey returned an invalid signing key")
	}
	result.SignPk = key
	return result, nil
}

func ServicePublicKey(appTag string) ([]byte, error) {
	resp, err := httpGet(ServiceKeyURL + "/" + url.PathEscape(appTag))
	if err != nil {
		return nil, err
	}
	out, err := decodeJSONMap(resp)
	if err != nil {
		return nil, err
	}
	if err := respErr(out); err != nil {
		return nil, err
	}
	value := asString(out["pk_base64"])
	if value == "" {
		return nil, nil
	}
	key, err := base64.StdEncoding.DecodeString(value)
	if err != nil {
		return nil, err
	}
	if len(key) != 32 {
		return nil, fmt.Errorf("svc_pk returned %d bytes, expected 32", len(key))
	}
	return key, nil
}

func decodeJSONMap(data []byte) (map[string]any, error) {
	var out map[string]any
	if err := json.Unmarshal(data, &out); err != nil {
		return nil, fmt.Errorf("decode JSON response: %w", err)
	}
	return out, nil
}

func respErr(body map[string]any) error {
	if asString(body["_res"]) != "err" {
		return nil
	}
	return &APIError{
		Code:      asString(body["code"]),
		Message:   asString(body["msg"]),
		RequestIP: asString(body["request_ip"]),
	}
}

func toBytes(value any) []byte {
	switch typed := value.(type) {
	case []byte:
		return typed
	case string:
		return []byte(typed)
	default:
		return nil
	}
}

func asString(value any) string {
	if value == nil {
		return ""
	}
	if text, ok := value.(string); ok {
		return text
	}
	return fmt.Sprint(value)
}

func asBool(value any) bool {
	result, _ := value.(bool)
	return result
}

func asInt(value any) (int, bool) {
	switch typed := value.(type) {
	case int:
		return typed, true
	case int8:
		return int(typed), true
	case int16:
		return int(typed), true
	case int32:
		return int(typed), true
	case int64:
		return int(typed), true
	case uint:
		return int(typed), true
	case uint8:
		return int(typed), true
	case uint16:
		return int(typed), true
	case uint32:
		return int(typed), true
	case uint64:
		return int(typed), true
	default:
		return 0, false
	}
}

func stringSlice(value any) ([]string, bool) {
	switch values := value.(type) {
	case []string:
		return values, true
	case []any:
		out := make([]string, 0, len(values))
		for _, value := range values {
			item, ok := value.(string)
			if !ok {
				return nil, false
			}
			out = append(out, item)
		}
		return out, true
	default:
		return nil, false
	}
}

func equalStrings(left, right []string) bool {
	if len(left) != len(right) {
		return false
	}
	for i := range left {
		if left[i] != right[i] {
			return false
		}
	}
	return true
}

func containsString(values []string, expected string) bool {
	for _, value := range values {
		if value == expected {
			return true
		}
	}
	return false
}

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func httpGet(endpoint string) ([]byte, error) {
	resp, err := http.Get(endpoint)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	return readHTTPResp(resp)
}

func httpPost(endpoint, contentType, accept string, body []byte) ([]byte, error) {
	req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", contentType)
	req.Header.Set("Accept", accept)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	return readHTTPResp(resp)
}

func readHTTPResp(resp *http.Response) ([]byte, error) {
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		return nil, fmt.Errorf("HTTP %s: %s", resp.Status, strings.TrimSpace(string(body)))
	}
	return body, nil
}
