// Package emby is a small client for the Emby REST API.
//
// Item payloads are deliberately carried as json.RawMessage and forwarded to the TV
// untouched: the Android client already models Emby's item shape, so passing it through
// verbatim means there is no second schema to keep in sync. Only the handful of fields
// the gateway itself reasons about (id, type, resume position) are ever unmarshalled.
package emby

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/ponzischeme89/memby/server/internal/buildinfo"
)

type Client struct {
	baseURL    string
	publicURL  string
	clientName string
	// gatewayVersion labels the requests the gateway makes on its own behalf — the
	// library sync, the health probe, device cleanup — which belong to no television and
	// so have no app version to report.
	gatewayVersion string
	http           *http.Client
}

// Credentials identify one signed-in Emby user.
type Credentials struct {
	UserID     string
	Token      string
	DeviceID   string
	DeviceName string
	// ClientVersion is the app version of the television this request is being made for,
	// as it reported in X-Memby-Version. Emby shows it beside the device, so a blank one
	// makes every set in the house look like the same build.
	ClientVersion string
}

type Device struct {
	ID               string `json:"Id"`
	ReportedDeviceID string `json:"ReportedDeviceId"`
}

type DevicesResult struct {
	Items []Device `json:"Items"`
}

type ItemsResult struct {
	Items            []json.RawMessage `json:"Items"`
	TotalRecordCount int               `json:"TotalRecordCount"`
}

type AuthResult struct {
	User struct {
		ID   string `json:"Id"`
		Name string `json:"Name"`
	} `json:"User"`
	AccessToken string `json:"AccessToken"`
	ServerID    string `json:"ServerId"`
}

type User struct {
	ID     string `json:"Id"`
	Name   string `json:"Name"`
	Policy struct {
		IsDisabled bool `json:"IsDisabled"`
	} `json:"Policy"`
}

// Summary is the minimal view of an item the gateway needs for its own logic.
type Summary struct {
	ID                string `json:"Id"`
	Name              string `json:"Name"`
	Type              string `json:"Type"`
	Overview          string `json:"Overview"`
	SeriesName        string `json:"SeriesName"`
	RunTimeTicks      int64  `json:"RunTimeTicks"`
	ParentIndexNumber int    `json:"ParentIndexNumber"`
	IndexNumber       int    `json:"IndexNumber"`
	UserData          struct {
		PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
	} `json:"UserData"`
}

type PlaybackInfo struct {
	MediaSources  []MediaSourceInfo `json:"MediaSources"`
	PlaySessionID string            `json:"PlaySessionId"`
}

type MediaSourceInfo struct {
	ID                   string        `json:"Id"`
	MediaStreams         []MediaStream `json:"MediaStreams"`
	SupportsDirectPlay   bool          `json:"SupportsDirectPlay"`
	SupportsDirectStream bool          `json:"SupportsDirectStream"`
	SupportsTranscoding  bool          `json:"SupportsTranscoding"`
	DirectStreamURL      string        `json:"DirectStreamUrl"`
	TranscodingURL       string        `json:"TranscodingUrl"`
}

type MediaStream struct {
	Index                  int    `json:"Index"`
	Type                   string `json:"Type"`
	Codec                  string `json:"Codec"`
	Title                  string `json:"Title"`
	DisplayTitle           string `json:"DisplayTitle"`
	Language               string `json:"Language"`
	IsDefault              bool   `json:"IsDefault"`
	IsForced               bool   `json:"IsForced"`
	IsHearingImpaired      bool   `json:"IsHearingImpaired"`
	IsExternal             bool   `json:"IsExternal"`
	IsTextSubtitleStream   bool   `json:"IsTextSubtitleStream"`
	SupportsExternalStream bool   `json:"SupportsExternalStream"`
	DeliveryURL            string `json:"DeliveryUrl"`
	DeliveryMethod         string `json:"DeliveryMethod"`
}

// APIError carries an upstream Emby status code so handlers can mirror it.
type APIError struct {
	StatusCode int
	Body       string
	RetryAfter string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body)
}

func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
	return &Client{
		baseURL:        strings.TrimRight(baseURL, "/"),
		publicURL:      strings.TrimRight(publicURL, "/"),
		clientName:     clientName,
		gatewayVersion: buildinfo.Version(),
		http: &http.Client{
			Timeout: timeout,
			Transport: &http.Transport{
				MaxIdleConns:        100,
				MaxIdleConnsPerHost: 20,
				IdleConnTimeout:     90 * time.Second,
			},
		},
	}
}

