// Package dlnatest provides an in-process DLNA / UPnP MediaServer for use in
// unit tests (via httptest.Server) and as a real LAN-visible server.
//
// The server handles:
//   - GET /rootDesc.xml    device description (UPnP root device)
//   - POST /ctl/ContentDir ContentDirectory Browse SOAP action
//   - GET /MediaItems/*.wav synthesised audio bytes (1 s silent WAV)
//   - GET /icons/sm.png    minimal 1x1 PNG so icon fetches do not 404
//
// All absolute URLs in DIDL-Lite <res> elements are built from the
// incoming request's Host header, so the same handler works unchanged
// behind httptest.Server and a real net.Listener.
package dlnatest

import (
	"bytes"
	"encoding/xml"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"time"
)

// serveModTime is a fixed modification time used for ServeContent so that
// range requests and caching headers behave deterministically.
var serveModTime = time.Unix(1136214245, 0)

// ----------------------------------------------------------------------------
// Content tree model
// ----------------------------------------------------------------------------

// Container represents a DLNA object.container node.
type Container struct {
	ID       string
	ParentID string
	Title    string
	Class    string // upnp:class value, e.g. "object.container.storageFolder"
	Children []*Item
}

// Item represents a DLNA object.item.audioItem node.
type Item struct {
	ID       string
	ParentID string
	Title    string
	Class    string // upnp:class value, e.g. "object.item.audioItem.musicTrack"
	Artist   string
	Album    string
	MimeType string
	DurSec   float64 // duration in seconds
	Payload  []byte  // raw audio bytes served at /MediaItems/<ID>.<ext>

	// ArtPayload, when non-empty, is album-art image bytes served at
	// /AlbumArt/<ID>.<ext> and advertised in DIDL-Lite via <upnp:albumArtURI>.
	// ArtMime is the art image MIME type (e.g. "image/jpeg").
	ArtPayload []byte
	ArtMime    string
}

// mediaExt returns the file extension for this item's MIME type.
func (it *Item) mediaExt() string {
	switch it.MimeType {
	case "audio/x-wav", "audio/wav":
		return "wav"
	case "audio/mpeg":
		return "mp3"
	case "audio/flac", "audio/x-flac":
		return "flac"
	case "audio/mp4", "audio/m4a", "audio/x-m4a":
		return "m4a"
	case "audio/ogg":
		return "ogg"
	default:
		return "bin"
	}
}

// artExt returns the file extension for an album-art MIME type.
func artExt(mime string) string {
	switch mime {
	case "image/jpeg", "image/jpg":
		return "jpg"
	case "image/png":
		return "png"
	case "image/webp":
		return "webp"
	case "image/gif":
		return "gif"
	default:
		return "img"
	}
}

// Tree is the in-memory content tree. Root containers are stored by ID.
type Tree struct {
	Containers []*Container // ordered; first container is the default music folder
}

// DefaultTree returns a minimal two-track music library that matches the
// structure used in the spec/capture comments.
func DefaultTree() *Tree {
	track01 := silentWAV(1, 8000, 1)
	track02 := silentWAV(1, 8000, 1)

	music := &Container{
		ID:       "1",
		ParentID: "0",
		Title:    "Music",
		Class:    "object.container.storageFolder",
		Children: []*Item{
			{
				ID:         "1$4$0",
				ParentID:   "1$4",
				Title:      "track01",
				Class:      "object.item.audioItem.musicTrack",
				Artist:     "Test Artist",
				Album:      "Test Album",
				MimeType:   "audio/x-wav",
				DurSec:     1.0,
				Payload:    track01,
				ArtPayload: tinyPNG,
				ArtMime:    "image/png",
			},
			{
				ID:         "1$4$1",
				ParentID:   "1$4",
				Title:      "track02",
				Class:      "object.item.audioItem.musicTrack",
				Artist:     "Test Artist",
				Album:      "Test Album",
				MimeType:   "audio/x-wav",
				DurSec:     1.0,
				Payload:    track02,
				ArtPayload: tinyPNG,
				ArtMime:    "image/png",
			},
		},
	}

	return &Tree{Containers: []*Container{music}}
}

