// Package datastore provides a simple XML-based datastore for SoundTouch devices.
package datastore

import (
	"bytes"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"log"
	"math/rand"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/gesellix/bose-soundtouch/pkg/models"
	"github.com/gesellix/bose-soundtouch/pkg/service/constants"
)

// ErrGroupNotFound is returned when no group is found for a given device.
var ErrGroupNotFound = errors.New("group not found")

func exists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

// maxSafeIdentifierLength bounds account/device IDs accepted from a
// speaker or third-party pairing tool. Well under typical filesystem
// path-component limits (255 bytes); generous for any realistic
// margeAccountUUID or MAC-derived device ID.
const maxSafeIdentifierLength = 128

// IsSafeIdentifier returns true if the given identifier is safe to use
// as a single path component (for account IDs, device IDs, etc.), and
// safe to embed in the other places these values end up: XML sent to a
// speaker, log lines, and datastore-key comparisons. It rejects empty
// or overlong strings, path separators, and parent directory
// references.
//
// The allowed character set intentionally excludes XML/HTML-special
// characters (`< > & " '`), whitespace, and shell/URL metacharacters
// (see #634's `postSetMargeAccount`, which interpolates an account ID
// into an XML body, and `PairAccount`, which interpolates one into a
// literal `envswitch accountid set <id>` telnet command line) even
// though it accepts more than Bose's own 7-digit account format —
// devices paired via third-party or manual tooling (e.g. the
// USB-stick SSH-enable method) can report arbitrary margeAccountUUID
// values such as "stick@local".
func IsSafeIdentifier(id string) bool {
	if id == "" || len(id) > maxSafeIdentifierLength {
		return false
	}

	// Disallow obvious path traversal / multi-component paths.
	if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") {
		return false
	}

	// Letters, digits, and a conservative set of punctuation seen in
	// real-world IDs: underscore, dash, dot, colon (MAC-like IDs), and
	// '@' (e.g. "stick@local"). Everything else — including all XML,
	// HTML, shell, and URL metacharacters, whitespace, and control
	// characters — is rejected.
	for i := 0; i < len(id); i++ {
		c := id[i]
		if (c >= 'a' && c <= 'z') ||
			(c >= 'A' && c <= 'Z') ||
			(c >= '0' && c <= '9') ||
			c == '_' || c == '-' || c == '.' || c == ':' || c == '@' {
			continue
		}

		return false
	}

	return true
}

// DataStore represents the device and configuration storage.
type DataStore struct {
	// DataDir is the (possibly relative) base directory for all datastore files.
	DataDir string
	// baseDir is the absolute, normalized base directory used for path safety checks.
	baseDir string

	// rootMu guards lazy initialisation of root.
	rootMu sync.Mutex
	// root is an os.Root anchored at baseDir. All filesystem operations within
	// the datastore go through it, so ".." or absolute paths in
	// caller-supplied components cannot escape the root — the Go runtime
	// enforces containment regardless of what safeJoin's output looks like.
	// Lazily opened so NewDataStore stays a pure constructor.
	root *os.Root

	eventMutex     sync.RWMutex
	deviceEvents   map[string][]models.DeviceEvent
	idMutex        sync.RWMutex
	deviceMappings map[string]string
	fileMutex      sync.RWMutex
}

// normalizeMAC normalizes a MAC address to a consistent format
func normalizeMAC(mac string) string {
	if mac == "" {
		return ""
	}
	// Remove spaces and common separators, then convert to uppercase
	mac = strings.TrimSpace(mac)
	mac = strings.ReplaceAll(mac, " ", "")
	mac = strings.ReplaceAll(mac, ":", "")
	mac = strings.ReplaceAll(mac, "-", "")
	mac = strings.ToUpper(mac)

	return mac
}

// NewDataStore creates a new DataStore.
// NewDataStore creates a new DataStore instance with the specified data directory.
func NewDataStore(dataDir string) *DataStore {
	if dataDir == "" {
		dataDir = "data"
	}

	absBase, err := filepath.Abs(dataDir)
	if err != nil {
		// Fallback to the provided dataDir if Abs fails; this preserves existing behavior.
		absBase = dataDir
	}

	return &DataStore{
		DataDir:        dataDir,
		baseDir:        absBase,
		deviceEvents:   make(map[string][]models.DeviceEvent),
		deviceMappings: make(map[string]string),
	}
}

// safeJoin joins the given path elements to the datastore baseDir and ensures
// that the resulting absolute path stays within baseDir. If any element would
// escape baseDir (absolute path, "..", or — on Windows — a drive/colon), the
// function falls back to baseDir to prevent directory traversal.
//
// The validation up-front uses filepath.IsLocal, which CodeQL recognises as a
// path-traversal sanitiser, so taint analysis at call sites that subsequently
// hand the result to os.ReadFile / os.Open / os.Remove etc. propagates safely.
// The post-join prefix check below stays as belt-and-suspenders for any
// unusual platform behaviour IsLocal does not cover.
func (ds *DataStore) safeJoin(elem ...string) string {
	for _, e := range elem {
		if e == "" {
			// filepath.Join silently skips empty elements, but IsLocal
			// returns false for "" — treat empties as a no-op.
			continue
		}

		if !filepath.IsLocal(e) {
			// Element is absolute, contains ".." or a reserved Windows
			// component. Refuse to join.
			return ds.baseDir
		}
	}

	// Join the base directory with the (now sanitised) elements.
	path := filepath.Join(append([]string{ds.baseDir}, elem...)...)

	absPath, err := filepath.Abs(path)
	if err != nil {
		// On error, fall back to baseDir to avoid using an unexpected path.
		return ds.baseDir
	}

	base := ds.baseDir
	if base == "" {
		// If baseDir is not set for some reason, fall back to original path.
		return absPath
	}

	// Belt-and-suspenders: ensure the resolved path is within the base
	// directory even if filepath.IsLocal somehow misjudged a component.
	baseWithSep := base
	if !strings.HasSuffix(baseWithSep, string(os.PathSeparator)) {
		baseWithSep += string(os.PathSeparator)
	}

	if absPath == base || strings.HasPrefix(absPath, baseWithSep) {
		return absPath
	}

	// If the path would escape the base directory, return baseDir as a safe default.
	return base
}

// SafeJoin returns a safe joined path relative to the datastore base directory.
func (ds *DataStore) SafeJoin(elem ...string) string {
	return ds.safeJoin(elem...)
}

// getRoot returns the lazily-opened *os.Root anchored at baseDir. The root is
// created on first call after MkdirAll-ing baseDir; subsequent calls return
// the cached handle. Filesystem operations performed via the returned root
// cannot escape baseDir even if the relative path passed to them is malicious.
func (ds *DataStore) getRoot() (*os.Root, error) {
	ds.rootMu.Lock()
	defer ds.rootMu.Unlock()

	if ds.root != nil {
		return ds.root, nil
	}

	if ds.baseDir == "" {
		return nil, fmt.Errorf("datastore: baseDir not configured")
	}

	if err := os.MkdirAll(ds.baseDir, 0755); err != nil {
		return nil, fmt.Errorf("datastore: ensure baseDir %s: %w", ds.baseDir, err)
	}

	r, err := os.OpenRoot(ds.baseDir)
	if err != nil {
		return nil, fmt.Errorf("datastore: open root at %s: %w", ds.baseDir, err)
	}

	ds.root = r

	return r, nil
}

// Close releases any open filesystem handles held by the datastore. Safe to
// call on a never-used DataStore.
func (ds *DataStore) Close() error {
	ds.rootMu.Lock()
	defer ds.rootMu.Unlock()

	if ds.root == nil {
		return nil
	}

	err := ds.root.Close()
	ds.root = nil

	return err
}

// rootRel converts a path produced by safeJoin (or by filepath.Join over
// ds.DataDir) into the form expected by *os.Root methods — relative to
// baseDir, no leading separator. Tolerates both absolute paths and paths
// whose root is the relative ds.DataDir.
//
// Returns "." for baseDir itself.
func (ds *DataStore) rootRel(absPath string) (string, error) {
	// If the input is relative, absolutise so the comparison with baseDir
	// works regardless of how DataDir was originally configured.
	if !filepath.IsAbs(absPath) {
		a, err := filepath.Abs(absPath)
		if err != nil {
			return "", fmt.Errorf("datastore: absolutise %s: %w", absPath, err)
		}

		absPath = a
	}

	if absPath == ds.baseDir {
		return ".", nil
	}

	rel, err := filepath.Rel(ds.baseDir, absPath)
	if err != nil {
		return "", fmt.Errorf("datastore: %s is outside baseDir: %w", absPath, err)
	}

	if rel == "." || rel == "" {
		return ".", nil
	}

	if strings.HasPrefix(rel, "..") {
		return "", fmt.Errorf("datastore: %s is outside baseDir", absPath)
	}

	return rel, nil
}

// rootStat is the os.Stat equivalent for a path under baseDir.
func (ds *DataStore) rootStat(absPath string) (os.FileInfo, error) {
	r, err := ds.getRoot()
	if err != nil {
		return nil, err
	}

	rel, err := ds.rootRel(absPath)
	if err != nil {
		return nil, err
	}

	return r.Stat(rel)
}

// rootReadFile is the os.ReadFile equivalent.
func (ds *DataStore) rootReadFile(absPath string) ([]byte, error) {
	r, err := ds.getRoot()
	if err != nil {
		return nil, err
	}

	rel, err := ds.rootRel(absPath)
	if err != nil {
		return nil, err
	}

	return r.ReadFile(rel)
}

// rootWriteFile is the os.WriteFile equivalent.
func (ds *DataStore) rootWriteFile(absPath string, data []byte, perm os.FileMode) error {
	r, err := ds.getRoot()
	if err != nil {
		return err
	}

	rel, err := ds.rootRel(absPath)
	if err != nil {
		return err
	}

	return r.WriteFile(rel, data, perm)
}

// rootMkdirAll is the os.MkdirAll equivalent.
func (ds *DataStore) rootMkdirAll(absPath string, perm os.FileMode) error {
	r, err := ds.getRoot()
	if err != nil {
		return err
	}

	rel, err := ds.rootRel(absPath)
	if err != nil {
		return err
	}

	if rel == "." {
		return nil
	}

	return r.MkdirAll(rel, perm)
}

// rootRemove is the os.Remove equivalent.
func (ds *DataStore) rootRemove(absPath string) error {
	r, err := ds.getRoot()
	if err != nil {
		return err
	}

	rel, err := ds.rootRel(absPath)
	if err != nil {
		return err
	}

	return r.Remove(rel)
}

// rootRemoveAll is the os.RemoveAll equivalent.
func (ds *DataStore) rootRemoveAll(absPath string) error {
	r, err := ds.getRoot()
	if err != nil {
		return err
	}

	rel, err := ds.rootRel(absPath)
	if err != nil {
		return err
	}

	return r.RemoveAll(rel)
}

// rootRename is the os.Rename equivalent. Both paths must be under baseDir.
func (ds *DataStore) rootRename(oldAbs, newAbs string) error {
	r, err := ds.getRoot()
	if err != nil {
		return err
	}

	oldRel, err := ds.rootRel(oldAbs)
	if err != nil {
		return err
	}

	newRel, err := ds.rootRel(newAbs)
	if err != nil {
		return err
	}

	return r.Rename(oldRel, newRel)
}

// rootReadDir lists the entries in absPath. Equivalent to os.ReadDir,
// including the same alphabetical-by-name sort order — *os.File.ReadDir(-1)
// returns entries in directory order, but callers (and existing tests)
// depend on the sorted contract that os.ReadDir documents.
func (ds *DataStore) rootReadDir(absPath string) ([]os.DirEntry, error) {
	r, err := ds.getRoot()
	if err != nil {
		return nil, err
	}

	rel, err := ds.rootRel(absPath)
	if err != nil {
		return nil, err
	}

	f, err := r.Open(rel)
	if err != nil {
		return nil, err
	}

	defer func() { _ = f.Close() }()

	entries, err := f.ReadDir(-1)
	if err != nil {
		return entries, err
	}

	sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })

	return entries, nil
}

// rootExists is true when absPath exists under baseDir.
func (ds *DataStore) rootExists(absPath string) bool {
	_, err := ds.rootStat(absPath)
	return err == nil
}

// ReadDirUnderBase lists the entries in absPath, which must resolve to a
// directory under the datastore baseDir. Cross-package callers (marge,
// handlers, …) use this instead of os.ReadDir so that the underlying
// *os.Root sanitises the path against traversal.
func (ds *DataStore) ReadDirUnderBase(absPath string) ([]os.DirEntry, error) {
	return ds.rootReadDir(absPath)
}

// MkdirAllUnderBase creates a directory tree under baseDir.
func (ds *DataStore) MkdirAllUnderBase(absPath string, perm os.FileMode) error {
	return ds.rootMkdirAll(absPath, perm)
}