// Authenticate signs a television in. clientVersion is the app version that set
// reported; it is what Emby stamps on the device record it creates here, so an empty one
// leaves the entry claiming the gateway's own build.
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName, clientVersion string) (*AuthResult, error) {
	body, err := json.Marshal(map[string]string{"Username": username, "Pw": password})
	if err != nil {
		return nil, err
	}
	req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil,
		Credentials{DeviceID: deviceID, DeviceName: deviceName, ClientVersion: clientVersion},
		bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")

	var out AuthResult
	if err := c.do(req, &out); err != nil {
		return nil, err
	}
	if out.AccessToken == "" || out.User.ID == "" {
		return nil, fmt.Errorf("emby: authentication returned no access token")
	}
	return &out, nil
}

// Logout retires a token created during authentication. The gateway uses this when a
// device is refused by policy so Emby is not left holding an orphaned session.
func (c *Client) Logout(ctx context.Context, cred Credentials) error {
	req, err := c.newRequest(ctx, http.MethodPost, "/Sessions/Logout", nil, cred, nil)
	if err != nil {
		return err
	}
	return c.do(req, nil)
}

// DeleteDevice removes the persistent Emby device record whose client-supplied ID
// matches reportedDeviceID. Logging out only revokes the access token; Emby deliberately
// keeps the device in its dashboard until the record itself is deleted.
func (c *Client) DeleteDevice(
	ctx context.Context,
	cred Credentials,
	reportedDeviceID string,
) error {
	req, err := c.newRequest(ctx, http.MethodGet, "/Devices", nil, cred, nil)
	if err != nil {
		return err
	}
	var devices DevicesResult
	if err := c.do(req, &devices); err != nil {
		return err
	}
	for _, device := range devices.Items {
		if device.ReportedDeviceID != reportedDeviceID || device.ID == "" {
			continue
		}
		params := url.Values{"Id": {device.ID}}
		req, err := c.newRequest(ctx, http.MethodDelete, "/Devices", params, cred, nil)
		if err != nil {
			return err
		}
		if err := c.do(req, nil); err != nil {
			return err
		}
	}
	return nil
}

// Users returns the household accounts visible to an administrative/service token.
// It is used only by the background For You builder, never on a television request.
func (c *Client) Users(ctx context.Context, cred Credentials) ([]User, error) {
	req, err := c.newRequest(ctx, http.MethodGet, "/Users", nil, cred, nil)
	if err != nil {
		return nil, err
	}
	var users []User
	if err := c.do(req, &users); err != nil {
		return nil, err
	}
	return users, nil
}

func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
	return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params)
}

func (c *Client) NextUp(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
	params.Set("UserId", cred.UserID)
	return c.items(ctx, cred, "/Shows/NextUp", params)
}

// Similar asks Emby which items resemble one the user already watched.
func (c *Client) Similar(ctx context.Context, cred Credentials, itemID string, params url.Values) (*ItemsResult, error) {
	params.Set("UserId", cred.UserID)
	return c.items(ctx, cred, "/Items/"+url.PathEscape(itemID)+"/Similar", params)
}

func (c *Client) Episodes(ctx context.Context, cred Credentials, seriesID string, params url.Values) (*ItemsResult, error) {
	params.Set("UserId", cred.UserID)
	return c.items(ctx, cred, "/Shows/"+url.PathEscape(seriesID)+"/Episodes", params)
}

// LocalTrailers returns the trailers Emby holds locally for an item.
func (c *Client) LocalTrailers(ctx context.Context, cred Credentials, itemID string) (*ItemsResult, error) {
	path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/LocalTrailers"
	req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil)
	if err != nil {
		return nil, err
	}
	// This endpoint answers with a bare array rather than an Items envelope.
	var items []json.RawMessage
	if err := c.do(req, &items); err != nil {
		return nil, err
	}
	return &ItemsResult{Items: items, TotalRecordCount: len(items)}, nil
}

func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields string) (json.RawMessage, error) {
	params := url.Values{}
	if fields != "" {
		params.Set("Fields", fields)
	}
	path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID)
	req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
	if err != nil {
		return nil, err
	}
	var raw json.RawMessage
	if err := c.do(req, &raw); err != nil {
		return nil, err
	}
	return raw, nil
}