// containerByID returns the container with the given ID, or nil.
func (t *Tree) containerByID(id string) *Container {
	for _, c := range t.Containers {
		if c.ID == id {
			return c
		}
	}

	return nil
}

// itemByID returns the first item in any container whose ID matches.
func (t *Tree) itemByID(id string) *Item {
	for _, c := range t.Containers {
		for _, it := range c.Children {
			if it.ID == id {
				return it
			}
		}
	}

	return nil
}

// ----------------------------------------------------------------------------
// Server
// ----------------------------------------------------------------------------

// Option is a functional option for NewServer.
type Option func(*Server)

// WithFriendlyName overrides the UPnP friendlyName.
func WithFriendlyName(name string) Option {
	return func(s *Server) { s.FriendlyName = name }
}

// WithUDN overrides the UPnP Unique Device Name (UUID).
func WithUDN(udn string) Option {
	return func(s *Server) { s.UDN = udn }
}

// WithTree replaces the entire content tree.
func WithTree(tree *Tree) Option {
	return func(s *Server) { s.tree = tree }
}

// Server is the DLNA / UPnP MediaServer implementation.
type Server struct {
	FriendlyName string
	UDN          string
	tree         *Tree
}

// NewServer creates a Server with the supplied options applied.
func NewServer(opts ...Option) *Server {
	s := &Server{
		FriendlyName: "AfterTouch Test Library",
		UDN:          "uuid:4d696e69-444c-164e-9d41-72ecda78e4c1",
		tree:         DefaultTree(),
	}

	for _, o := range opts {
		o(s)
	}

	return s
}

// NewHTTPTest starts an httptest.Server backed by s and returns both.
// Call ts.Close() when the test is done.
func NewHTTPTest(opts ...Option) (*httptest.Server, *Server) {
	s := NewServer(opts...)
	ts := httptest.NewServer(s.HTTPHandler())

	return ts, s
}

// HTTPHandler returns an http.Handler that serves all DLNA endpoints.
func (s *Server) HTTPHandler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/rootDesc.xml", s.serveRootDesc)
	mux.HandleFunc("/ctl/ContentDir", s.serveContentDir)
	mux.HandleFunc("/icons/sm.png", s.serveIcon)
	mux.HandleFunc("/MediaItems/", s.serveMediaItem)
	mux.HandleFunc("/AlbumArt/", s.serveAlbumArt)

	return mux
}

// ----------------------------------------------------------------------------
// /rootDesc.xml
// ----------------------------------------------------------------------------