// WriteFileUnderBase atomically writes data to absPath, which must be under
// baseDir.
func (ds *DataStore) WriteFileUnderBase(absPath string, data []byte, perm os.FileMode) error {
	return ds.rootWriteFile(absPath, data, perm)
}

// rootOpen is the os.Open equivalent for a path under baseDir. The caller
// owns the returned *os.File and must Close it.
func (ds *DataStore) rootOpen(absPath string) (*os.File, error) {
	r, err := ds.getRoot()
	if err != nil {
		return nil, err
	}

	rel, err := ds.rootRel(absPath)
	if err != nil {
		return nil, err
	}

	return r.Open(rel)
}

// ListAccounts returns a list of all account IDs (directories in the data root).
func (ds *DataStore) ListAccounts() ([]string, error) {
	ds.fileMutex.RLock()
	defer ds.fileMutex.RUnlock()

	// Account data is stored in 'accounts' subdirectory within the data root.
	accountsDir := filepath.Join(ds.baseDir, "accounts")
	if !ds.rootExists(accountsDir) {
		return []string{"default"}, nil
	}

	entries, err := ds.rootReadDir(accountsDir)
	if err != nil {
		return nil, err
	}

	accounts := make([]string, 0)

	for _, entry := range entries {
		if entry.IsDir() {
			// Basic filter to ignore common hidden/system dirs
			if entry.Name() != ".git" && entry.Name() != "logs" {
				accounts = append(accounts, entry.Name())
			}
		}
	}

	if len(accounts) == 0 {
		accounts = append(accounts, "default")
	}

	return accounts, nil
}

// AccountDir returns the directory path for a specific account.
func (ds *DataStore) AccountDir(account string) string {
	return ds.safeJoin("accounts", account)
}

// AccountDevicesDir returns the devices directory path for a specific account.
func (ds *DataStore) AccountDevicesDir(account string) string {
	return ds.safeJoin("accounts", account, constants.DevicesDir)
}

// AccountDeviceDir returns the directory path for a specific device within an account.
func (ds *DataStore) AccountDeviceDir(account, device string) string {
	// First, check if the device directory exists directly with the given deviceID
	// This prioritizes MAC-based deviceIDs over legacy mappings
	directPath := ds.safeJoin("accounts", account, constants.DevicesDir, device)
	if _, err := ds.rootStat(directPath); err == nil {
		// Directory exists, use the direct deviceID (preferred for MAC-based IDs)
		return directPath
	}

	// If direct path doesn't exist, check device mappings for backward compatibility
	ds.idMutex.RLock()

	mappedDevice, ok := ds.deviceMappings[device]
	if !ok {
		// Try with normalized MAC address
		normalizedDevice := normalizeMAC(device)
		mappedDevice, ok = ds.deviceMappings[normalizedDevice]
	}

	ds.idMutex.RUnlock()

	if ok {
		// Use the mapped device only if it exists and the direct path doesn't
		mappedPath := ds.safeJoin("accounts", account, constants.DevicesDir, mappedDevice)
		if _, err := ds.rootStat(mappedPath); err == nil {
			return mappedPath
		}
	}

	// If neither direct path nor mapping work, return the direct path
	// (this allows new devices to be created with MAC-based IDs)
	return directPath
}

// GetDeviceInfo retrieves device information for the specified account and device.
func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) {
	ds.fileMutex.RLock()
	defer ds.fileMutex.RUnlock()

	return ds.getDeviceInfoNoLock(account, device)
}

func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.ServiceDeviceInfo, error) {
	path := ds.AccountDeviceDir(account, device)
	deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile)

	data, err := ds.rootReadFile(deviceInfoPath)
	if err != nil {
		return nil, err
	}

	var info struct {
		XMLName    xml.Name `xml:"info"`
		DeviceID   string   `xml:"deviceID,attr"`
		Name       string   `xml:"name"`
		Type       string   `xml:"type"`
		ModuleType string   `xml:"moduleType"`
		Components []struct {
			Category        string `xml:"componentCategory"`
			SoftwareVersion string `xml:"softwareVersion"`
			SerialNumber    string `xml:"serialNumber"`
		} `xml:"components>component"`
		NetworkInfo []struct {
			Type       string `xml:"type,attr"`
			IPAddress  string `xml:"ipAddress"`
			MacAddress string `xml:"macAddress"`
		} `xml:"networkInfo"`
		DiscoveryMethod string `xml:"discoveryMethod"`
		CreatedOn       string `xml:"createdOn,omitempty"`
		UpdatedOn       string `xml:"updatedOn,omitempty"`
	}

	if err := xml.Unmarshal(data, &info); err != nil {
		return nil, err
	}

	deviceInfo := &models.ServiceDeviceInfo{
		DeviceID:        info.DeviceID,
		AccountID:       account, // Set AccountID from parameter
		ProductCode:     fmt.Sprintf("%s %s", info.Type, info.ModuleType),
		Name:            info.Name,
		DiscoveryMethod: info.DiscoveryMethod,
		CreatedOn:       info.CreatedOn,
		UpdatedOn:       info.UpdatedOn,
	}

	for _, comp := range info.Components {
		deviceInfo.Components = append(deviceInfo.Components, models.ServiceComponent{
			Category:        comp.Category,
			SoftwareVersion: comp.SoftwareVersion,
			SerialNumber:    comp.SerialNumber,
		})

		switch comp.Category {
		case "SCM":
			deviceInfo.FirmwareVersion = comp.SoftwareVersion
			deviceInfo.DeviceSerialNumber = comp.SerialNumber
		case "PackagedProduct":
			deviceInfo.ProductSerialNumber = comp.SerialNumber
		}
	}

	for _, net := range info.NetworkInfo {
		if net.Type == "SCM" {
			deviceInfo.IPAddress = net.IPAddress
			deviceInfo.MacAddress = net.MacAddress
		}
	}

	return deviceInfo, nil
}

// ListAllDevices returns a list of all devices in all accounts.
func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
	dirs := ds.getPossibleDataDirs()
	if len(dirs) == 0 {
		return []models.ServiceDeviceInfo{}, nil
	}

	devices := []models.ServiceDeviceInfo{}

	type seenEntry struct {
		index   int
		account string
	}

	seenIDs := make(map[string]seenEntry)

	for _, dir := range dirs {
		entries, err := os.ReadDir(dir)
		if err != nil {
			continue
		}

		// Sort "default" to the back so a real-account entry always
		// wins the first-seen race. "default" exists as a pre-pair
		// placeholder; once the speaker pairs with a real account it
		// becomes orphan state. We don't try to pick a winner among
		// multiple real accounts via mtime or other proxies — the
		// authoritative signal is the URL of the speaker's incoming
		// PUT, which only the live handler sees. Stale real-account
		// entries are flagged as findings by the consistency check
		// instead.
		accounts := make([]os.DirEntry, 0, len(entries))
		for _, e := range entries {
			if e.IsDir() {
				accounts = append(accounts, e)
			}
		}

		sort.SliceStable(accounts, func(i, j int) bool {
			ai, aj := accounts[i].Name(), accounts[j].Name()
			if ai == accountIDDefault && aj != accountIDDefault {
				return false
			}

			if aj == accountIDDefault && ai != accountIDDefault {
				return true
			}

			return ai < aj
		})

		for _, acc := range accounts {
			accDevices := ds.listDevicesInAccount(dir, acc.Name())
			for i := range accDevices {
				info := accDevices[i]
				info.AccountID = acc.Name()

				key := info.DeviceID
				if key == "" {
					key = info.IPAddress
				}

				entry, alreadySeen := seenIDs[key]
				if !alreadySeen {
					devices = append(devices, info)
					seenIDs[key] = seenEntry{index: len(devices) - 1, account: info.AccountID}

					continue
				}

				// "default" never replaces a real-account entry.
				// But when two "default" entries collide across data dirs,
				// prefer the one with a non-empty name (more information).
				if info.AccountID == accountIDDefault {
					if entry.account == accountIDDefault && devices[entry.index].Name == "" && info.Name != "" {
						devices[entry.index] = info
						seenIDs[key] = seenEntry{index: entry.index, account: info.AccountID}
					}

					continue
				}

				// First real-account encountered wins (sort is
				// stable + alphabetical so the result is
				// deterministic across runs). Don't pick a different
				// winner here — the consistency check enumerates all
				// the duplicate account dirs for the operator to
				// clean up.
				if entry.account == accountIDDefault {
					devices[entry.index] = info
					seenIDs[key] = seenEntry{index: entry.index, account: info.AccountID}
				}
			}
		}
	}

	return devices, nil
}

// AllAccountsForDevice returns every account directory that contains a
// device with the given deviceID, in their on-disk order. Used by the
// consistency check to enumerate stale account entries left behind
// when the speaker was re-paired to a different account.
func (ds *DataStore) AllAccountsForDevice(deviceID string) []string {
	if deviceID == "" {
		return nil
	}

	var hits []string

	seen := map[string]bool{}

	for _, dir := range ds.getPossibleDataDirs() {
		entries, err := os.ReadDir(dir)
		if err != nil {
			continue
		}

		for _, acc := range entries {
			if !acc.IsDir() {
				continue
			}

			devicePath := filepath.Join(dir, acc.Name(), "devices", deviceID)
			if _, err := os.Stat(devicePath); err != nil {
				continue
			}

			if !seen[acc.Name()] {
				seen[acc.Name()] = true
				hits = append(hits, acc.Name())
			}
		}
	}

	return hits
}

// accountIDDefault is the placeholder account id assigned to records
// that exist before a speaker has been paired with a real Marge
// account. Treated as fallback in ListAllDevices and skipped in
// consistency checks when a real-account entry exists for the same
// device.
const accountIDDefault = "default"

func (ds *DataStore) getPossibleDataDirs() []string {
	dirs := []string{}
	// Check primary data directory
	if ds.DataDir != "" {
		if exists(filepath.Join(ds.DataDir, "accounts")) {
			dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
		}
		// Also check the DataDir itself as a base for account directories
		if exists(ds.DataDir) && ds.DataDir != "." {
			dirs = append(dirs, ds.DataDir)
		}
	}

	// Also check st-go/data/accounts if it's different and exists
	altDir := "st-go/data/accounts"
	if exists(altDir) {
		dirs = append(dirs, altDir)
	}
	// And st-go/data/accounts/default
	altDir2 := "st-go/data"
	if exists(altDir2) {
		dirs = append(dirs, altDir2)
	}
	// And repro_data
	altDir3 := "repro_data"
	if exists(altDir3) {
		dirs = append(dirs, altDir3)
	}

	// Add special handling for test environments where we might have account directories
	// directly in the current working directory or a temp dir.
	// Walk up from DataDir to find any 'accounts' directory.
	curr := ds.DataDir
	for i := 0; i < 3; i++ {
		absCurr, _ := filepath.Abs(curr)
		if exists(filepath.Join(absCurr, "accounts")) {
			dirs = append(dirs, filepath.Join(absCurr, "accounts"))
		}

		if exists(absCurr) {
			dirs = append(dirs, absCurr)
		}

		if curr == "." || curr == "/" || curr == "" {
			break
		}

		curr = filepath.Dir(curr)
	}

	// Remove duplicates and ensure unique directories
	uniqueDirs := make(map[string]bool)
	result := []string{}

	for _, dir := range dirs {
		absDir, err := filepath.Abs(dir)
		if err != nil {
			absDir = dir
		}

		if !uniqueDirs[absDir] {
			uniqueDirs[absDir] = true

			result = append(result, dir)
		}
	}

	return result
}

func (ds *DataStore) listDevicesInAccount(baseDir, accountName string) []models.ServiceDeviceInfo {
	devices := []models.ServiceDeviceInfo{}
	devicesDir := filepath.Join(baseDir, accountName, constants.DevicesDir)

	deviceEntries, err := os.ReadDir(devicesDir)
	if err != nil {
		return devices
	}

	for _, dev := range deviceEntries {
		var (
			info *models.ServiceDeviceInfo
			err  error
		)

		if !dev.IsDir() {
			if dev.Name() == constants.DeviceInfoFile {
				// Special case for DeviceInfo.xml directly in devicesDir
				path := filepath.Join(devicesDir, constants.DeviceInfoFile)
				info, err = ds.parseDeviceInfoFile(path)
			}
		} else {
			path := filepath.Join(devicesDir, dev.Name(), constants.DeviceInfoFile)
			info, err = ds.parseDeviceInfoFile(path)
		}

		if err == nil && info != nil {
			// Update bidirectional device mappings for resolution
			ds.updateDeviceMappings(*info)

			devices = append(devices, *info)
		}
	}

	return devices
}