func (c *Client) PlaybackInfo(
	ctx context.Context,
	cred Credentials,
	itemID string,
	startTicks int64,
	subtitleStreamIndex *int,
	currentPlaySessionID string,
	forceTranscode bool,
	capabilities PlaybackCapabilities,
) (*PlaybackInfo, error) {
	params := url.Values{
		"UserId":     {cred.UserID},
		"IsPlayback": {"true"},
	}
	profile := androidTVDeviceProfile(capabilities)
	if forceTranscode {
		forceH264TranscodeProfile(profile)
	}
	body, err := json.Marshal(map[string]any{
		"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
		"StartTimeTicks": startTicks, "EnableDirectPlay": !forceTranscode,
		"EnableDirectStream": !forceTranscode, "EnableTranscoding": true,
		"AllowVideoStreamCopy": !forceTranscode, "AllowAudioStreamCopy": true,
		"DeviceProfile": profile,
	})
	var requestBody map[string]any
	if err == nil {
		err = json.Unmarshal(body, &requestBody)
	}
	if subtitleStreamIndex != nil {
		requestBody["SubtitleStreamIndex"] = *subtitleStreamIndex
	}
	if currentPlaySessionID != "" {
		requestBody["CurrentPlaySessionId"] = currentPlaySessionID
	}
	if err == nil {
		body, err = json.Marshal(requestBody)
	}
	if err != nil {
		return nil, err
	}
	req, err := c.newRequest(
		ctx,
		http.MethodPost,
		"/Items/"+url.PathEscape(itemID)+"/PlaybackInfo",
		params,
		cred,
		bytes.NewReader(body),
	)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	var out PlaybackInfo
	if err := c.do(req, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

func directPlayVideoCodecs(supportsHEVC bool) string {
	if supportsHEVC {
		return "h264,hevc"
	}
	return "h264"
}

func (c *Client) ResumeItems(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
	return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items/Resume", params)
}

// SetFavorite/SetPlayed return Emby's resulting UserData verbatim.
func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) {
	method := http.MethodDelete
	if favorite {
		method = http.MethodPost
	}
	path := "/Users/" + url.PathEscape(cred.UserID) + "/FavoriteItems/" + url.PathEscape(itemID)
	return c.userDataCall(ctx, method, path, cred)
}

func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string, played bool) (json.RawMessage, error) {
	method := http.MethodDelete
	if played {
		method = http.MethodPost
	}
	path := "/Users/" + url.PathEscape(cred.UserID) + "/PlayedItems/" + url.PathEscape(itemID)
	return c.userDataCall(ctx, method, path, cred)
}

// HideFromResume removes an item from resume and next-up feeds without marking it played.
func (c *Client) HideFromResume(ctx context.Context, cred Credentials, itemID string) (json.RawMessage, error) {
	params := url.Values{"Hide": {"true"}}
	path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/HideFromResume"
	req, err := c.newRequest(ctx, http.MethodPost, path, params, cred, nil)
	if err != nil {
		return nil, err
	}
	var raw json.RawMessage
	if err := c.do(req, &raw); err != nil {
		return nil, err
	}
	return raw, nil
}

// RefreshItem asks Emby to re-scan one item's files.
//
// It exists for the subtitle download: Bazarr writes the new .srt beside the media file
// and Emby has no idea it is there until something makes it look. Metadata and images are
// deliberately left alone (ReplaceAllMetadata=false, ImageRefreshMode=None) — the point is
// to notice a new sidecar, not to re-fetch a title's artwork from the internet because
// somebody wanted Italian subtitles.
func (c *Client) RefreshItem(ctx context.Context, cred Credentials, itemID string) error {
	params := url.Values{
		"Recursive":           {"false"},
		"MetadataRefreshMode": {"Default"},
		"ImageRefreshMode":    {"None"},
		"ReplaceAllMetadata":  {"false"},
		"ReplaceAllImages":    {"false"},
	}
	req, err := c.newRequest(
		ctx, http.MethodPost, "/Items/"+url.PathEscape(itemID)+"/Refresh", params, cred, nil,
	)
	if err != nil {
		return err
	}
	return c.do(req, nil)
}

// ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped".
func (c *Client) ReportPlayback(
	ctx context.Context,
	cred Credentials,
	phase, itemID, mediaSourceID, playSessionID, playMethod, eventName string,
	positionTicks int64,
	isPaused bool,
) error {
	var path string
	switch phase {
	case "started":
		path = "/Sessions/Playing"
	case "progress":
		path = "/Sessions/Playing/Progress"
	case "stopped":
		path = "/Sessions/Playing/Stopped"
	default:
		return fmt.Errorf("emby: unknown playback phase %q", phase)
	}

	body, err := json.Marshal(map[string]any{
		"ItemId":        itemID,
		"MediaSourceId": mediaSourceID,
		"PlaySessionId": playSessionID,
		"PositionTicks": positionTicks,
		"IsPaused":      isPaused,
		"IsMuted":       false,
		"CanSeek":       true,
		"PlayMethod":    playMethod,
	})
	if phase == "progress" {
		var fields map[string]any
		if err := json.Unmarshal(body, &fields); err != nil {
			return err
		}
		fields["EventName"] = eventName
		body, err = json.Marshal(fields)
	}
	if err != nil {
		return err
	}
	req, err := c.newRequest(ctx, http.MethodPost, path, nil, cred, bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	return c.do(req, nil)
}

// ImageResponse streams an image straight from Emby so the caller can copy it to the TV.
// The caller owns closing the body.
func (c *Client) ImageResponse(ctx context.Context, cred Credentials, itemID, imageType string, params url.Values) (*http.Response, error) {
	path := "/Items/" + url.PathEscape(itemID) + "/Images/" + url.PathEscape(imageType)
	req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
	if err != nil {
		return nil, err
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode >= 400 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
		resp.Body.Close()
		return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")}
	}
	return resp, nil
}

// TrickplayBytes reads part of a title's preview-thumbnail file (BIF).
//
// It is always a ranged read, because the whole file is megabytes and nothing here ever
// wants all of it: callers take the index off the front, then one frame at a time. Emby
// answers ranges on this route correctly but does not say so — the response advertises
// "Accept-Ranges: none" and a Content-Length taken from the media file — so the request is
// made on the strength of the 206 rather than on what the headers promise, and a server
// that ignored the range would simply return the head of the file, which is the index a
// caller wanted anyway.
func (c *Client) TrickplayBytes(
	ctx context.Context, cred Credentials, itemID string, width int, from, to int64,
) ([]byte, error) {
	params := url.Values{}
	params.Set("Width", strconv.Itoa(width))
	path := "/Videos/" + url.PathEscape(itemID) + "/index.bif"
	req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", from, to))

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 400 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
		return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")}
	}
	// Cap the read at what was asked for. A 200 means the range was ignored and the whole
	// file is on its way, which must not become a multi-megabyte read on a seek.
	return io.ReadAll(io.LimitReader(resp.Body, to-from+1))
}

// MaxSubtitleBytes caps a subtitle read. A feature-length SubRip file is tens of
// kilobytes; a megabyte is far past anything genuine and is what an Emby that answered
// with the media file instead would send.
const MaxSubtitleBytes = 4 << 20

// SubtitleBytes reads a text subtitle track's content.
//
// This is the one place the gateway pulls a subtitle *out* of Emby rather than pointing
// the television at it, and it exists so a track can be used as a timing reference. SubRip
// is asked for because that is what the sync reads and writes; Emby converts from whatever
// the container holds.
func (c *Client) SubtitleBytes(
	ctx context.Context, cred Credentials, itemID, mediaSourceID string, index int,
) ([]byte, error) {
	if mediaSourceID == "" {
		mediaSourceID = itemID
	}
	path := fmt.Sprintf(
		"/Videos/%s/%s/Subtitles/%d/Stream.srt",
		url.PathEscape(itemID), url.PathEscape(mediaSourceID), index,
	)
	req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil)
	if err != nil {
		return nil, err
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 400 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
		return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")}
	}
	return io.ReadAll(io.LimitReader(resp.Body, MaxSubtitleBytes))
}

// StreamURL is the direct-play URL handed to the TV. It points at the *public* Emby
// address: video never flows through the gateway, only metadata does.
func (c *Client) StreamURL(cred Credentials, itemID string) string {
	params := url.Values{}
	params.Set("static", "true")
	params.Set("api_key", cred.Token)
	params.Set("DeviceId", cred.DeviceID)
	return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode())
}