func (s *Server) serveRootDesc(w http.ResponseWriter, _ *http.Request) {
	type specVersion struct {
		Major int `xml:"major"`
		Minor int `xml:"minor"`
	}

	type icon struct {
		MimeType string `xml:"mimetype"`
		Width    int    `xml:"width"`
		Height   int    `xml:"height"`
		Depth    int    `xml:"depth"`
		URL      string `xml:"url"`
	}

	type service struct {
		ServiceType string `xml:"serviceType"`
		ServiceID   string `xml:"serviceId"`
		ControlURL  string `xml:"controlURL"`
		EventSubURL string `xml:"eventSubURL,omitempty"`
		SCPDURL     string `xml:"SCPDURL,omitempty"`
	}

	type device struct {
		DeviceType   string    `xml:"deviceType"`
		FriendlyName string    `xml:"friendlyName"`
		Manufacturer string    `xml:"manufacturer"`
		ModelName    string    `xml:"modelName"`
		ModelNumber  string    `xml:"modelNumber"`
		SerialNumber string    `xml:"serialNumber"`
		UDN          string    `xml:"UDN"`
		IconList     []icon    `xml:"iconList>icon"`
		ServiceList  []service `xml:"serviceList>service"`
	}

	type rootDesc struct {
		XMLName     xml.Name    `xml:"urn:schemas-upnp-org:device-1-0 root"`
		SpecVersion specVersion `xml:"specVersion"`
		Device      device      `xml:"device"`
	}

	desc := rootDesc{
		SpecVersion: specVersion{Major: 1, Minor: 0},
		Device: device{
			DeviceType:   "urn:schemas-upnp-org:device:MediaServer:1",
			FriendlyName: s.FriendlyName,
			Manufacturer: "AfterTouch",
			ModelName:    "AfterTouch Test MediaServer",
			ModelNumber:  "1",
			SerialNumber: "00000000",
			UDN:          s.UDN,
			IconList: []icon{
				{MimeType: "image/png", Width: 48, Height: 48, Depth: 24, URL: "/icons/sm.png"},
			},
			ServiceList: []service{
				{
					ServiceType: "urn:schemas-upnp-org:service:ContentDirectory:1",
					ServiceID:   "urn:upnp-org:serviceId:ContentDirectory",
					ControlURL:  "/ctl/ContentDir",
					EventSubURL: "/evt/ContentDir",
					SCPDURL:     "/ContentDir.xml",
				},
				{
					ServiceType: "urn:schemas-upnp-org:service:ConnectionManager:1",
					ServiceID:   "urn:upnp-org:serviceId:ConnectionManager",
					ControlURL:  "/ctl/ConnectionMgr",
				},
			},
		},
	}

	w.Header().Set("Content-Type", "text/xml; charset=utf-8")

	if _, err := fmt.Fprint(w, xml.Header); err != nil {
		http.Error(w, "write error", http.StatusInternalServerError)

		return
	}

	enc := xml.NewEncoder(w)
	enc.Indent("", "")

	if err := enc.Encode(desc); err != nil {
		// Headers already sent; best effort.
		return
	}
}

// ----------------------------------------------------------------------------
// /ctl/ContentDir (ContentDirectory Browse SOAP action)
// ----------------------------------------------------------------------------

// soapBrowseRequest is the envelope we parse from the incoming POST.
type soapBrowseRequest struct {
	Body struct {
		Browse struct {
			ObjectID       string `xml:"ObjectID"`
			BrowseFlag     string `xml:"BrowseFlag"`
			StartingIndex  int    `xml:"StartingIndex"`
			RequestedCount int    `xml:"RequestedCount"`
		} `xml:"Browse"`
	} `xml:"Body"`
}

func (s *Server) serveContentDir(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)

		return
	}

	// Parse the SOAP envelope (lenient: ignore namespace prefixes via xml.Unmarshal).
	var req soapBrowseRequest
	if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad soap envelope", http.StatusBadRequest)

		return
	}

	objectID := req.Body.Browse.ObjectID
	startIndex := req.Body.Browse.StartingIndex
	reqCount := req.Body.Browse.RequestedCount

	base := baseURL(r)

	var didl string

	var total int

	if req.Body.Browse.BrowseFlag == "BrowseMetadata" {
		// Metadata for a single object (the speaker resolves a track's <res>
		// this way before playing it).
		didl, total = s.browseMetadata(objectID, base)
	} else {
		switch objectID {
		case "0":
			// Root: return containers.
			didl, total = s.browseRoot(startIndex, reqCount)
		default:
			// Try as a container ID.
			if c := s.tree.containerByID(objectID); c != nil {
				didl, total = s.browseContainer(c, startIndex, reqCount, base)
			} else {
				// Unknown object: return empty result.
				didl = emptyDIDL()
				total = 0
			}
		}
	}

	// NumberReturned is the count of items in this page.
	var returned int
	if reqCount <= 0 || reqCount > total-startIndex {
		returned = total - startIndex
	} else {
		returned = reqCount
	}

	if returned < 0 {
		returned = 0
	}

	writeSOAPBrowseResponse(w, didl, returned, total)
}

// baseURL builds an absolute http://host:port prefix from the request.
func baseURL(r *http.Request) string {
	scheme := "http"
	if r.TLS != nil {
		scheme = "https"
	}

	return scheme + "://" + r.Host
}