func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}

	var info struct {
		XMLName    xml.Name `xml:"info"`
		DeviceID   string   `xml:"deviceID,attr"`
		Name       string   `xml:"name"`
		Type       string   `xml:"type"`
		ModuleType string   `xml:"moduleType"`
		Components []struct {
			Category        string `xml:"componentCategory"`
			SoftwareVersion string `xml:"softwareVersion"`
			SerialNumber    string `xml:"serialNumber"`
		} `xml:"components>component"`
		NetworkInfo []struct {
			Type       string `xml:"type,attr"`
			IPAddress  string `xml:"ipAddress"`
			MacAddress string `xml:"macAddress"`
		} `xml:"networkInfo"`
		DiscoveryMethod string `xml:"discoveryMethod"`
		CreatedOn       string `xml:"createdOn,omitempty"`
		UpdatedOn       string `xml:"updatedOn,omitempty"`
	}

	if err := xml.Unmarshal(data, &info); err != nil {
		return nil, err
	}

	deviceInfo := &models.ServiceDeviceInfo{
		DeviceID:        info.DeviceID,
		ProductCode:     info.Type,
		Name:            info.Name,
		DiscoveryMethod: info.DiscoveryMethod,
	}

	for _, comp := range info.Components {
		deviceInfo.Components = append(deviceInfo.Components, models.ServiceComponent{
			Category:        comp.Category,
			SoftwareVersion: comp.SoftwareVersion,
			SerialNumber:    comp.SerialNumber,
		})

		switch comp.Category {
		case "SCM":
			deviceInfo.FirmwareVersion = comp.SoftwareVersion
			deviceInfo.DeviceSerialNumber = comp.SerialNumber
		case "PackagedProduct":
			deviceInfo.ProductSerialNumber = comp.SerialNumber
		}
	}

	for _, net := range info.NetworkInfo {
		if net.Type == "SCM" {
			deviceInfo.IPAddress = net.IPAddress
			deviceInfo.MacAddress = net.MacAddress
		}
	}

	return deviceInfo, nil
}

// GetPresets retrieves all presets for the specified account and device.
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
	presets, needsRewrite, err := ds.readPresetsLocked(account, device)
	if err != nil {
		return nil, err
	}

	if needsRewrite {
		log.Printf("[Datastore] Presets.xml for device %s used legacy <ContentItem> format; rewriting in canonical form", sanitizeLog(device))

		if werr := ds.SavePresets(account, device, presets); werr != nil {
			log.Printf("[Datastore] failed to rewrite normalised Presets.xml for device %s: %s", sanitizeLog(device), sanitizeErr(werr))
		}
	}

	return presets, nil
}

// readPresetsLocked is the locked read half of GetPresets. It returns the
// parsed presets and a flag indicating whether the on-disk file used the
// legacy <ContentItem> (capital C) format that needs rewriting.
func (ds *DataStore) readPresetsLocked(account, device string) ([]models.ServicePreset, bool, error) {
	ds.fileMutex.RLock()
	defer ds.fileMutex.RUnlock()

	path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)

	data, err := ds.rootReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			log.Printf("[Datastore] readPresetsLocked: no Presets.xml at %s — reporting no presets", sanitizeLog(path))

			return []models.ServicePreset{}, false, nil
		}

		return nil, false, err
	}

	// An empty / 0-byte Presets.xml (e.g. truncated by an unclean power-cut on
	// the speaker's NAND) is treated as "no presets" rather than a hard parse
	// error, so the device-level /presets endpoint returns an empty list
	// instead of HTTP 500. See #458.
	if len(bytes.TrimSpace(data)) == 0 {
		log.Printf("[Datastore] readPresetsLocked: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))

		return []models.ServicePreset{}, false, nil
	}

	var presetsWrap struct {
		Presets []struct {
			ID          string `xml:"id,attr"`
			CreatedOn   string `xml:"createdOn,attr"`
			UpdatedOn   string `xml:"updatedOn,attr"`
			ContentItem struct {
				Source        string `xml:"source,attr"`
				Type          string `xml:"type,attr"`
				Location      string `xml:"location,attr"`
				SourceAccount string `xml:"sourceAccount,attr"`
				IsPresetable  string `xml:"isPresetable,attr"`
				ItemName      string `xml:"itemName"`
				ContainerArt  string `xml:"containerArt"`
			} `xml:"contentItem"`
			SourceID string `xml:"sourceid"`
		} `xml:"preset"`
	}

	// encoding/xml is case-sensitive. Older AfterTouch versions (and raw
	// speaker XML) used <ContentItem> (capital C); normalise to lowercase
	// before unmarshaling so legacy files are parsed correctly.
	normalized := bytes.ReplaceAll(data, []byte("<ContentItem"), []byte("<contentItem"))
	normalized = bytes.ReplaceAll(normalized, []byte("</ContentItem>"), []byte("</contentItem>"))

	needsRewrite := !bytes.Equal(normalized, data)

	if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
		log.Printf("[Datastore] readPresetsLocked: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))

		return []models.ServicePreset{}, false, nil
	}

	presets := []models.ServicePreset{}

	for i := range presetsWrap.Presets {
		p := &presetsWrap.Presets[i]

		presets = append(presets, models.ServicePreset{
			ServiceContentItem: models.ServiceContentItem{
				Name:            p.ContentItem.ItemName,
				Source:          repairLeakedSource(account, device, "preset "+p.ID, p.ContentItem.Source, p.SourceID, ds),
				Type:            p.ContentItem.Type,
				ContentItemType: p.ContentItem.Type,
				Location:        p.ContentItem.Location,
				SourceAccount:   p.ContentItem.SourceAccount,
				IsPresetable:    p.ContentItem.IsPresetable,
				SourceID:        p.SourceID,
			},
			ID:           p.ID,
			ButtonNumber: p.ID,
			ContainerArt: p.ContentItem.ContainerArt,
			CreatedOn:    p.CreatedOn,
			UpdatedOn:    p.UpdatedOn,
		})
	}

	return presets, needsRewrite, nil
}

// repairLeakedSource quietly substitutes the speaker-perspective
// SourceKeyType for a persisted Source that has the protocol-level
// "Audio" leak signature from the legacy syncPresets/syncRecents path.
// Critically it only repairs the *leak* — when persisted Source carries
// a non-leak symbolic value, even one that disagrees with the current
// Sources.xml mapping for the same SourceID, we leave it alone. That's
// what protects the GH-343 case: a TUNEIN preset whose SourceID was
// re-classified to RADIOPLAYER in Sources.xml must stay TUNEIN here,
// otherwise the speaker's previously-stored intent gets silently
// overwritten by the stale source-list entry.
//
// Speaker is the source of truth: this only un-rots data that was
// never the speaker's perspective to begin with.
func repairLeakedSource(account, device, label, persistedSource, sourceID string, ds *DataStore) string {
	if !isLeakedSourceValue(persistedSource) {
		return persistedSource
	}

	if sourceID == "" {
		return persistedSource
	}

	sources, err := ds.getConfiguredSourcesLocked(account, device)
	if err != nil {
		return persistedSource
	}

	for i := range sources {
		if sources[i].ID == sourceID && sources[i].SourceKeyType != "" {
			log.Printf("[Datastore] %s: repaired leaked source %q -> %q via sourceid=%s (account=%s device=%s) — likely written by the pre-fix marge.syncPresets/syncRecents path; speaker's perspective is now restored on read",
				sanitizeLog(label), sanitizeLog(persistedSource), sanitizeLog(sources[i].SourceKeyType), sanitizeLog(sourceID), sanitizeLog(account), sanitizeLog(device))

			return sources[i].SourceKeyType
		}
	}

	return persistedSource
}

// isLeakedSourceValue identifies the known protocol-level leak signature
// (the upstream <source type="Audio"> attribute that the old
// marge.syncPresets persisted into ServicePreset.Source). Empty Source
// is also treated as a leak so the resolve fires for legacy entries
// missing the attribute entirely.
func isLeakedSourceValue(s string) bool {
	return s == "" || s == "Audio"
}

// getConfiguredSourcesLocked is GetConfiguredSources without the
// fileMutex.RLock() — callers must already hold it. Used by
// repairLeakedSource from within GetPresets/GetRecents which already
// hold the lock.
func (ds *DataStore) getConfiguredSourcesLocked(account, device string) ([]models.ConfiguredSource, error) {
	path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)

	data, err := ds.rootReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return ds.getDefaultSources(), nil
		}

		return nil, err
	}

	type persistentSource struct {
		ID            string `xml:"id,attr,omitempty"`
		Type          string `xml:"type,attr,omitempty"`
		SourceKeyType string `xml:"-"`
		SourceKey     struct {
			Type    string `xml:"type,attr"`
			Account string `xml:"account,attr"`
		} `xml:"sourceKey"`
	}

	var sourcesWrap struct {
		Sources []persistentSource `xml:"source"`
	}

	if err := xml.Unmarshal(data, &sourcesWrap); err != nil {
		return nil, err
	}

	out := make([]models.ConfiguredSource, 0, len(sourcesWrap.Sources))
	for i := range sourcesWrap.Sources {
		ps := sourcesWrap.Sources[i]
		out = append(out, models.ConfiguredSource{
			ID:            ps.ID,
			Type:          ps.Type,
			SourceKeyType: ps.SourceKey.Type,
		})
	}

	return out, nil
}

// SavePresets saves the preset list for the specified account and device.
func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error {
	ds.fileMutex.Lock()
	defer ds.fileMutex.Unlock()

	path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
	if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
		return err
	}

	type PresetXML struct {
		ID          string `xml:"id,attr"`
		CreatedOn   string `xml:"createdOn,attr"`
		UpdatedOn   string `xml:"updatedOn,attr"`
		ContentItem struct {
			Source        string `xml:"source,attr,omitempty"`
			Type          string `xml:"type,attr"`
			Location      string `xml:"location,attr"`
			SourceAccount string `xml:"sourceAccount,attr"`
			IsPresetable  string `xml:"isPresetable,attr"`
			ItemName      string `xml:"itemName"`
			ContainerArt  string `xml:"containerArt"`
		} `xml:"contentItem"`
		SourceID string `xml:"sourceid,omitempty"`
	}

	type PresetsXML struct {
		XMLName xml.Name    `xml:"presets"`
		Presets []PresetXML `xml:"preset"`
	}

	var px PresetsXML

	for i := range presets {
		p := &presets[i]

		var pxml PresetXML

		pxml.ID = p.ButtonNumber
		if pxml.ID == "" {
			pxml.ID = p.ID
		}

		pxml.CreatedOn = p.CreatedOn
		pxml.UpdatedOn = p.UpdatedOn
		pxml.ContentItem.Source = p.Source
		pxml.ContentItem.Type = p.Type
		pxml.ContentItem.Location = p.Location
		pxml.ContentItem.SourceAccount = p.SourceAccount

		// Preserve the speaker's IsPresetable verdict instead of forcing
		// "true". The speaker firmware sets isPresetable="false" for
		// content it can't independently recall later (e.g. Spotify Connect
		// pushes from a phone — see GH-235). Hard-coding "true" makes the
		// on-disk XML look valid while the speaker still refuses to play
		// the preset, which leaves users debugging a phantom "stored but
		// won't play" state. Default to "true" only when the caller
		// supplied nothing.
		pxml.ContentItem.IsPresetable = p.IsPresetable
		if pxml.ContentItem.IsPresetable == "" {
			pxml.ContentItem.IsPresetable = "true"
		}

		if pxml.ContentItem.IsPresetable == "false" {
			log.Printf("[Datastore] SavePresets: storing preset %s as isPresetable=false (account=%s device=%s source=%s) — speaker firmware marked this content non-recallable; preset will appear on the speaker but pressing it will not play",
				sanitizeLog(pxml.ID), sanitizeLog(account), sanitizeLog(device), sanitizeLog(p.Source))
		}

		pxml.ContentItem.ItemName = p.Name
		pxml.ContentItem.ContainerArt = p.ContainerArt
		pxml.SourceID = p.SourceID
		px.Presets = append(px.Presets, pxml)
	}

	data, err := xml.MarshalIndent(px, "", "    ")
	if err != nil {
		return err
	}

	header := []byte(xml.Header)

	return ds.atomicWriteFile(path, append(header, data...))
}

// atomicWriteFile writes data to filename atomically AND durably: it writes a
// temp file, fsyncs it, renames it into place, then fsyncs the parent
// directory. Without the fsyncs an unclean power-cut on a journaling NAND
// filesystem (UBIFS, the speaker's /mnt/nv) can leave the renamed file present
// but 0 bytes — the rename was journalled but the data blocks were never
// flushed. See #458.
func (ds *DataStore) atomicWriteFile(filename string, data []byte) error {
	perm := os.FileMode(0644)

	tempFile := filename + ".tmp"
	if err := ds.rootWriteFileSync(tempFile, data, perm); err != nil {
		return err
	}

	if err := ds.rootRename(tempFile, filename); err != nil {
		return err
	}

	// Fsync the parent directory so the rename itself survives a power-cut.
	// Best-effort: not every filesystem permits directory fsync, and the data +
	// rename have already succeeded by this point.
	ds.rootSyncDir(filepath.Dir(filename))

	return nil
}