// DeliveryURL converts a PlaybackInfo URL into a TV-reachable, authenticated URL.
func (c *Client) DeliveryURL(cred Credentials, delivery string) string {
	delivery = strings.TrimSpace(delivery)
	if delivery == "" {
		return ""
	}
	var resolved string
	if parsed, err := url.Parse(delivery); err == nil && parsed.IsAbs() {
		resolved = parsed.String()
	} else {
		resolved = c.publicURL + "/" + strings.TrimLeft(delivery, "/")
	}
	parsed, err := url.Parse(resolved)
	if err != nil {
		return resolved
	}
	query := parsed.Query()
	if query.Get("api_key") == "" {
		query.Set("api_key", cred.Token)
		parsed.RawQuery = query.Encode()
	}
	return parsed.String()
}

// SubtitleURL uses Emby's stable subtitle download route rather than the optional
// MediaStream.DeliveryUrl. VTT normalizes every text subtitle codec before it reaches
// the TV.
func (c *Client) SubtitleURL(cred Credentials, itemID, mediaSourceID string, index int, extensions ...string) string {
	if mediaSourceID == "" {
		mediaSourceID = itemID
	}
	extension := "vtt"
	if len(extensions) > 0 && strings.TrimSpace(extensions[0]) != "" {
		extension = extensions[0]
	}
	path := fmt.Sprintf(
		"/Videos/%s/%s/Subtitles/%d/Stream.%s",
		url.PathEscape(itemID),
		url.PathEscape(mediaSourceID),
		index, url.PathEscape(extension),
	)
	return c.DeliveryURL(cred, path)
}

// Ping checks that Emby is reachable, for readiness probes.
func (c *Client) Ping(ctx context.Context) error {
	req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil)
	if err != nil {
		return err
	}
	return c.do(req, nil)
}

func (c *Client) items(ctx context.Context, cred Credentials, path string, params url.Values) (*ItemsResult, error) {
	req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
	if err != nil {
		return nil, err
	}
	var out ItemsResult
	if err := c.do(req, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

func (c *Client) userDataCall(ctx context.Context, method, path string, cred Credentials) (json.RawMessage, error) {
	req, err := c.newRequest(ctx, method, path, nil, cred, nil)
	if err != nil {
		return nil, err
	}
	var raw json.RawMessage
	if err := c.do(req, &raw); err != nil {
		return nil, err
	}
	return raw, nil
}

func (c *Client) newRequest(ctx context.Context, method, path string, params url.Values, cred Credentials, body io.Reader) (*http.Request, error) {
	full := c.baseURL + path
	if len(params) > 0 {
		full += "?" + params.Encode()
	}
	req, err := http.NewRequestWithContext(ctx, method, full, body)
	if err != nil {
		return nil, err
	}

	deviceID := cred.DeviceID
	if deviceID == "" {
		deviceID = "memby-gateway"
	}
	deviceName := strings.TrimSpace(cred.DeviceName)
	if deviceName == "" {
		deviceName = "Memby TV"
	}
	// The version Emby records is the television's app version, not a constant: every
	// device in the dashboard read as one build before this, so there was no way to tell
	// which set was behind. A request the gateway makes for itself reports its own build.
	version := strings.TrimSpace(cred.ClientVersion)
	if version == "" {
		version = c.gatewayVersion
	}
	req.Header.Set("Accept", "application/json")
	req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
		`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
		c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
		strings.ReplaceAll(version, `"`, ""),
	))
	if cred.Token != "" {
		req.Header.Set("X-Emby-Token", cred.Token)
	}
	return req, nil
}

// do executes a request and decodes into out (which may be nil to discard the body).
func (c *Client) do(req *http.Request, out any) error {
	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		// Emby error bodies can echo request details; cap what we keep and never log it
		// alongside a token.
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
		return &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")}
	}
	if out == nil {
		_, _ = io.Copy(io.Discard, resp.Body)
		return nil
	}
	return json.NewDecoder(resp.Body).Decode(out)
}

// Summarise unmarshals the fields the gateway reasons about from a raw item.
func Summarise(raw json.RawMessage) (Summary, error) {
	var s Summary
	err := json.Unmarshal(raw, &s)
	return s, err
}