// browseRoot returns DIDL-Lite for the root container (ObjectID "0").
func (s *Server) browseRoot(start, count int) (string, int) {
	containers := s.tree.Containers
	total := len(containers)
	page := page(containers, start, count)

	var b strings.Builder

	b.WriteString(`<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" `)
	b.WriteString(`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" `)
	b.WriteString(`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" `)
	b.WriteString(`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">`)

	for _, c := range page {
		childCount := len(c.Children)
		_, _ = fmt.Fprintf(&b,
			`<container id=%s parentID=%s restricted="1" childCount="%d">`,
			xmlAttr(c.ID), xmlAttr(c.ParentID), childCount,
		)
		b.WriteString(`<dc:title>` + xmlEsc(c.Title) + `</dc:title>`)
		b.WriteString(`<upnp:class>` + xmlEsc(c.Class) + `</upnp:class>`)
		b.WriteString(`</container>`)
	}

	b.WriteString(`</DIDL-Lite>`)

	return b.String(), total
}

// didlOpen is the opening tag (with namespaces) shared by all DIDL-Lite results.
const didlOpen = `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ` +
	`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" ` +
	`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ` +
	`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">`

// writeItemDIDL writes a single DIDL-Lite <item> (title, artist/album, class,
// optional albumArtURI, and the <res> media URL) into b.
func writeItemDIDL(b *strings.Builder, it *Item, base string) {
	size := len(it.Payload)
	dur := formatDuration(it.DurSec)
	resURL := fmt.Sprintf("%s/MediaItems/%s.%s", base, urlPathEsc(it.ID), it.mediaExt())

	_, _ = fmt.Fprintf(b, `<item id=%s parentID=%s restricted="1">`, xmlAttr(it.ID), xmlAttr(it.ParentID))
	b.WriteString(`<dc:title>` + xmlEsc(it.Title) + `</dc:title>`)

	if it.Artist != "" {
		b.WriteString(`<upnp:artist>` + xmlEsc(it.Artist) + `</upnp:artist>`)
	}

	if it.Album != "" {
		b.WriteString(`<upnp:album>` + xmlEsc(it.Album) + `</upnp:album>`)
	}

	b.WriteString(`<upnp:class>` + xmlEsc(it.Class) + `</upnp:class>`)

	if len(it.ArtPayload) > 0 {
		artURL := fmt.Sprintf("%s/AlbumArt/%s.%s", base, urlPathEsc(it.ID), artExt(it.ArtMime))
		b.WriteString(`<upnp:albumArtURI>` + xmlEsc(artURL) + `</upnp:albumArtURI>`)
	}

	_, _ = fmt.Fprintf(b,
		`<res size="%d" duration="%s" bitrate="128000" sampleFrequency="8000" nrAudioChannels="1" protocolInfo="http-get:*:%s:*">%s</res>`,
		size, dur, xmlEsc(it.MimeType), xmlEsc(resURL),
	)
	b.WriteString(`</item>`)
}

// browseContainer returns DIDL-Lite for the items inside a container.
func (s *Server) browseContainer(c *Container, start, count int, base string) (string, int) {
	items := c.Children
	total := len(items)
	pageItems := pageItems(items, start, count)

	var b strings.Builder

	b.WriteString(didlOpen)

	for _, it := range pageItems {
		writeItemDIDL(&b, it, base)
	}

	b.WriteString(`</DIDL-Lite>`)

	return b.String(), total
}

// browseMetadata returns DIDL-Lite describing a single object (BrowseMetadata),
// which speakers request to resolve a track's <res> URL before playing it.
// Without this, a STORED_MUSIC select of a track ID returns empty metadata and
// the speaker reports INVALID_SOURCE.
func (s *Server) browseMetadata(objectID, base string) (string, int) {
	var b strings.Builder

	b.WriteString(didlOpen)

	switch {
	case objectID == "0":
		_, _ = fmt.Fprintf(&b,
			`<container id="0" parentID="-1" restricted="1" childCount="%d"><dc:title>Root</dc:title><upnp:class>object.container.storageFolder</upnp:class></container>`,
			len(s.tree.Containers),
		)
	case s.tree.containerByID(objectID) != nil:
		c := s.tree.containerByID(objectID)
		_, _ = fmt.Fprintf(&b,
			`<container id=%s parentID=%s restricted="1" childCount="%d">`,
			xmlAttr(c.ID), xmlAttr(c.ParentID), len(c.Children),
		)
		b.WriteString(`<dc:title>` + xmlEsc(c.Title) + `</dc:title>`)
		b.WriteString(`<upnp:class>` + xmlEsc(c.Class) + `</upnp:class>`)
		b.WriteString(`</container>`)
	case s.tree.itemByID(objectID) != nil:
		writeItemDIDL(&b, s.tree.itemByID(objectID), base)
	default:
		b.WriteString(`</DIDL-Lite>`)

		return b.String(), 0
	}

	b.WriteString(`</DIDL-Lite>`)

	return b.String(), 1
}