// rootWriteFileSync writes data to absPath (truncating any existing file) and
// fsyncs the file before returning, so the contents are on stable storage. This
// is the durable equivalent of rootWriteFile.
func (ds *DataStore) rootWriteFileSync(absPath string, data []byte, perm os.FileMode) error {
	r, err := ds.getRoot()
	if err != nil {
		return err
	}

	rel, err := ds.rootRel(absPath)
	if err != nil {
		return err
	}

	f, err := r.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
	if err != nil {
		return err
	}

	if _, werr := f.Write(data); werr != nil {
		_ = f.Close()

		return werr
	}

	if serr := f.Sync(); serr != nil {
		_ = f.Close()

		return serr
	}

	return f.Close()
}

// rootSyncDir fsyncs the directory at absDir so a preceding create/rename is
// durable. Best-effort: directory fsync isn't supported on every filesystem, so
// failures are logged and swallowed rather than failing an already-successful
// write.
func (ds *DataStore) rootSyncDir(absDir string) {
	d, err := ds.rootOpen(absDir)
	if err != nil {
		log.Printf("[Datastore] rootSyncDir: open %s failed (best-effort): %s", sanitizeLog(absDir), sanitizeErr(err))

		return
	}

	if serr := d.Sync(); serr != nil {
		log.Printf("[Datastore] rootSyncDir: fsync %s failed (best-effort): %s", sanitizeLog(absDir), sanitizeErr(serr))
	}

	_ = d.Close()
}

// GetRecents returns the list of recently played items for the specified account and device.
func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, error) {
	ds.fileMutex.RLock()
	defer ds.fileMutex.RUnlock()

	path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)

	data, err := ds.rootReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			log.Printf("[Datastore] GetRecents: no Recents.xml at %s — reporting no recents", sanitizeLog(path))

			return []models.ServiceRecent{}, nil
		}

		return nil, err
	}

	// An empty / 0-byte Recents.xml (e.g. truncated by an unclean power-cut) is
	// treated as "no recents" rather than a hard parse error, so the
	// device-level /recents endpoint returns an empty list instead of HTTP 500.
	// See #458.
	if len(bytes.TrimSpace(data)) == 0 {
		log.Printf("[Datastore] GetRecents: empty/0-byte Recents.xml at %s — treating as no recents (#458)", sanitizeLog(path))

		return []models.ServiceRecent{}, nil
	}

	type RecentXML struct {
		DeviceID    string `xml:"deviceID,attr,omitempty"`
		UtcTime     string `xml:"utcTime,attr,omitempty"`
		ID          string `xml:"id,attr"`
		ContentItem struct {
			Source        string `xml:"source,attr"`
			Type          string `xml:"type,attr"`
			Location      string `xml:"location,attr"`
			SourceAccount string `xml:"sourceAccount,attr"`
			IsPresetable  string `xml:"isPresetable,attr"`
			ItemName      string `xml:"itemName"`
			ContainerArt  string `xml:"containerArt,omitempty"`
		} `xml:"contentItem"`
		CreatedOn    string `xml:"createdOn,omitempty"`
		UpdatedOn    string `xml:"updatedOn,omitempty"`
		LastPlayedAt string `xml:"lastplayedat,omitempty"`
		SourceID     string `xml:"sourceid,omitempty"`
		Username     string `xml:"username,omitempty"`
	}

	type RecentsXML struct {
		XMLName xml.Name    `xml:"recents"`
		Recents []RecentXML `xml:"recent"`
	}

	var wrap RecentsXML
	if err := xml.Unmarshal(data, &wrap); err != nil {
		log.Printf("[Datastore] GetRecents: malformed Recents.xml at %s (%s) — treating as no recents (#458)", sanitizeLog(path), sanitizeErr(err))

		return []models.ServiceRecent{}, nil
	}

	recents := make([]models.ServiceRecent, 0, len(wrap.Recents))
	maxID := 0

	for i := range wrap.Recents {
		rx := &wrap.Recents[i]
		r := models.ServiceRecent{
			DeviceID: rx.DeviceID,
			UtcTime:  rx.UtcTime,
			ServiceContentItem: models.ServiceContentItem{
				ID:            rx.ID,
				Name:          rx.ContentItem.ItemName,
				Source:        rx.ContentItem.Source,
				Type:          rx.ContentItem.Type,
				Location:      rx.ContentItem.Location,
				SourceAccount: rx.ContentItem.SourceAccount,
				IsPresetable:  rx.ContentItem.IsPresetable,
				ContainerArt:  rx.ContentItem.ContainerArt,
			},
			CreatedOn:    rx.CreatedOn,
			UpdatedOn:    rx.UpdatedOn,
			LastPlayedAt: rx.LastPlayedAt,
		}
		r.SourceID = rx.SourceID

		if id, err := strconv.Atoi(r.ID); err == nil {
			if id > maxID {
				maxID = id
			}
		}

		recents = append(recents, r)
	}

	for i := range recents {
		r := &recents[i]
		if r.ContentItemType == "" {
			if r.Type != "" {
				r.ContentItemType = r.Type
			}
		}

		if _, err := strconv.Atoi(recents[i].ID); err != nil || recents[i].ID == "" {
			maxID++
			recents[i].ID = strconv.Itoa(maxID)
		}

		r.Source = repairLeakedSource(account, device, "recent "+r.ID, r.Source, r.SourceID, ds)
	}

	return recents, nil
}

// SaveRecents saves the recent items list for the specified account and device.
func (ds *DataStore) SaveRecents(account, device string, recents []models.ServiceRecent) error {
	ds.fileMutex.Lock()
	defer ds.fileMutex.Unlock()

	dir := ds.AccountDeviceDir(account, device)
	if err := ds.rootMkdirAll(dir, 0755); err != nil {
		return err
	}

	path := filepath.Join(dir, constants.RecentsFile)

	type RecentXML struct {
		DeviceID    string `xml:"deviceID,attr,omitempty"`
		UtcTime     string `xml:"utcTime,attr,omitempty"`
		ID          string `xml:"id,attr"`
		ContentItem struct {
			Source        string `xml:"source,attr"`
			Type          string `xml:"type,attr"`
			Location      string `xml:"location,attr"`
			SourceAccount string `xml:"sourceAccount,attr"`
			IsPresetable  string `xml:"isPresetable,attr"`
			ItemName      string `xml:"itemName"`
			ContainerArt  string `xml:"containerArt,omitempty"`
		} `xml:"contentItem"`
		CreatedOn    string `xml:"createdOn,omitempty"`
		UpdatedOn    string `xml:"updatedOn,omitempty"`
		LastPlayedAt string `xml:"lastplayedat,omitempty"`
		SourceID     string `xml:"sourceid,omitempty"`
		Username     string `xml:"username,omitempty"`
	}

	type RecentsXML struct {
		XMLName xml.Name    `xml:"recents"`
		Recents []RecentXML `xml:"recent"`
	}

	// Deduplicate by ID before saving; first occurrence wins. A speaker<->marge
	// recents sync can otherwise re-store the same recent (same ID) multiple
	// times — it then crowds the capped list and evicts other sources from the
	// speaker's recents. Mirrors SaveConfiguredSources.
	seen := make(map[string]bool)
	deduped := make([]models.ServiceRecent, 0, len(recents))

	for i := range recents {
		if id := recents[i].ID; id != "" {
			if seen[id] {
				continue
			}

			seen[id] = true
		}

		deduped = append(deduped, recents[i])
	}

	recents = deduped

	wrap := RecentsXML{
		Recents: make([]RecentXML, 0, len(recents)),
	}

	for i := range recents {
		r := &recents[i]
		rx := RecentXML{
			DeviceID:     r.DeviceID,
			UtcTime:      r.UtcTime,
			ID:           r.ID,
			CreatedOn:    r.CreatedOn,
			UpdatedOn:    r.UpdatedOn,
			LastPlayedAt: r.LastPlayedAt,
			SourceID:     r.SourceID,
			Username:     r.Username,
		}
		rx.ContentItem.Source = r.Source
		rx.ContentItem.Type = r.Type
		rx.ContentItem.Location = r.Location
		rx.ContentItem.SourceAccount = r.SourceAccount

		rx.ContentItem.IsPresetable = r.IsPresetable
		if rx.ContentItem.IsPresetable == "" {
			rx.ContentItem.IsPresetable = "true"
		}

		rx.ContentItem.ItemName = r.Name
		rx.ContentItem.ContainerArt = r.ContainerArt
		rx.SourceID = r.SourceID

		wrap.Recents = append(wrap.Recents, rx)
	}

	data, err := xml.MarshalIndent(wrap, "", "    ")
	if err != nil {
		return err
	}

	header := []byte(xml.Header)

	return ds.atomicWriteFile(path, append(header, data...))
}

// SaveDeviceInfo saves device information for the specified account and device.
func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.ServiceDeviceInfo) error {
	ds.fileMutex.Lock()
	defer ds.fileMutex.Unlock()

	if device == "" {
		return fmt.Errorf("device ID/name cannot be empty")
	}

	if !IsSafeIdentifier(device) {
		return fmt.Errorf("invalid device ID")
	}

	if account == "" {
		return fmt.Errorf("account ID cannot be empty")
	}

	if !IsSafeIdentifier(account) {
		return fmt.Errorf("invalid account ID")
	}

	// Try to load existing device info to avoid overwriting existing details with empty values.
	ds.mergeWithExistingDeviceInfo(account, device, info)

	dir := ds.AccountDeviceDir(account, device)
	if err := ds.rootMkdirAll(dir, 0755); err != nil {
		return err
	}

	path := filepath.Join(dir, constants.DeviceInfoFile)

	type NetworkInfoXML struct {
		Type       string `xml:"type,attr"`
		IPAddress  string `xml:"ipAddress"`
		MacAddress string `xml:"macAddress"`
	}

	type InfoXML struct {
		XMLName         xml.Name         `xml:"info"`
		DeviceID        string           `xml:"deviceID,attr"`
		Name            string           `xml:"name"`
		Type            string           `xml:"type"`
		ModuleType      string           `xml:"moduleType"`
		Components      []componentXML   `xml:"components>component"`
		NetworkInfo     []NetworkInfoXML `xml:"networkInfo"`
		DiscoveryMethod string           `xml:"discoveryMethod,omitempty"`
		CreatedOn       string           `xml:"createdOn,omitempty"`
		UpdatedOn       string           `xml:"updatedOn,omitempty"`
	}

	// Parsing product code back to type and moduleType (best effort)
	devType, moduleType := ds.parseProductCode(info.ProductCode)

	ix := InfoXML{
		DeviceID:        info.DeviceID,
		Name:            info.Name,
		Type:            devType,
		ModuleType:      moduleType,
		DiscoveryMethod: info.DiscoveryMethod,
		CreatedOn:       info.CreatedOn,
		UpdatedOn:       info.UpdatedOn,
	}

	if ix.DiscoveryMethod == "" {
		ix.DiscoveryMethod = "sync_full"
	}

	ix.Components = ds.buildComponentsXML(info)

	ix.NetworkInfo = []NetworkInfoXML{
		{
			Type:       "SCM",
			IPAddress:  info.IPAddress,
			MacAddress: info.MacAddress,
		},
	}

	data, err := xml.MarshalIndent(ix, "", "    ")
	if err != nil {
		return err
	}

	header := []byte(xml.Header)

	return ds.atomicWriteFile(path, append(header, data...))
}

func (ds *DataStore) mergeWithExistingDeviceInfo(account, device string, info *models.ServiceDeviceInfo) {
	existing, _ := ds.getDeviceInfoNoLock(account, device)
	if existing == nil {
		return
	}

	if info.Name == "" {
		info.Name = existing.Name
	}

	if info.ProductCode == "" {
		info.ProductCode = existing.ProductCode
	}

	if info.DeviceSerialNumber == "" {
		info.DeviceSerialNumber = existing.DeviceSerialNumber
	}

	if info.ProductSerialNumber == "" {
		info.ProductSerialNumber = existing.ProductSerialNumber
	}

	if info.FirmwareVersion == "" {
		info.FirmwareVersion = existing.FirmwareVersion
	}

	if info.IPAddress == "" {
		info.IPAddress = existing.IPAddress
	}

	if info.MacAddress == "" {
		info.MacAddress = existing.MacAddress
	}

	if info.DiscoveryMethod == "" {
		info.DiscoveryMethod = existing.DiscoveryMethod
	}

	// CreatedOn is set once at first persistence and never re-derived
	// from inbound data — preserve unconditionally so the
	// "first-paired" timestamp survives renames, IP refreshes, etc.
	// UpdatedOn is the opposite: every write that reaches here is by
	// definition an update, so callers that want it refreshed must
	// set it explicitly. If they didn't, fall back to the existing
	// value (better than a regression to empty).
	if existing.CreatedOn != "" {
		info.CreatedOn = existing.CreatedOn
	}

	if info.UpdatedOn == "" {
		info.UpdatedOn = existing.UpdatedOn
	}
}

func (ds *DataStore) parseProductCode(productCode string) (string, string) {
	devType := productCode
	moduleType := ""

	for i := 0; i < len(productCode); i++ {
		if productCode[i] == ' ' {
			devType = productCode[:i]
			moduleType = productCode[i+1:]

			break
		}
	}

	return devType, moduleType
}

type componentXML struct {
	ComponentCategory string `xml:"componentCategory"`
	SoftwareVersion   string `xml:"softwareVersion,omitempty"`
	SerialNumber      string `xml:"serialNumber,omitempty"`
}

func (ds *DataStore) buildComponentsXML(info *models.ServiceDeviceInfo) []componentXML {
	var components []componentXML
	for _, comp := range info.Components {
		components = append(components, componentXML{
			ComponentCategory: comp.Category,
			SoftwareVersion:   comp.SoftwareVersion,
			SerialNumber:      comp.SerialNumber,
		})
	}

	if len(components) == 0 && (info.FirmwareVersion != "" || info.DeviceSerialNumber != "" || info.ProductSerialNumber != "") {
		components = []componentXML{
			{
				ComponentCategory: "SCM",
				SoftwareVersion:   info.FirmwareVersion,
				SerialNumber:      info.DeviceSerialNumber,
			},
			{
				ComponentCategory: "PackagedProduct",
				SerialNumber:      info.ProductSerialNumber,
			},
		}
	} else if len(components) > 0 {
		if info.FirmwareVersion != "" {
			for i := range components {
				if components[i].ComponentCategory == "SCM" {
					components[i].SoftwareVersion = info.FirmwareVersion
					break
				}
			}
		}
	}

	return components
}

// SaveAccountInfo stores account-level metadata in the datastore.
func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccountInfo) error {
	if ds == nil || ds.DataDir == "" || accountID == "" {
		return nil
	}

	if !IsSafeIdentifier(accountID) {
		return fmt.Errorf("invalid account ID")
	}

	dir := ds.AccountDir(accountID)
	if err := ds.rootMkdirAll(dir, 0755); err != nil {
		return err
	}

	path := filepath.Join(dir, "account.json")

	data, err := json.MarshalIndent(info, "", "  ")
	if err != nil {
		return err
	}

	return ds.atomicWriteFile(path, data)
}

// GetAccountInfo retrieves account-level metadata from the datastore.
func (ds *DataStore) GetAccountInfo(accountID string) (*models.ServiceAccountInfo, error) {
	if ds == nil || ds.DataDir == "" || accountID == "" {
		return &models.ServiceAccountInfo{AccountID: accountID}, nil
	}

	// Try account root (canonical location)
	path := filepath.Join(ds.AccountDir(accountID), "account.json")
	if !ds.rootExists(path) {
		return &models.ServiceAccountInfo{AccountID: accountID, IsPlaceholder: true}, nil
	}

	data, err := ds.rootReadFile(path)
	if err != nil {
		return nil, err
	}

	var info models.ServiceAccountInfo
	if err := json.Unmarshal(data, &info); err != nil {
		return nil, err
	}

	return &info, nil
}

// RemoveDevice removes a device and all its data from the specified account.
func (ds *DataStore) RemoveDevice(account, device string) error {
	ds.fileMutex.Lock()
	defer ds.fileMutex.Unlock()

	dir := ds.AccountDeviceDir(account, device)

	return ds.rootRemoveAll(dir)
}

// RemoveDeviceDir is an alias for RemoveDevice for backwards compatibility.
func (ds *DataStore) RemoveDeviceDir(account, device string) error {
	return ds.RemoveDevice(account, device)
}

// MoveDevice atomically moves a device directory from one account to another
// on the same filesystem. If the target account directory doesn't exist it is
// created. Returns an error if the rename fails (leaving the source intact).
func (ds *DataStore) MoveDevice(oldAccount, newAccount, deviceID string) error {
	ds.fileMutex.Lock()
	defer ds.fileMutex.Unlock()

	oldDir := ds.AccountDeviceDir(oldAccount, deviceID)
	newDir := ds.AccountDeviceDir(newAccount, deviceID)

	newAccountDir := filepath.Dir(newDir)
	if err := ds.rootMkdirAll(newAccountDir, 0755); err != nil {
		return err
	}

	return ds.rootRename(oldDir, newDir)
}

// DeduceSourceIDs updates the source IDs in the given slice by deducing them from recents and presets.
func (ds *DataStore) DeduceSourceIDs(account, device string, sources []models.ConfiguredSource) {
	// Deduce source IDs from recents and presets
	deducedIDs := ds.collectDeducedIDs(account, device)

	for i := range sources {
		if id, ok := deducedIDs[sources[i].SourceProviderID]; ok {
			sources[i].ID = id
		} else if sources[i].SourceKeyType == "AUX" {
			auxID := strconv.Itoa(constants.AuxProviderID)
			if id, ok := deducedIDs[auxID]; ok {
				sources[i].ID = id
				sources[i].SourceProviderID = auxID
			}
		}
	}
}

func (ds *DataStore) collectDeducedIDs(account, device string) map[string]string {
	deducedIDs := make(map[string]string)

	// Check recents and presets to find source IDs for provider IDs 2, 9, 11, 25
	for _, filename := range []string{constants.RecentsFile, constants.PresetsFile} {
		fileContent, err := ds.rootReadFile(filepath.Join(ds.AccountDeviceDir(account, device), filename))
		if err != nil {
			continue
		}

		ds.parseIDsFromFile(fileContent, deducedIDs)
	}

	return deducedIDs
}

func (ds *DataStore) parseIDsFromFile(fileContent []byte, deducedIDs map[string]string) {
	decoder := xml.NewDecoder(bytes.NewReader(fileContent))
	for {
		token, _ := decoder.Token()
		if token == nil {
			break
		}

		if se, ok := token.(xml.StartElement); ok {
			switch se.Name.Local {
			case "source":
				ds.parseSourceElement(decoder, &se, deducedIDs)
			case "recent", "preset":
				ds.parseRecentPresetElement(decoder, &se, deducedIDs)
			}
		}
	}
}

func (ds *DataStore) parseSourceElement(decoder *xml.Decoder, se *xml.StartElement, deducedIDs map[string]string) {
	var s struct {
		ID               string `xml:"id,attr"`
		SourceProviderID string `xml:"sourceproviderid"`
		// Also check for sourceproviderid as attribute just in case
		SourceProviderIDAttr string `xml:"sourceproviderid,attr"`
	}
	if err := decoder.DecodeElement(&s, se); err == nil {
		pid := s.SourceProviderID
		if pid == "" {
			pid = s.SourceProviderIDAttr
		}

		ds.extractIDs(pid, s.ID, deducedIDs)
	}
}

func (ds *DataStore) parseRecentPresetElement(decoder *xml.Decoder, se *xml.StartElement, deducedIDs map[string]string) {
	var s struct {
		SourceID         string `xml:"sourceid"`
		SourceProviderID string `xml:"sourceproviderid"`
		ContentItem      struct {
			Source string `xml:"source,attr"`
			Type   string `xml:"type,attr"`
		} `xml:"contentItem"`
		Source struct {
			SourceProviderID string `xml:"sourceproviderid"`
		} `xml:"source"`
	}
	if err := decoder.DecodeElement(&s, se); err == nil {
		pid := s.SourceProviderID
		if pid == "" {
			pid = s.Source.SourceProviderID
		}

		if pid == "" {
			// For AUX, we often don't have provider ID 9 but we know its name/source
			switch s.ContentItem.Source {
			case constants.ProviderAux:
				pid = strconv.Itoa(constants.AuxProviderID)
			case constants.ProviderInternetRadio:
				pid = strconv.Itoa(constants.InternetRadioProviderID)
			case constants.ProviderLocalInternetRadio:
				pid = strconv.Itoa(constants.LocalInternetRadioProviderID)
			case constants.ProviderTunein:
				pid = strconv.Itoa(constants.TuneinProviderID)
			}
		}

		ds.extractIDs(pid, s.SourceID, deducedIDs)
	}
}

func (ds *DataStore) extractIDs(providerID, sourceID string, deducedIDs map[string]string) {
	if sourceID == "" || providerID == "" {
		return
	}
	// Stick to the provider ids mentioned: 2, 9, 11, 25
	switch providerID {
	case strconv.Itoa(constants.InternetRadioProviderID),
		strconv.Itoa(constants.AuxProviderID),
		strconv.Itoa(constants.LocalInternetRadioProviderID),
		strconv.Itoa(constants.TuneinProviderID):
		if _, exists := deducedIDs[providerID]; !exists {
			deducedIDs[providerID] = sourceID
		}
	}
}

// GetConfiguredSources retrieves all configured sources for the specified account and device.
func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) {
	ds.fileMutex.RLock()
	defer ds.fileMutex.RUnlock()

	path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)

	// defaultSources is the fallback used whenever there is no usable
	// Sources.xml: file missing (normal for a fresh device) or present but
	// empty / 0-byte / unparseable. The latter happens when an unclean
	// power-cut truncates a not-yet-flushed datastore write on the speaker's
	// NAND; treating it like "missing" lets /full re-serve the managed defaults
	// so the speaker self-heals instead of dropping all its sources. See #458.
	defaultSources := func() []models.ConfiguredSource {
		sources := ds.getInitialSources()
		ds.DeduceSourceIDs(account, device, sources)

		return sources
	}

	data, err := ds.rootReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return defaultSources(), nil
		}

		return nil, err
	}

	if len(bytes.TrimSpace(data)) == 0 {
		log.Printf("[Datastore] GetConfiguredSources: empty/0-byte Sources.xml at %s — treating as missing, serving defaults (#458)", sanitizeLog(path))

		return defaultSources(), nil
	}

	type persistentSource struct {
		DisplayName      string `xml:"displayName,attr,omitempty"`
		ID               string `xml:"id,attr,omitempty"`
		Secret           string `xml:"secret,attr"`
		SecretType       string `xml:"secretType,attr"`
		Type             string `xml:"type,attr,omitempty"`
		CreatedOn        string `xml:"createdOn,attr,omitempty"`
		UpdatedOn        string `xml:"updatedOn,attr,omitempty"`
		SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
		Credential       struct {
			Type  string `xml:"type,attr"`
			Value string `xml:",chardata"`
		} `xml:"credential,omitempty"`
		SourceKey struct {
			Type    string `xml:"type,attr"`
			Account string `xml:"account,attr"`
		} `xml:"sourceKey"`
	}

	var sourcesWrap struct {
		Sources []persistentSource `xml:"source"`
	}

	if err := xml.Unmarshal(data, &sourcesWrap); err != nil {
		log.Printf("[Datastore] GetConfiguredSources: malformed Sources.xml at %s (%s) — treating as missing, serving defaults (#458)", sanitizeLog(path), sanitizeErr(err))

		return defaultSources(), nil
	}

	sources := make([]models.ConfiguredSource, len(sourcesWrap.Sources))
	defaults := ds.getDefaultSources()

	// Pre-claim IDs already explicitly set in the file so the canonical fill
	// below doesn't reuse them when multiple entries share a SourceKey.Type.
	claimedIDs := make(map[string]bool, len(sourcesWrap.Sources))
	for i := range sourcesWrap.Sources {
		if id := sourcesWrap.Sources[i].ID; id != "" {
			claimedIDs[id] = true
		}
	}

	for i := range sourcesWrap.Sources {
		ps := &sourcesWrap.Sources[i]
		s := &sources[i]

		s.DisplayName = ps.DisplayName
		s.ID = ps.ID
		s.Secret = ps.Secret
		s.SecretType = ps.SecretType
		s.Type = ps.Type
		s.CreatedOn = ps.CreatedOn
		s.UpdatedOn = ps.UpdatedOn
		s.SourceProviderID = ps.SourceProviderID
		s.SourceKey.Type = ps.SourceKey.Type
		s.SourceKey.Account = ps.SourceKey.Account

		// Prioritize Credential element if present, otherwise use secret/secretType attributes
		if ps.Credential.Value != "" {
			s.Secret = ps.Credential.Value
			s.SecretType = ps.Credential.Type
		}

		// Ensure Secret/SecretType values are prioritized from legacy fields if still missing
		if s.Secret == "" && s.Credential.Value != "" {
			s.Secret = s.Credential.Value
		}

		if s.SecretType == "" && s.Credential.Type != "" {
			s.SecretType = s.Credential.Type
		}

		// Ensure SourceKey values are prioritized for legacy fields
		if s.SourceKey.Type != "" {
			s.SourceKeyType = s.SourceKey.Type
		}

		if s.SourceKey.Account != "" {
			s.SourceKeyAccount = s.SourceKey.Account
		}

		applyCanonicalDefaults(s, defaults, claimedIDs)

		// Last-resort ID for unknown providers.
		if s.ID == "" {
			s.ID = strconv.Itoa(2000001 + i)
		}
	}

	return sources, nil
}