func emptyDIDL() string {
	return `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ` +
		`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" ` +
		`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ` +
		`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"></DIDL-Lite>`
}

// writeSOAPBrowseResponse writes the full SOAP envelope around the DIDL-Lite result.
func writeSOAPBrowseResponse(w http.ResponseWriter, didl string, returned, total int) {
	w.Header().Set("Content-Type", "text/xml; charset=utf-8")

	// The DIDL-Lite result must appear as XML-escaped text inside the <Result> element.
	escaped := xmlEsc(didl)

	body := `<?xml version="1.0" encoding="utf-8"?>` +
		`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">` +
		`<s:Body>` +
		`<u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">` +
		`<Result>` + escaped + `</Result>` +
		`<NumberReturned>` + strconv.Itoa(returned) + `</NumberReturned>` +
		`<TotalMatches>` + strconv.Itoa(total) + `</TotalMatches>` +
		`<UpdateID>0</UpdateID>` +
		`</u:BrowseResponse>` +
		`</s:Body>` +
		`</s:Envelope>`

	_, _ = fmt.Fprint(w, body)
}

// ----------------------------------------------------------------------------
// /MediaItems/<id>.<ext>
// ----------------------------------------------------------------------------

func (s *Server) serveMediaItem(w http.ResponseWriter, r *http.Request) {
	// Path: /MediaItems/<id>.<ext>
	rel := strings.TrimPrefix(r.URL.Path, "/MediaItems/")
	// Strip extension.
	dot := strings.LastIndexByte(rel, '.')
	id := rel

	if dot >= 0 {
		id = rel[:dot]
	}

	// The ID may contain '$' which is percent-encoded in URLs.
	// url.PathUnescape would normally handle this, but the mux already decoded it.
	item := s.tree.itemByID(id)
	if item == nil {
		http.NotFound(w, r)

		return
	}

	// ServeContent gives us byte-range support, which real speakers use when
	// streaming audio (raw io.Writer with a fixed Content-Length does not).
	if item.MimeType != "" {
		w.Header().Set("Content-Type", item.MimeType)
	}

	http.ServeContent(w, r, "media."+item.mediaExt(), serveModTime, bytes.NewReader(item.Payload))
}

// ----------------------------------------------------------------------------
// /AlbumArt/<id>.<ext>
// ----------------------------------------------------------------------------

func (s *Server) serveAlbumArt(w http.ResponseWriter, r *http.Request) {
	rel := strings.TrimPrefix(r.URL.Path, "/AlbumArt/")

	dot := strings.LastIndexByte(rel, '.')
	id := rel

	if dot >= 0 {
		id = rel[:dot]
	}

	item := s.tree.itemByID(id)
	if item == nil || len(item.ArtPayload) == 0 {
		http.NotFound(w, r)

		return
	}

	if item.ArtMime != "" {
		w.Header().Set("Content-Type", item.ArtMime)
	}

	http.ServeContent(w, r, "art."+artExt(item.ArtMime), serveModTime, bytes.NewReader(item.ArtPayload))
}

// ----------------------------------------------------------------------------
// /icons/sm.png
// ----------------------------------------------------------------------------