// applyCanonicalDefaults fills missing canonical ID/Type/SourceProviderID for
// known providers and repairs Type that was previously synthesized from
// SourceKey.Type (e.g. "AUX") rather than the canonical value (e.g. "Audio").
// Without this, the on-device Sources.xml — which carries only displayName +
// sourceKey — would round-trip as id="2000001+i" type="<sourceKey.Type>" and
// be rejected by the speaker as INVALID_SOURCE after migration.
//
// claimedIDs tracks which canonical IDs are already in use so that multiple
// entries with the same SourceKey.Type don't collide on the same ID.
func applyCanonicalDefaults(s *models.ConfiguredSource, defaults []models.ConfiguredSource, claimedIDs map[string]bool) {
	def := findCanonicalSource(defaults, s.SourceKey.Type)
	if def == nil {
		return
	}

	if s.ID == "" && !claimedIDs[def.ID] {
		s.ID = def.ID
		claimedIDs[def.ID] = true
	}

	if s.Type == "" || s.Type == s.SourceKey.Type {
		s.Type = def.Type
	}

	if s.SourceProviderID == "" {
		s.SourceProviderID = def.SourceProviderID
	}
}

// findCanonicalSource returns the default source matching the given
// SourceKey.Type, or nil if it's not one of our known providers.
func findCanonicalSource(defaults []models.ConfiguredSource, sourceKeyType string) *models.ConfiguredSource {
	if sourceKeyType == "" {
		return nil
	}

	for i := range defaults {
		if defaults[i].SourceKey.Type == sourceKeyType {
			return &defaults[i]
		}
	}

	return nil
}

// SaveConfiguredSources saves the configured sources list for the specified account and device.
func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error {
	ds.fileMutex.Lock()
	defer ds.fileMutex.Unlock()

	path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
	if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
		return err
	}

	type persistentSource struct {
		DisplayName      string `xml:"displayName,attr,omitempty"`
		ID               string `xml:"id,attr,omitempty"`
		Secret           string `xml:"secret,attr"`
		SecretType       string `xml:"secretType,attr"`
		Type             string `xml:"type,attr,omitempty"`
		CreatedOn        string `xml:"createdOn,attr,omitempty"`
		UpdatedOn        string `xml:"updatedOn,attr,omitempty"`
		SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
		Credential       struct {
			Type  string `xml:"type,attr"`
			Value string `xml:",chardata"`
		} `xml:"credential,omitempty"`
		SourceKey struct {
			Type    string `xml:"type,attr"`
			Account string `xml:"account,attr"`
		} `xml:"sourceKey"`
	}

	// Deduplicate by ID before saving; first occurrence wins to preserve established data
	seen := make(map[string]bool)

	deduped := make([]models.ConfiguredSource, 0, len(sources))
	for i := range sources {
		s := &sources[i]
		if s.ID != "" {
			if seen[s.ID] {
				continue
			}

			seen[s.ID] = true
		}

		deduped = append(deduped, *s)
	}

	sources = deduped

	// Ensure SourceKey is populated from legacy fields if necessary before saving
	// and map to persistentSource to avoid custom MarshalXML for disk storage
	persistSources := make([]persistentSource, len(sources))
	for i := range sources {
		s := sources[i]
		if s.SourceKey.Type == "" && s.SourceKeyType != "" {
			s.SourceKey.Type = s.SourceKeyType
		}

		if s.SourceKey.Account == "" && s.SourceKeyAccount != "" {
			s.SourceKey.Account = s.SourceKeyAccount
		}

		persistSources[i] = persistentSource{
			DisplayName:      s.DisplayName,
			ID:               s.ID,
			Secret:           s.Secret,
			SecretType:       s.SecretType,
			Type:             s.Type,
			CreatedOn:        s.CreatedOn,
			UpdatedOn:        s.UpdatedOn,
			SourceProviderID: s.SourceProviderID,
		}

		// Save to Credential element as well for parity with official Bose format
		if s.Secret != "" {
			persistSources[i].Credential.Value = s.Secret
			persistSources[i].Credential.Type = s.SecretType
		} else if s.Credential.Value != "" {
			persistSources[i].Credential.Value = s.Credential.Value
			persistSources[i].Credential.Type = s.Credential.Type
		}

		if persistSources[i].Secret == "" && s.Credential.Value != "" {
			persistSources[i].Secret = s.Credential.Value
		}

		if persistSources[i].SecretType == "" && s.Credential.Type != "" {
			persistSources[i].SecretType = s.Credential.Type
		}

		persistSources[i].SourceKey.Type = s.SourceKey.Type
		persistSources[i].SourceKey.Account = s.SourceKey.Account
	}

	wrap := struct {
		XMLName xml.Name           `xml:"sources"`
		Sources []persistentSource `xml:"source"`
	}{
		Sources: persistSources,
	}

	data, err := xml.MarshalIndent(wrap, "", "    ")
	if err != nil {
		return err
	}

	header := []byte(xml.Header)

	return ds.atomicWriteFile(path, append(header, data...))
}

// DeleteSourceByID removes the source with the given ID from the device's
// Sources.xml. It is a no-op if the source is not present.
func (ds *DataStore) DeleteSourceByID(account, device, sourceID string) error {
	sources, err := ds.GetConfiguredSources(account, device)
	if err != nil {
		return err
	}

	filtered := make([]models.ConfiguredSource, 0, len(sources))

	for i := range sources {
		if sources[i].ID != sourceID {
			filtered = append(filtered, sources[i])
		}
	}

	if len(filtered) == len(sources) {
		return nil
	}

	return ds.SaveConfiguredSources(account, device, filtered)
}

// DeleteSourceByType removes the source with the given SourceKeyType from
// the device's Sources.xml. Returns an error if more than one source matches
// (to prevent accidental bulk deletion). No-op if no match is found.
func (ds *DataStore) DeleteSourceByType(account, device, sourceKeyType string) error {
	sources, err := ds.GetConfiguredSources(account, device)
	if err != nil {
		return err
	}

	var matches int

	for i := range sources {
		if sources[i].SourceKeyType == sourceKeyType {
			matches++
		}
	}

	if matches > 1 {
		return fmt.Errorf("found %d sources with type %q; use an ID-based deletion for precision", matches, sourceKeyType)
	}

	if matches == 0 {
		return nil
	}

	filtered := make([]models.ConfiguredSource, 0, len(sources))

	for i := range sources {
		if sources[i].SourceKeyType != sourceKeyType {
			filtered = append(filtered, sources[i])
		}
	}

	return ds.SaveConfiguredSources(account, device, filtered)
}

// updateDeviceMappings creates bidirectional mappings for device resolution
func (ds *DataStore) updateDeviceMappings(info models.ServiceDeviceInfo) {
	ds.idMutex.Lock()
	defer ds.idMutex.Unlock()

	deviceID := info.DeviceID
	macAddress := info.MacAddress
	deviceSerial := info.DeviceSerialNumber

	// If device is stored with MAC as deviceID and has a serial, create backward mapping
	if isMACAddressFormat(deviceID) && deviceSerial != "" && deviceSerial != deviceID {
		ds.deviceMappings[deviceSerial] = deviceID
	}

	// If device is stored with serial as deviceID and has a MAC, create forward mapping
	if !isMACAddressFormat(deviceID) && macAddress != "" {
		ds.deviceMappings[macAddress] = deviceID
		// Also store normalized MAC version
		normalizedMAC := normalizeMAC(macAddress)
		if normalizedMAC != macAddress {
			ds.deviceMappings[normalizedMAC] = deviceID
		}
	}
}

// UpdateMapping maintains backward compatibility for external callers
func (ds *DataStore) UpdateMapping(mac, serial string) {
	if mac == "" || serial == "" {
		return
	}

	ds.idMutex.Lock()
	defer ds.idMutex.Unlock()

	// In the new system, MAC addresses are preferred as deviceIDs
	// So map the serial TO the MAC (reverse of old system)
	ds.deviceMappings[serial] = mac

	// Also map MAC to serial for any remaining legacy code
	ds.deviceMappings[mac] = serial

	normalizedMAC := normalizeMAC(mac)
	if normalizedMAC != mac {
		ds.deviceMappings[normalizedMAC] = serial
	}
}

// GenerateSerialSecret generates a base64 encoded JSON object with the specified serial.
func GenerateSerialSecret(serial string) string {
	m := map[string]string{"serial": serial}

	b, err := json.Marshal(m)
	if err != nil {
		return ""
	}

	return base64.StdEncoding.EncodeToString(b)
}

// GetDefaultSources returns the list of default sources.
func (ds *DataStore) GetDefaultSources() []models.ConfiguredSource {
	return ds.getDefaultSources()
}

// GetInitialSources returns the default sources for a brand-new device:
// the full default list minus the legacy INTERNET_RADIO stub.
// Use this (not GetDefaultSources) whenever deciding which sources to add
// to a device — re-adding INTERNET_RADIO to existing devices would silently
// undo the stale_internet_radio health-check quick-fix.
func (ds *DataStore) GetInitialSources() []models.ConfiguredSource {
	return ds.getInitialSources()
}

// CanonicalSourceByID returns the canonical default ConfiguredSource for one
// of the well-known built-in source IDs (10001..10005). The returned source
// has its SourceKey.Type / SourceKey.Account mirrored from
// SourceKeyType / SourceKeyAccount so it round-trips through SaveConfiguredSources
// without losing the embedded struct fields. Returns (zero, false) when the
// ID isn't one we can synthesise.
//
// Callers that auto-add missing sources (e.g. UpdatePreset when a speaker
// PUTs a preset referencing a canonical source AfterTouch hasn't been told
// about yet) should use this to keep parity with the post-pair defaults.
func (ds *DataStore) CanonicalSourceByID(id string) (models.ConfiguredSource, bool) {
	defaults := ds.getDefaultSources()
	for i := range defaults {
		if defaults[i].ID == id {
			return defaults[i], true
		}
	}

	return models.ConfiguredSource{}, false
}

func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
	sources := []models.ConfiguredSource{
		{
			ID:               "10001",
			DisplayName:      "AUX IN",
			SourceKeyType:    constants.ProviderAux,
			SourceKeyAccount: constants.ProviderAux,
			Type:             "Audio",
			Status:           "READY",
			CreatedOn:        "2015-03-11T19:12:38.000+00:00",
			UpdatedOn:        "2015-03-11T19:12:38.000+00:00",
		},
		{
			ID:               "10002",
			DisplayName:      "",
			SourceKeyType:    constants.ProviderInternetRadio,
			SourceKeyAccount: "",
			SourceProviderID: strconv.Itoa(constants.InternetRadioProviderID),
			Type:             "Audio",
			SecretType:       "token",
			Status:           "READY",
			CreatedOn:        "2015-03-11T19:12:38.000+00:00",
			UpdatedOn:        "2015-03-11T19:12:38.000+00:00",
		},
		{
			ID:               "10003",
			DisplayName:      "",
			SourceKeyType:    constants.ProviderLocalInternetRadio,
			SourceKeyAccount: "",
			SourceProviderID: strconv.Itoa(constants.LocalInternetRadioProviderID),
			Type:             "Audio",
			Secret:           GenerateSerialSecret("local-internet-radio"),
			SecretType:       "token",
			Status:           "READY",
			CreatedOn:        "2019-01-24T08:18:37.000+00:00",
			UpdatedOn:        "2019-02-03T18:35:45.000+00:00",
		},
		{
			ID:               "10004",
			DisplayName:      "",
			SourceKeyType:    constants.ProviderTunein,
			SourceKeyAccount: "",
			SourceProviderID: strconv.Itoa(constants.TuneinProviderID),
			Type:             "Audio",
			Secret:           GenerateSerialSecret("tunein"),
			SecretType:       "token",
			Status:           "READY",
			CreatedOn:        "2017-07-20T16:43:48.000+00:00",
			UpdatedOn:        "2017-07-20T16:43:48.000+00:00",
		},
		{
			ID:               "10005",
			DisplayName:      "",
			SourceKeyType:    constants.ProviderRadioBrowser,
			SourceKeyAccount: "",
			SourceProviderID: strconv.Itoa(constants.RadioBrowserProviderID),
			Type:             "Audio",
			SecretType:       "token",
			Status:           "READY",
			CreatedOn:        "2026-02-16T01:01:01.000+00:00",
			UpdatedOn:        "2026-02-16T01:01:01.000+00:00",
		},
	}

	for i := range sources {
		sources[i].SourceKey.Type = sources[i].SourceKeyType
		sources[i].SourceKey.Account = sources[i].SourceKeyAccount
	}

	return sources
}