// tinyPNG is a 1x1 white pixel PNG (67 bytes, entirely static).
var tinyPNG = []byte{
	0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
	0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR length + type
	0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // width=1, height=1
	0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // bit depth=8, color=RGB, ...
	0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IHDR CRC; IDAT length + type
	0x54, 0x08, 0xd7, 0x63, 0xf8, 0xff, 0xff, 0x3f, // IDAT data (deflate)
	0x00, 0x05, 0xfe, 0x02, 0xfe, 0xdc, 0xcc, 0x59, // IDAT continued
	0xe7, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IDAT CRC; IEND length + type
	0x44, 0xae, 0x42, 0x60, 0x82, // IEND CRC
}

func (s *Server) serveIcon(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "image/png")
	w.Header().Set("Content-Length", strconv.Itoa(len(tinyPNG)))
	_, _ = w.Write(tinyPNG)
}

// ----------------------------------------------------------------------------
// WAV synthesis
// ----------------------------------------------------------------------------

// silentWAV generates a minimal PCM WAV file: mono, 16-bit, given sample rate
// and duration in seconds. All samples are zero (silence).
func silentWAV(durationSec float64, sampleRate, channels int) []byte {
	numSamples := int(float64(sampleRate) * durationSec)
	bitsPerSample := 16
	byteRate := sampleRate * channels * bitsPerSample / 8
	blockAlign := channels * bitsPerSample / 8
	dataSize := numSamples * blockAlign
	fileSize := 36 + dataSize

	buf := make([]byte, 44+dataSize)

	// RIFF header
	copy(buf[0:], "RIFF")
	le32(buf[4:], uint32(fileSize))
	copy(buf[8:], "WAVE")

	// fmt chunk
	copy(buf[12:], "fmt ")
	le32(buf[16:], 16) // chunk size
	le16(buf[20:], 1)  // PCM
	le16(buf[22:], uint16(channels))
	le32(buf[24:], uint32(sampleRate))
	le32(buf[28:], uint32(byteRate))
	le16(buf[32:], uint16(blockAlign))
	le16(buf[34:], uint16(bitsPerSample))

	// data chunk
	copy(buf[36:], "data")
	le32(buf[40:], uint32(dataSize))
	// samples are already zero

	return buf
}

func le16(b []byte, v uint16) {
	b[0] = byte(v)
	b[1] = byte(v >> 8)
}

func le32(b []byte, v uint32) {
	b[0] = byte(v)
	b[1] = byte(v >> 8)
	b[2] = byte(v >> 16)
	b[3] = byte(v >> 24)
}

// ----------------------------------------------------------------------------
// Utility helpers
// ----------------------------------------------------------------------------

// xmlEsc escapes s for use as XML text content.
func xmlEsc(s string) string {
	var b strings.Builder
	xml.EscapeText(&b, []byte(s)) //nolint:errcheck // strings.Builder never errors

	return b.String()
}

// xmlAttr returns s as a double-quoted XML attribute value with proper escaping.
func xmlAttr(s string) string {
	return `"` + xmlEsc(s) + `"`
}

// urlPathEsc percent-encodes characters that are not safe in a URL path
// segment. We only need to encode '$' (which appears in item IDs).
func urlPathEsc(s string) string {
	return strings.ReplaceAll(s, "$", "%24")
}

// formatDuration converts seconds to "h:mm:ss.mmm" as used in DIDL-Lite.
func formatDuration(sec float64) string {
	ms := int(sec * 1000)
	h := ms / 3600000
	ms -= h * 3600000
	m := ms / 60000
	ms -= m * 60000
	s := ms / 1000
	ms -= s * 1000

	return fmt.Sprintf("%d:%02d:%02d.%03d", h, m, s, ms)
}

// page returns a slice of containers for the requested page.
func page(containers []*Container, start, count int) []*Container {
	if start >= len(containers) {
		return nil
	}

	end := len(containers)
	if count > 0 && start+count < end {
		end = start + count
	}

	return containers[start:end]
}

// pageItems returns a slice of items for the requested page.
func pageItems(items []*Item, start, count int) []*Item {
	if start >= len(items) {
		return nil
	}

	end := len(items)
	if count > 0 && start+count < end {
		end = start + count
	}

	return items[start:end]
}