// getInitialSources returns the default sources for a brand-new device.
// INTERNET_RADIO (ID 10002) is excluded — it is a legacy provider no longer
// actively served by AfterTouch; omitting it prevents speakers from
// receiving a stale entry in their initial Sources.xml. The full list
// (including 10002) is kept in getDefaultSources() for canonicalisation
// of existing devices that already reference INTERNET_RADIO.
func (ds *DataStore) getInitialSources() []models.ConfiguredSource {
	all := ds.getDefaultSources()

	out := make([]models.ConfiguredSource, 0, len(all))
	for i := range all {
		if all[i].SourceKeyType == constants.ProviderInternetRadio {
			continue
		}

		out = append(out, all[i])
	}

	return out
}

// isMACAddressFormat checks if a string looks like a MAC address
func isMACAddressFormat(s string) bool {
	// AABBCCDDEEFF format
	if len(s) == 12 {
		return isHexOnly(s)
	}

	// AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF format
	if len(s) == 17 && (strings.Contains(s, ":") || strings.Contains(s, "-")) {
		s = strings.ReplaceAll(s, "-", ":")

		parts := strings.Split(s, ":")
		if len(parts) != 6 {
			return false
		}

		for _, part := range parts {
			if len(part) != 2 || !isHexOnly(part) {
				return false
			}
		}

		return true
	}

	return false
}

func isHexOnly(s string) bool {
	for _, r := range s {
		if (r < '0' || r > '9') && (r < 'A' || r > 'F') && (r < 'a' || r > 'f') {
			return false
		}
	}

	return true
}

// Initialize creates the necessary directory structure for the datastore and populates ID mappings.
func (ds *DataStore) Initialize() error {
	// Ensure base data directory exists
	if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
		return fmt.Errorf("failed to create data directory: %w", err)
	}

	// Scan for devices to populate MAC to Serial mapping
	_, err := ds.ListAllDevices()

	return err
}

// GetETagForPresets returns the ETag (modification time) for the presets file for a specific device.
func (ds *DataStore) GetETagForPresets(account, device string) int64 {
	path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)

	info, err := ds.rootStat(path)
	if err != nil {
		return 0
	}

	return info.ModTime().UnixNano() / int64(time.Millisecond)
}

// HasConfiguredSources reports whether a non-empty Sources.xml file exists for
// the given account and device. A present-but-0-byte file (truncated by an
// unclean power-cut) counts as absent. See #458.
func (ds *DataStore) HasConfiguredSources(account, device string) bool {
	path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)

	data, err := ds.rootReadFile(path)
	if err != nil {
		return false
	}

	// An existing but empty / 0-byte Sources.xml (e.g. truncated by an unclean
	// power-cut on the speaker's NAND) must not count as "present": otherwise it
	// hides the sources_xml_present health check and its create_default_sources
	// quick fix, leaving the device with no managed sources. See #458.
	return len(bytes.TrimSpace(data)) > 0
}

// GetETagForSources returns the ETag (modification time) for the sources file for a specific device.
func (ds *DataStore) GetETagForSources(account, device string) int64 {
	path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)

	info, err := ds.rootStat(path)
	if err != nil {
		return 0
	}

	return info.ModTime().UnixNano() / int64(time.Millisecond)
}

// GetETagForRecents returns the ETag (modification time) for the recents file for a specific device.
func (ds *DataStore) GetETagForRecents(account, device string) int64 {
	path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)

	info, err := ds.rootStat(path)
	if err != nil {
		return 0
	}

	return info.ModTime().UnixNano() / int64(time.Millisecond)
}

// GetETagForAccount returns a content hash (SHA-256) over presets, sources, and recents for the account and device.
// If device is empty, it hashes across all devices in the account.
// The default sources fingerprint is always included so that newly added defaults (e.g. Amazon)
// invalidate cached responses even when the stored Sources.xml has not changed.
func (ds *DataStore) GetETagForAccount(account, device string) string {
	h := sha256.New()

	// Include the default sources fingerprint so mergeDefaultSources changes are visible.
	defaults := ds.GetDefaultSources()
	for i := range defaults {
		_, _ = io.WriteString(h, defaults[i].ID+defaults[i].SourceKeyType+defaults[i].DisplayName)
	}

	if device != "" {
		deviceDir := ds.AccountDeviceDir(account, device)
		for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} {
			f, err := ds.rootOpen(filepath.Join(deviceDir, name))
			if err != nil {
				continue
			}

			_, _ = io.Copy(h, f)
			_ = f.Close()
		}

		return hex.EncodeToString(h.Sum(nil))
	}

	devicesDir := ds.AccountDevicesDir(account)

	// Ignore error: missing directory is treated as no devices, producing a
	// stable non-empty hash rather than "" which would false-match an absent
	// If-None-Match header and return 304 on the first request.
	entries, _ := ds.rootReadDir(devicesDir)

	for _, entry := range entries {
		if entry.IsDir() {
			deviceDir := ds.AccountDeviceDir(account, entry.Name())
			for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} {
				f, err := ds.rootOpen(filepath.Join(deviceDir, name))
				if err != nil {
					continue
				}

				_, _ = io.Copy(h, f)
				_ = f.Close()
			}
		}
	}

	return hex.EncodeToString(h.Sum(nil))
}

// Settings represents the global service settings.
type Settings struct {
	ServerURL           string         `json:"server_url"`
	HTTPServerURL       string         `json:"https_server_url,omitempty"`
	RedactLogs          bool           `json:"redact_logs"`
	LogBodies           bool           `json:"log_bodies"`
	RecordInteractions  bool           `json:"record_interactions"`
	DiscoveryInterval   string         `json:"discovery_interval,omitempty"`
	DiscoveryEnabled    bool           `json:"discovery_enabled"`
	UpdateCheckInterval string         `json:"update_check_interval,omitempty"`
	UpdateCheckEnabled  bool           `json:"update_check_enabled"`
	DNSEnabled          bool           `json:"dns_enabled"`
	DNSUpstream         []string       `json:"dns_upstream,omitempty"`
	DNSBindAddr         string         `json:"dns_bind_addr,omitempty"`
	InternalPaths       []string       `json:"internal_paths,omitempty"`
	Shortcuts           map[string]int `json:"shortcuts,omitempty"`
	SpotifyClientID     string         `json:"spotify_client_id,omitempty"`
	SpotifyClientSecret string         `json:"spotify_client_secret,omitempty"`
	SpotifyRedirectURI  string         `json:"spotify_redirect_uri,omitempty"`
	AmazonClientID      string         `json:"amazon_client_id,omitempty"`
	AmazonClientSecret  string         `json:"amazon_client_secret,omitempty"`
	AmazonRedirectURI   string         `json:"amazon_redirect_uri,omitempty"`
	TTSProvider         string         `json:"tts_provider,omitempty"`
	TTSGoogleAPIKey     string         `json:"tts_google_api_key,omitempty"`
	TTSAppKey           string         `json:"tts_app_key,omitempty"`
	TTSLanguage         string         `json:"tts_language,omitempty"`
	TTSVoice            string         `json:"tts_voice,omitempty"`
	TTSVolume           int            `json:"tts_volume,omitempty"`

	// TrustForwardedHeaders enables proxy-aware client IP resolution: when the
	// immediate TCP peer is one of the TrustedProxyCIDRs, the client IP is
	// resolved from the X-Forwarded-For header (read via the request context;
	// it does not rewrite r.RemoteAddr). Required when the service is fronted
	// by nginx, Caddy, or any other reverse proxy. Default false - direct LAN
	// deployments must not enable this, otherwise a malicious LAN-resident
	// client could spoof its source IP via the X-Forwarded-For header.
	TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`

	// TrustedProxyCIDRs is the list of CIDR blocks whose immediate TCP peers
	// are allowed to set X-Forwarded-For headers when TrustForwardedHeaders is
	// true. Defaults to loopback (127.0.0.0/8 and ::1/128) - i.e. only a
	// reverse proxy on the same host. Override only if the proxy lives on a
	// different host within a known-good private subnet.
	TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`

	// TLSExtraHosts is the persisted list of additional DNS names or IPs
	// to include in the TLS certificate SAN list. Merged with the
	// CLI/env --tls-extra-host values at startup (CLI/env wins; persisted
	// values are additive and deduplicated). Applying a change requires a
	// service restart so the TLS cert can be regenerated. Used by the
	// `speaker_marge_url` health check's QuickFix and the Settings tab UI.
	TLSExtraHosts []string `json:"tls_extra_hosts,omitempty"`

	// TuneInStreamFormats overrides the comma-separated format list
	// AfterTouch sends to TuneIn's Tune.ashx (formats=…). Empty value
	// uses bmx.DefaultTuneInStreamFormats ("mp3,aac,ogg"), which
	// matches AfterTouch's pre-2026-05-10 behaviour and plays on
	// every SoundTouch model verified so far. PR #249 had added
	// "hls" unconditionally; that regressed playback on the
	// SoundTouch line (#292 — speaker can't parse the .m3u8 playlist
	// and blinks amber). Operators with HLS-compatible speakers can
	// set this to e.g. "mp3,aac,ogg,hls" via settings.json. The value
	// is passed through verbatim; AfterTouch does not validate the
	// individual format tokens.
	TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`

	// DefaultLanding selects what the root path "/" serves to a browser:
	//   "chooser" (or empty) — the neutral landing page that links to the
	//                          player and the admin/setup console;
	//   "app"               — redirect straight to the player UI (/app);
	//   "admin"             — redirect straight to the admin console (/admin).
	// API/speaker clients (non-HTML Accept) always get the version JSON
	// regardless of this setting.
	DefaultLanding string `json:"default_landing,omitempty"`

	// AdminAreaAuth is a tri-state toggle for gating the entire admin area
	// (/admin, /setup, /api/setup — minus a small set of routes shared with
	// soundtouch-cli/soundtouch-player) behind the same Basic Auth used for
	// /api/mgmt/*, rather than just the Local Account / Spotify / Amazon
	// linking endpoints as today. Values:
	//   ""         — unset (default). Today this means "not enforced"; a
	//                later release is expected to flip the *meaning* of ""
	//                to "enforced" as the project moves the entire admin
	//                area to require login by default. See #419.
	//   "enabled"  — the whole admin area requires Basic Auth now.
	//   "disabled" — explicit opt-out. Kept open even after the default
	//                flips, so an operator's deliberate choice survives
	//                the upgrade.
	// The tri-state (rather than a plain bool) is what lets "never decided"
	// be told apart from "explicitly chose off" once that default flips.
	AdminAreaAuth string `json:"admin_area_auth,omitempty"`
}

// GetSettings retrieves the global service settings.
func (ds *DataStore) GetSettings() (Settings, error) {
	if ds == nil || ds.DataDir == "" {
		return Settings{}, nil
	}

	path := filepath.Join(ds.DataDir, "settings.json")
	if !ds.rootExists(path) {
		return Settings{}, nil
	}

	data, err := ds.rootReadFile(path)
	if err != nil {
		return Settings{}, err
	}

	var settings Settings
	if err := json.Unmarshal(data, &settings); err != nil {
		return Settings{}, err
	}

	return settings, nil
}

// SaveSettings saves the global service settings.
func (ds *DataStore) SaveSettings(settings Settings) error {
	if ds == nil || ds.DataDir == "" {
		return nil
	}

	if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
		return fmt.Errorf("failed to create data directory: %w", err)
	}

	path := filepath.Join(ds.DataDir, "settings.json")

	data, err := json.MarshalIndent(settings, "", "  ")
	if err != nil {
		return err
	}

	return ds.atomicWriteFile(path, data)
}

// UpdateCheckState is the small persisted state for the opt-in periodic
// update check (#591, _/i591/design-update-check.md): when it last ran and
// what it last saw, so a restart doesn't lose the "already logged this
// version" and "don't hammer GitHub on every startup" context. Separate
// from Settings, which is operator-editable config, not runtime state.
type UpdateCheckState struct {
	LastCheckedAt   string `json:"last_checked_at,omitempty"`
	LastSeenVersion string `json:"last_seen_version,omitempty"`
	LastReleaseURL  string `json:"last_release_url,omitempty"`
}

// GetUpdateCheckState retrieves the persisted update-check state. Same
// missing-file-is-not-an-error shape as GetSettings — a fresh install (or
// one that has never had the check enabled) has no file yet.
func (ds *DataStore) GetUpdateCheckState() (UpdateCheckState, error) {
	if ds == nil || ds.DataDir == "" {
		return UpdateCheckState{}, nil
	}

	path := filepath.Join(ds.DataDir, "update-check.json")
	if !ds.rootExists(path) {
		return UpdateCheckState{}, nil
	}

	data, err := ds.rootReadFile(path)
	if err != nil {
		return UpdateCheckState{}, err
	}

	var state UpdateCheckState
	if err := json.Unmarshal(data, &state); err != nil {
		return UpdateCheckState{}, err
	}

	return state, nil
}

// SaveUpdateCheckState persists the update-check state.
func (ds *DataStore) SaveUpdateCheckState(state UpdateCheckState) error {
	if ds == nil || ds.DataDir == "" {
		return nil
	}

	if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
		return fmt.Errorf("failed to create data directory: %w", err)
	}

	path := filepath.Join(ds.DataDir, "update-check.json")

	data, err := json.MarshalIndent(state, "", "  ")
	if err != nil {
		return err
	}

	return ds.atomicWriteFile(path, data)
}

// SaveUsageStats saves usage statistics to the datastore.
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
	dir := filepath.Join(ds.DataDir, "stats", "usage")
	if err := ds.rootMkdirAll(dir, 0755); err != nil {
		return err
	}

	filename := fmt.Sprintf("%d_%s.json", time.Now().UnixNano(), stats.DeviceID)
	path := filepath.Join(dir, filename)

	data, err := json.MarshalIndent(stats, "", "  ")
	if err != nil {
		return err
	}

	return ds.atomicWriteFile(path, data)
}

// RecordActivity appends one entry to the local admin-UI activity log, under
// DataDir/stats/activity/<kind>/, one file per event (same shape as
// SaveUsageStats/SaveErrorStats above). kind is meant to be a small,
// developer-defined constant (e.g. "notification_dismissed") used directly
// as a directory name — callers must not pass untrusted/user-supplied
// values. id may recur across calls with a new timestamp each time; this is
// an append-only log, not a keyed store. See models.ActivityRecord for the
// local-only/never-transmitted-automatically guarantee this backs.
func (ds *DataStore) RecordActivity(kind, id string, detail map[string]interface{}) error {
	dir := filepath.Join(ds.DataDir, "stats", "activity", kind)
	if err := ds.rootMkdirAll(dir, 0755); err != nil {
		return err
	}

	now := time.Now()
	record := models.ActivityRecord{
		Kind:      kind,
		ID:        id,
		Timestamp: now.UTC().Format(time.RFC3339Nano),
		Detail:    detail,
	}

	// The random suffix guards against two events for the same id landing in
	// the same nanosecond (observed as flaky on coarser-resolution clocks)
	// silently overwriting one another instead of both being recorded. Not
	// a security-sensitive use of randomness — only affects filename
	// uniqueness, not any value that's compared or kept secret.
	// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
	filename := fmt.Sprintf("%d_%d_%s.json", now.UnixNano(), rand.Int63n(1_000_000), id) //nolint:gosec
	path := filepath.Join(dir, filename)

	data, err := json.MarshalIndent(record, "", "  ")
	if err != nil {
		return err
	}

	return ds.atomicWriteFile(path, data)
}

// GetActivityRecords reads back every entry recorded via RecordActivity for
// the given kind. Unreadable or malformed files are skipped rather than
// failing the whole read — a single corrupt event shouldn't make the rest of
// the log unreadable. Returns an empty slice (not an error) when the
// directory doesn't exist yet, matching the "nothing recorded yet" case.
func (ds *DataStore) GetActivityRecords(kind string) ([]models.ActivityRecord, error) {
	dir := filepath.Join(ds.DataDir, "stats", "activity", kind)

	entries, err := ds.rootReadDir(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}

		return nil, err
	}

	records := make([]models.ActivityRecord, 0, len(entries))

	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}

		data, readErr := ds.rootReadFile(filepath.Join(dir, entry.Name()))
		if readErr != nil {
			continue
		}

		var record models.ActivityRecord
		if unmarshalErr := json.Unmarshal(data, &record); unmarshalErr != nil {
			continue
		}

		records = append(records, record)
	}

	return records, nil
}

// SaveErrorStats saves error statistics to the datastore.
func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error {
	dir := filepath.Join(ds.DataDir, "stats", "error")
	if err := ds.rootMkdirAll(dir, 0755); err != nil {
		return err
	}

	filename := fmt.Sprintf("%d_%s.json", time.Now().UnixNano(), stats.DeviceID)
	path := filepath.Join(dir, filename)

	data, err := json.MarshalIndent(stats, "", "  ")
	if err != nil {
		return err
	}

	return ds.atomicWriteFile(path, data)
}

// AddDeviceEvent adds a device event to the in-memory event store.
func (ds *DataStore) AddDeviceEvent(deviceID string, event models.DeviceEvent) {
	ds.eventMutex.Lock()
	defer ds.eventMutex.Unlock()

	events := ds.deviceEvents[deviceID]
	events = append(events, event)

	// Keep only last 100 events
	if len(events) > 100 {
		events = events[len(events)-100:]
	}

	ds.deviceEvents[deviceID] = events
}

// GetDeviceEvents retrieves all events for the specified device.
func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent {
	ds.eventMutex.RLock()
	defer ds.eventMutex.RUnlock()

	events, ok := ds.deviceEvents[deviceID]
	if !ok {
		return []models.DeviceEvent{}
	}

	// Return a copy to avoid race conditions if the caller modifies it
	copiedEvents := make([]models.DeviceEvent, len(events))
	copy(copiedEvents, events)

	return copiedEvents
}

// DNSDiscoveryEntry represents a persisted DNS discovery.
type DNSDiscoveryEntry struct {
	Hostname      string    `json:"hostname"`
	FirstSeen     time.Time `json:"first_seen"`
	LastSeen      time.Time `json:"last_seen"`
	QueryCount    int       `json:"query_count"`
	IsBoseService bool      `json:"is_bose_service"`
	IsIntercepted bool      `json:"is_intercepted"`
	RemoteAddr    string    `json:"remote_addr,omitempty"`
}

// SaveDNSDiscoveries saves DNS discoveries to the datastore.
func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
	if ds == nil || ds.DataDir == "" {
		return nil
	}

	dir := filepath.Join(ds.DataDir, "dns")
	if err := ds.rootMkdirAll(dir, 0755); err != nil {
		return fmt.Errorf("failed to create dns directory: %w", err)
	}

	path := filepath.Join(dir, "discoveries.json")

	// Sort by last seen descending
	sort.Slice(discoveries, func(i, j int) bool {
		return discoveries[i].LastSeen.After(discoveries[j].LastSeen)
	})

	data, err := json.MarshalIndent(discoveries, "", "  ")
	if err != nil {
		return err
	}

	return ds.atomicWriteFile(path, data)
}

// LoadDNSDiscoveries loads DNS discoveries from the datastore.
func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
	if ds == nil || ds.DataDir == "" {
		return []DNSDiscoveryEntry{}, nil
	}

	path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
	if !ds.rootExists(path) {
		return []DNSDiscoveryEntry{}, nil
	}

	data, err := ds.rootReadFile(path)
	if err != nil {
		return nil, err
	}

	var discoveries []DNSDiscoveryEntry
	if err := json.Unmarshal(data, &discoveries); err != nil {
		return nil, err
	}

	return discoveries, nil
}

// ClearDNSDiscoveries removes all DNS discoveries from the datastore.
func (ds *DataStore) ClearDNSDiscoveries() error {
	if ds == nil || ds.DataDir == "" {
		return nil
	}

	path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
	if !ds.rootExists(path) {
		return nil
	}

	return ds.rootRemove(path)
}

// groupFilePath returns the on-disk path for a group file.
func (ds *DataStore) groupFilePath(account, groupID string) string {
	return filepath.Join(ds.AccountDevicesDir(account), "Group_"+groupID+".xml")
}

// generateGroupID returns a unique 7-digit group ID that has no existing file.
func (ds *DataStore) generateGroupID(account string) string {
	for {
		id := fmt.Sprintf("%07d", rand.Int63n(10_000_000)) //nolint:gosec
		if !ds.rootExists(ds.groupFilePath(account, id)) {
			return id
		}
	}
}

// GetGroupForDevice returns the group containing the given device, or nil if ungrouped.
func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group, error) {
	ds.fileMutex.RLock()
	defer ds.fileMutex.RUnlock()

	dir := ds.AccountDevicesDir(account)

	entries, err := ds.rootReadDir(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, ErrGroupNotFound
		}

		return nil, err
	}

	for _, e := range entries {
		if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
			continue
		}

		data, readErr := ds.rootReadFile(filepath.Join(dir, e.Name()))
		if readErr != nil {
			continue
		}

		var g models.Group
		if unmarshalErr := xml.Unmarshal(data, &g); unmarshalErr != nil {
			continue
		}

		for _, role := range g.Roles.Roles {
			if role.DeviceID == deviceID {
				return &g, nil
			}
		}
	}

	return nil, ErrGroupNotFound
}

// AddGroup saves a new group to disk and returns its generated ID.
func (ds *DataStore) AddGroup(account string, group *models.Group) (string, error) {
	ds.fileMutex.Lock()
	defer ds.fileMutex.Unlock()

	dir := ds.AccountDevicesDir(account)
	if err := ds.rootMkdirAll(dir, 0755); err != nil {
		return "", err
	}

	id := ds.generateGroupID(account)
	group.ID = id

	data, err := xml.MarshalIndent(group, "", "    ")
	if err != nil {
		return "", err
	}

	return id, ds.atomicWriteFile(ds.groupFilePath(account, id), append([]byte(xml.Header), data...))
}

// ModifyGroup updates the name of an existing group and returns the updated group.
func (ds *DataStore) ModifyGroup(account, groupID, newName string) (*models.Group, error) {
	ds.fileMutex.Lock()
	defer ds.fileMutex.Unlock()

	path := ds.groupFilePath(account, groupID)

	data, err := ds.rootReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("group %s not found", groupID)
		}

		return nil, err
	}

	var g models.Group
	if xmlErr := xml.Unmarshal(data, &g); xmlErr != nil {
		return nil, xmlErr
	}

	g.Name = newName

	updated, err := xml.MarshalIndent(&g, "", "    ")
	if err != nil {
		return nil, err
	}

	if err := ds.atomicWriteFile(path, append([]byte(xml.Header), updated...)); err != nil {
		return nil, err
	}

	return &g, nil
}

// DeleteGroup removes a group from disk.
func (ds *DataStore) DeleteGroup(account, groupID string) error {
	ds.fileMutex.Lock()
	defer ds.fileMutex.Unlock()

	err := ds.rootRemove(ds.groupFilePath(account, groupID))
	if os.IsNotExist(err) {
		return fmt.Errorf("group %s not found", groupID)
	}

	return err
}

// DeleteAllGroupsForAccount removes every Group_*.xml file stored under
// account. Speakers send DELETE /streaming/account/{id}/group/ (no group
// ID) during stereo-pair teardown; since master and slave may live in
// different accounts each speaker deletes its own copy. Returns nil if no
// group files are found — idempotent by design.
func (ds *DataStore) DeleteAllGroupsForAccount(account string) error {
	ds.fileMutex.Lock()
	defer ds.fileMutex.Unlock()

	dir := ds.AccountDevicesDir(account)

	entries, err := ds.rootReadDir(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil // nothing to delete
		}

		return err
	}

	for _, e := range entries {
		if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
			continue
		}

		_ = ds.rootRemove(filepath.Join(dir, e.Name()))
	}

	return nil
}

// SaveTuneInFavorite records a TuneIn station as favorited by creating a marker file.
// File presence indicates the station is a favorite; no content is stored.
func (ds *DataStore) SaveTuneInFavorite(stationID string) error {
	if ds == nil || ds.DataDir == "" || stationID == "" {
		return nil
	}

	dir := ds.safeJoin("tunein", "favorites")
	if err := ds.rootMkdirAll(dir, 0755); err != nil {
		return err
	}

	return ds.rootWriteFile(ds.safeJoin("tunein", "favorites", stationID), nil, 0644)
}

// DeleteTuneInFavorite removes a previously saved TuneIn favorite marker file.
// Returns nil if the station was not favorited.
func (ds *DataStore) DeleteTuneInFavorite(stationID string) error {
	if ds == nil || ds.DataDir == "" || stationID == "" {
		return nil
	}

	err := ds.rootRemove(ds.safeJoin("tunein", "favorites", stationID))
	if os.IsNotExist(err) {
		return nil
	}

	return err
}
