// Package setup contains speaker migration and configuration helpers.
package setup

import (
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/gesellix/bose-soundtouch/pkg/client"
	"github.com/gesellix/bose-soundtouch/pkg/models"

	"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
	"github.com/gesellix/bose-soundtouch/pkg/service/constants"
	"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
	"github.com/gesellix/bose-soundtouch/pkg/service/marge"
	"github.com/gesellix/bose-soundtouch/pkg/ssh"
	"github.com/gesellix/bose-soundtouch/pkg/telnet"
)

// MigrationMethod represents the method used to migrate a speaker.
type MigrationMethod string

const (
	// MigrationMethodXML redirects services by modifying SoundTouchSdkPrivateCfg.xml.
	MigrationMethodXML MigrationMethod = "xml"
	// MigrationMethodHosts redirects services by modifying /etc/hosts and updating the CA trust store.
	MigrationMethodHosts MigrationMethod = "hosts"
	// MigrationMethodResolvConf redirects services by injecting a priority DNS hook into the DHCP logic and updating the CA trust store.
	MigrationMethodResolvConf MigrationMethod = "resolv"
	// MigrationMethodTelnet redirects services by driving the device's diagnostic
	// shell on TCP port 17000. Requires no SSH access on the device.
	MigrationMethodTelnet MigrationMethod = "telnet"
)

// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device.
const SoundTouchSdkPrivateCfgPath = "/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml"

// PrivateCfg represents the SoundTouchSdkPrivateCfg XML structure.
type PrivateCfg struct {
	XMLName                    xml.Name `xml:"SoundTouchSdkPrivateCfg" json:"-"`
	MargeServerUrl             string   `xml:"margeServerUrl" json:"margeServerUrl"`
	StatsServerUrl             string   `xml:"statsServerUrl" json:"statsServerUrl"`
	SwUpdateUrl                string   `xml:"swUpdateUrl" json:"swUpdateUrl"`
	UsePandoraProductionServer bool     `xml:"usePandoraProductionServer" json:"usePandoraProductionServer"`
	IsZeroconfEnabled          bool     `xml:"isZeroconfEnabled" json:"isZeroconfEnabled"`
	SaveMargeCustomerReport    bool     `xml:"saveMargeCustomerReport" json:"saveMargeCustomerReport"`
	BmxRegistryUrl             string   `xml:"bmxRegistryUrl" json:"bmxRegistryUrl"`
}

// MigrationSummary provides details about the state of a speaker before migration.
type MigrationSummary struct {
	SSHSuccess               bool        `json:"ssh_success"`
	CurrentConfig            string      `json:"current_config"`
	PlannedConfig            string      `json:"planned_config"`
	OriginalConfig           string      `json:"original_config,omitempty"`
	ParsedCurrentConfig      *PrivateCfg `json:"parsed_current_config,omitempty"`
	PlannedHosts             string      `json:"planned_hosts,omitempty"`
	RemoteServicesEnabled    bool        `json:"remote_services_enabled"`
	RemoteServicesPersistent bool        `json:"remote_services_persistent"`
	RemoteServicesFound      []string    `json:"remote_services_found"`
	RemoteServicesCheckErr   string      `json:"remote_services_check_err,omitempty"`
	DeviceName               string      `json:"device_name,omitempty"`
	DeviceModel              string      `json:"device_model,omitempty"`
	DeviceSerial             string      `json:"device_serial,omitempty"`
	DeviceID                 string      `json:"device_id,omitempty"`
	AccountID                string      `json:"account_id,omitempty"`
	FirmwareVersion          string      `json:"firmware_version,omitempty"`
	CACertTrusted            bool        `json:"ca_cert_trusted"`
	ServerHTTPSURL           string      `json:"server_https_url,omitempty"`
	CurrentResolvConf        string      `json:"current_resolv_conf,omitempty"`
	PlannedResolv            string      `json:"planned_resolv,omitempty"`
	IsMigrated               bool        `json:"is_migrated"`
	// Per-axis migration signals — IsMigrated is the OR of these. The UI
	// displays them individually so users can see partial states (e.g.
	// URLs flipped via telnet but the on-disk XML hasn't caught up, or
	// DNS interception in place but no CA installed).
	XMLMigrated    bool `json:"xml_migrated"`
	HostsMigrated  bool `json:"hosts_migrated"`
	ResolvMigrated bool `json:"resolv_migrated"`
	TelnetMigrated bool `json:"telnet_migrated"`
	// IsPaired reports whether the device's live :8090/info advertises a
	// non-empty margeAccountUUID. Surfaced separately so the wizard can
	// flag pairing as a precondition independently of the URL flip.
	IsPaired bool `json:"is_paired"`

	ResolveIPError string `json:"resolve_ip_error,omitempty"`
	// ResolveIPSource records where the resolved IP came from:
	// "device" — authoritative answer via SSH ping (preferred for
	// migration). "service" — service-side DNS lookup (fast but may
	// differ when NAT or split-DNS is in play). Empty when host was
	// already an IP literal or could not be resolved at all.
	ResolveIPSource string `json:"resolve_ip_source,omitempty"`
	// ResolveIPDurationMS measures how long the resolve call took
	// (wall-clock, milliseconds). Captured during preflight so we can
	// observe the SSH-ping cost in the wild.
	ResolveIPDurationMS int64 `json:"resolve_ip_duration_ms,omitempty"`

	// Telnet (port 17000) preflight state — populated when the user is about to
	// or has just used MigrationMethodTelnet.
	TelnetReachable      bool   `json:"telnet_reachable"`
	TelnetBanner         string `json:"telnet_banner,omitempty"`
	TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
	TelnetProbeError     string `json:"telnet_probe_error,omitempty"`

	// KnownAccountIDs are accountIDs already present in the local datastore;
	// the UI offers them as choices when pairing a fresh device.
	KnownAccountIDs []string `json:"known_account_ids,omitempty"`

	// Warnings holds non-fatal advisories emitted during summary
	// construction — currently the cross-check between SSH-XML and
	// telnet-getpdo readings of the device's URL configuration. The UI
	// should display them as informational hints, not errors.
	Warnings []string `json:"warnings,omitempty"`
}

// SSHClient defines the interface for SSH operations.
//
// Connect/Close are optional: Run/UploadContent both work standalone
// (dialing their own one-off connection each time, as they always have).
// Call Connect first when making several calls in a row — e.g.
// RevertMigration's ~17 commands — so they reuse one connection instead of
// dialing fresh every time; defer Close to release it afterward.
type SSHClient interface {
	Run(command string) (string, error)
	UploadContent(content []byte, remotePath string) error
	Connect() error
	Close() error
}

// TelnetClient defines the interface for the device's port-17000 diagnostic
// shell. The concrete implementation lives in github.com/gesellix/bose-soundtouch/pkg/telnet;
// the interface exists so tests can substitute a mock.
type TelnetClient interface {
	Dial() error
	Probe() (string, error)
	SendCommand(cmd string) (string, error)
	Close() error
}

// Manager handles the migration of speakers to the service.
type Manager struct {
	ServerURL string
	DataStore *datastore.DataStore
	Crypto    *certmanager.CertificateManager
	NewSSH    func(host string) SSHClient
	NewTelnet func(host string) TelnetClient

	// NewSession opens the WebSocket setup state-machine session used
	// by ExecuteInitPlan. Tests inject an in-memory fake; the production
	// default is DialSession.
	NewSession func(deviceIP, deviceID string, stepTimeout time.Duration) (StateMachine, error)

	// GetDNSRunning is an optional callback to check the actual state of the DNS server.
	GetDNSRunning func() (bool, string)

	// HTTPGet is an optional override for http.Get (primarily for testing).
	HTTPGet func(url string) (*http.Response, error)

	// Spotify management credentials for the boot primer
	MgmtUsername string
	MgmtPassword string
}

// NewManager creates a new Manager with the given base server URL.
func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.CertificateManager) *Manager {
	return &Manager{
		ServerURL: serverURL,
		DataStore: ds,
		Crypto:    cm,
		NewSSH: func(host string) SSHClient {
			return ssh.NewClient(host)
		},
		NewTelnet: func(host string) TelnetClient {
			return telnet.NewClient(host)
		},
		NewSession: func(deviceIP, deviceID string, stepTimeout time.Duration) (StateMachine, error) {
			return DialSession(deviceIP, deviceID, SessionConfig{StepTimeout: stepTimeout})
		},
		// Bound the speaker GETs (/info, /presets, /recents, /sources,
		// inspect, peer probes). The default http.Get uses
		// http.DefaultClient, which has no timeout, so an offline speaker
		// hangs the caller for the full OS-level TCP timeout (~30 s). That
		// is fine for a one-off, but the admin device list refreshes every
		// device's live /info on each load, so a few offline speakers used
		// to stall the page for 30 s each. A healthy speaker answers in well
		// under a second on the LAN; liveDeviceHTTPTimeout fails the dead
		// ones fast instead.
		HTTPGet:      (&http.Client{Timeout: liveDeviceHTTPTimeout}).Get,
		MgmtUsername: "admin",
		MgmtPassword: "change_me!",
	}
}

// liveDeviceHTTPTimeout bounds the plain HTTP GETs the Manager makes to a
// speaker's :8090 endpoints. Generous enough for a healthy speaker on a
// busy LAN, short enough that an offline speaker fails quickly rather than
// holding a request (and a browser connection slot) for ~30 s.
const liveDeviceHTTPTimeout = 5 * time.Second

// DeviceInfoXML represents the XML structure from :8090/info
type DeviceInfoXML struct {
	XMLName          xml.Name `xml:"info" json:"-"`
	DeviceID         string   `xml:"deviceID,attr" json:"deviceID"`
	Name             string   `xml:"name" json:"name"`
	Type             string   `xml:"type" json:"type"`
	ModuleType       string   `xml:"moduleType" json:"moduleType"`
	MargeAccountUUID string   `xml:"margeAccountUUID" json:"margeAccountUUID"`
	MargeURL         string   `xml:"margeURL" json:"margeURL"`
	CountryCode      string   `xml:"countryCode" json:"countryCode"`
	RegionCode       string   `xml:"regionCode" json:"regionCode"`
	Variant          string   `xml:"variant" json:"variant"`
	VariantMode      string   `xml:"variantMode" json:"variantMode"`
	Components       []struct {
		Category        string `xml:"componentCategory"`
		SoftwareVersion string `xml:"softwareVersion"`
		SerialNumber    string `xml:"serialNumber"`
	} `xml:"components>component" json:"-"`
	NetworkInfo []struct {
		Type       string `xml:"type,attr"`
		MacAddress string `xml:"macAddress"`
		IPAddress  string `xml:"ipAddress"`
	} `xml:"networkInfo" json:"networkInfo"`
	SoftwareVer  string `xml:"-" json:"softwareVersion"`
	SerialNumber string `xml:"-" json:"serialNumber"`
}

// GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint.
func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) {
	infoURL := fmt.Sprintf("http://%s:8090/info", deviceIP)
	// For testing, if the IP already contains a port, don't append :8090
	if host, _, err := net.SplitHostPort(deviceIP); err == nil {
		infoURL = fmt.Sprintf("http://%s/info", deviceIP)
		_ = host
	}

	resp, err := m.HTTPGet(infoURL)
	if err != nil {
		return nil, fmt.Errorf("failed to fetch info from %s: %w", infoURL, err)
	}

	defer func() { _ = resp.Body.Close() }()

	var infoXML DeviceInfoXML
	if err := m.parseDeviceInfoXML(resp.Body, &infoXML); err != nil {
		return nil, fmt.Errorf("failed to decode info XML from %s: %w", infoURL, err)
	}

	return &infoXML, nil
}

// parseDeviceInfoXML is a helper method for parsing device info XML from a reader
func (m *Manager) parseDeviceInfoXML(reader io.Reader, infoXML *DeviceInfoXML) error {
	if err := xml.NewDecoder(reader).Decode(infoXML); err != nil {
		return err
	}

	// Extract data from components
	for _, comp := range infoXML.Components {
		switch comp.Category {
		case "SCM":
			infoXML.SoftwareVer = comp.SoftwareVersion
			if infoXML.SerialNumber == "" {
				infoXML.SerialNumber = comp.SerialNumber
			}
		case "PackagedProduct":
			if infoXML.SerialNumber == "" {
				infoXML.SerialNumber = comp.SerialNumber
			}
		}
	}

	return nil
}

// GetPrimaryMacAddress returns the primary MAC address from the SCM network interface.
func (d *DeviceInfoXML) GetPrimaryMacAddress() string {
	for _, net := range d.NetworkInfo {
		if net.Type == "SCM" && net.MacAddress != "" {
			return net.MacAddress
		}
	}

	return ""
}

// GetMigrationSummary returns a summary of the current and planned state of the speaker.
func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, options map[string]string) (*MigrationSummary, error) {
	if targetURL == "" {
		targetURL = m.ServerURL
	}

	summary := &MigrationSummary{
		SSHSuccess: false,
	}

	// Run the telnet preflight in parallel with the SSH-based probes below.
	// Both transports are queried independently: SSH gives access to
	// /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's
	// `getpdo CurrentSystemConfiguration` reports the live URL set without
	// needing root. They are complementary, so we wait for both and merge
	// the results — total wall time = max(ssh, telnet).
	telnetCh := make(chan MigrationSummary, 1)

	go func() {
		var local MigrationSummary
		m.telnetPreflight(&local, deviceIP)

		telnetCh <- local
	}()

	// Populate device info from datastore and live info
	m.populateDeviceInfo(summary, deviceIP)

	// 1. Initial planned config
	plannedCfg := PrivateCfg{
		MargeServerUrl:             targetURL,
		StatsServerUrl:             targetURL,
		SwUpdateUrl:                fmt.Sprintf("%s/updates/soundtouch", targetURL),
		UsePandoraProductionServer: true,
		IsZeroconfEnabled:          true,
		SaveMargeCustomerReport:    false,
		BmxRegistryUrl:             fmt.Sprintf("%s/bmx/registry/v1/services", targetURL),
	}

	// 2. One batched SSH round-trip collects every file/existence probe
	// we need. Without this, the legacy per-helper path issued ~8 fresh
	// SSH dials in sequence — each pkg/ssh.Run() opens a brand-new
	// TCP+SSH handshake on legacy crypto, ~500 ms–1 s each.
	probe := m.probeSpeakerSSH(deviceIP)

	m.applyProbeToSummary(summary, probe, &plannedCfg, proxyURL, targetURL, options)

	// Per-field literal URL overrides win over both the canonical
	// derivation and any self/proxied/original mode applied above —
	// the user picked a URL, so the planned preview reflects exactly
	// what the XML migration will write.
	applyURLOverrides(&plannedCfg, options)

	xmlContent, err := xml.MarshalIndent(plannedCfg, "", "  ")
	if err != nil {
		return nil, fmt.Errorf("failed to marshal planned XML: %w", err)
	}

	summary.PlannedConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)

	// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error).
	// Pass an SSH client only when the probe succeeded — opening a fresh
	// dial when we already know SSH is dead would burn ~handshake-timeout
	// of wall time per refresh.
	var resolveClient SSHClient
	if probe.SSHOK && m.NewSSH != nil {
		resolveClient = m.NewSSH(deviceIP)
	}

	m.populatePlannedNetworkConfig(summary, deviceIP, targetURL, resolveClient)

	// 3. Provide HTTPS URL for testing (consumed by the migration UI)
	summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)

	// 5. Merge telnet preflight results (started in parallel at the top).
	telnetResult := <-telnetCh
	summary.TelnetReachable = telnetResult.TelnetReachable
	summary.TelnetBanner = telnetResult.TelnetBanner
	summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig
	summary.TelnetProbeError = telnetResult.TelnetProbeError

	// 6. Check if migrated (must run after telnet results are merged so
	// TelnetVerifiedConfig is populated). XML/hosts/resolv axes use the
	// probe data already gathered above.
	m.checkIsMigratedFromProbe(summary, probe)

	// 7. Cross-check SSH-XML and telnet-getpdo readings; surface any
	// divergence as a non-fatal warning.
	m.crossCheckPreflights(summary)

	return summary, nil
}

func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, targetURL string, sshClient SSHClient) {
	parsedURL, err := url.Parse(targetURL)
	if err != nil {
		return
	}

	hostName := parsedURL.Hostname()
	if hostName == "" || hostName == "localhost" {
		return
	}

	// Resolve the target hostname. When the caller provides an SSH
	// client (i.e. the speaker already answered the probe), we prefer
	// the device-side lookup — it's authoritative for the actual
	// network path the speaker will use. When SSH isn't available we
	// fall back to service-side DNS and tag the result with
	// ErrResolvedFromServiceOnly so the summary can render it as
	// informational rather than as a hard error.
	//
	// Historical note: the SSH path was previously skipped here for
	// cost reasons (a comment claimed "2–5 s extra per preflight
	// refresh"). Measured 2026-05-16 across ST10 + ST20 on firmware
	// 27.0.6.46330.5043500: ~290 ms ± 10 ms per resolve, three runs.
	// Well under the original estimate — promoted to the default path.
	// ResolveIPDurationMS stays on the summary so any regression
	// (firmware upgrade, slower kex, etc.) is visible.
	start := time.Now()
	hostIP, resolveErr := m.resolveIP(hostName, sshClient)
	summary.ResolveIPDurationMS = time.Since(start).Milliseconds()

	switch {
	case resolveErr == nil && hostIP != "":
		summary.ResolveIPSource = "device"
		if sshClient == nil {
			// Caller didn't ask for the SSH path, and we got a clean
			// answer — that only happens when host was already an IP
			// literal. Source is neither "device" nor "service" in a
			// meaningful sense; leave it empty.
			summary.ResolveIPSource = ""
		}
	case errors.Is(resolveErr, ErrResolvedFromServiceOnly):
		summary.ResolveIPSource = "service"
		// Sentinel-tagged errors are informational — the resolved IP
		// is still usable for the preview, the caller just shouldn't
		// treat it as authoritative. We do NOT populate ResolveIPError
		// here; the CLI/UI use that field for hard failures only.
	case resolveErr != nil:
		summary.ResolveIPError = resolveErr.Error()
	}

	if hostIP == "" {
		hostIP = hostName
	}

	summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)

	domains := []string{
		"streaming.bose.com",
		"updates.bose.com",
		"stats.bose.com",
		"bmx.bose.com",
		"content.api.bose.io",
		"events.api.bosecm.com",
		// app_key validation for /speaker audio notifications (TTS). Without
		// these redirects the speaker validates against the dead Bose cloud and
		// reports an invalid app key. Both the prod and dev hosts are seeded
		// (firmware may use either). DNS interception already covers them via
		// the bosecm.com substring; seed here for /etc/hosts migrations too.
		"audionotification.api.bosecm.com",
		"audionotificationdev.api.bosecm.com",
		"bose-prod.apigee.net",
		"worldwide.bose.com",
		"music.api.bose.com",
		"media.bose.io",
		"downloads.bose.com",
		"voice.api.bose.io",
	}

	hostsLines := make([]string, len(domains))
	for i, domain := range domains {
		hostsLines[i] = fmt.Sprintf("%s\t%s", hostIP, domain)
	}

	summary.PlannedHosts = strings.Join(hostsLines, "\n")
}

// buildServerHTTPSURL composes the AfterTouch /health probe URL.
//
// Port resolution order (first non-empty wins):
//  1. The port from targetURL when it is already an https:// URL — the
//     caller supplied an HTTPS endpoint, so it owns the port choice.
//  2. The implicit https:// default 443 when targetURL is https:// with
//     no explicit port.
//  3. The HTTPS_PORT env var — back-compat for deployments where
//     targetURL is http://…:8000 and HTTPS_PORT names the separate TLS
//     listener.
//  4. The legacy default 8443.
func (m *Manager) buildServerHTTPSURL(targetURL string) string {
	parsedURL, err := url.Parse(targetURL)
	if err != nil || parsedURL.Hostname() == "" {
		return ""
	}

	var httpsPort string

	if parsedURL.Scheme == "https" {
		httpsPort = parsedURL.Port()
		if httpsPort == "" {
			httpsPort = "443"
		}
	}

	if httpsPort == "" {
		httpsPort = os.Getenv("HTTPS_PORT")
	}

	if httpsPort == "" {
		httpsPort = "8443"
	}

	return fmt.Sprintf("https://%s:%s/health", parsedURL.Hostname(), httpsPort)
}

// checkIsMigrated determines if the device is already migrated to
// AfterTouch and which mechanism is in place.
//
// Each axis is recorded as a separate boolean so the UI can show
// partial-state cells (e.g. URLs flipped via telnet but the on-disk XML
// hasn't been re-rendered, or DNS interception present but no CA
// installed). IsMigrated is the OR — if any mechanism reports the
// device pointing at our service, the device is "migrated."
//
// The telnet-based check runs unconditionally because it is the only
// migration-state signal available on devices that do not expose SSH
// (USB-unlock-refusing firmware on SA-5, ST520, recent ST Portable).
// The SSH-based checks need a working shell and cover the /etc/hosts
// and /etc/resolv.conf interception variants, neither of which shows
// up in `getpdo CurrentSystemConfiguration`.
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
	summary.TelnetMigrated = m.isTelnetMigrated(summary)

	if summary.SSHSuccess {
		client := m.NewSSH(deviceIP)
		summary.XMLMigrated = m.isXMLMigrated(summary)
		summary.HostsMigrated = m.isHostsMigrated(client, summary)
		summary.ResolvMigrated = m.isResolvConfMigrated(client, summary)
	}

	summary.IsMigrated = summary.TelnetMigrated ||
		summary.XMLMigrated ||
		summary.HostsMigrated ||
		summary.ResolvMigrated
}

// isTelnetMigrated reports whether the live device config (read via the
// telnet preflight's `getpdo CurrentSystemConfiguration`) already points
// at our service. Mirrors isXMLMigrated's substring-match semantics — any
// occurrence of our hostname in the response is enough.
func (m *Manager) isTelnetMigrated(summary *MigrationSummary) bool {
	if summary.TelnetVerifiedConfig == "" {
		return false
	}

	parsedTarget, err := url.Parse(m.ServerURL)
	if err != nil {
		return false
	}

	targetHost := parsedTarget.Hostname()
	if targetHost == "" {
		return false
	}

	return strings.Contains(summary.TelnetVerifiedConfig, targetHost)
}

// isXMLMigrated checks whether current XML config already points to our server.
func (m *Manager) isXMLMigrated(summary *MigrationSummary) bool {
	if summary.ParsedCurrentConfig == nil {
		return false
	}

	parsedTarget, err := url.Parse(m.ServerURL)
	if err != nil {
		return false
	}

	targetHost := parsedTarget.Hostname()
	if targetHost == "" {
		return false
	}

	return strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) ||
		strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) ||
		strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) ||
		strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost)
}

// isHostsMigrated checks if /etc/hosts contains Bose domain redirections and CA is trusted.
func (m *Manager) isHostsMigrated(client SSHClient, summary *MigrationSummary) bool {
	hostsContent, err := client.Run("cat /etc/hosts")
	if err != nil {
		return false
	}

	boseDomains := []string{
		"streaming.bose.com",
		"updates.bose.com",
		"stats.bose.com",
		"bmx.bose.com",
	}
	for _, domain := range boseDomains {
		if strings.Contains(hostsContent, domain) && summary.CACertTrusted {
			return true
		}
	}

	return false
}

// isResolvConfMigrated checks for Aftertouch DNS migration signals and CA trust.
func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSummary) bool {
	// Hook file present
	if _, err := client.Run("[ -f /mnt/nv/aftertouch.resolv.conf ]"); err == nil {
		return summary.CACertTrusted
	}

	if summary.CurrentResolvConf == "" {
		return false
	}

	// Marker comment present
	if strings.Contains(summary.CurrentResolvConf, "# Priority nameserver for Bose service redirection") && summary.CACertTrusted {
		return true
	}

	// Match hostname or resolved IP
	parsedTarget, err := url.Parse(m.ServerURL)
	if err != nil {
		return false
	}

	targetHost := parsedTarget.Hostname()
	if targetHost == "" {
		return false
	}

	if strings.Contains(summary.CurrentResolvConf, targetHost) && summary.CACertTrusted {
		return true
	}

	resolvedIP, _ := m.resolveIP(targetHost, client)
	if resolvedIP != "" && strings.Contains(summary.CurrentResolvConf, resolvedIP) && summary.CACertTrusted {
		return true
	}

	return false
}

// populateDeviceInfo fills in device information from datastore and live info
func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string) {
	// Populate from datastore if available
	if m.DataStore != nil {
		devices, err := m.DataStore.ListAllDevices()
		if err == nil {
			for i := range devices {
				d := devices[i]
				if d.IPAddress != deviceIP {
					continue
				}

				summary.DeviceName = d.Name
				summary.DeviceModel = d.ProductCode
				summary.DeviceSerial = d.DeviceSerialNumber
				summary.DeviceID = d.DeviceID
				summary.AccountID = d.AccountID
				summary.FirmwareVersion = d.FirmwareVersion

				break
			}
		}
	}

	// Supplement with live info from :8090/info
	if infoXML, err := m.GetLiveDeviceInfo(deviceIP); err == nil {
		if infoXML.Name != "" {
			summary.DeviceName = infoXML.Name
		}

		if infoXML.Type != "" {
			summary.DeviceModel = infoXML.Type
		}

		if infoXML.SerialNumber != "" {
			summary.DeviceSerial = infoXML.SerialNumber
		}

		if infoXML.SoftwareVer != "" {
			summary.FirmwareVersion = infoXML.SoftwareVer
		}

		if infoXML.DeviceID != "" {
			summary.DeviceID = infoXML.DeviceID
		}

		if infoXML.MargeAccountUUID != "" {
			summary.AccountID = infoXML.MargeAccountUUID
		}
	}

	// Pairing state is derived from the live :8090/info value above
	// (which clobbers the stale datastore copy if both are present).
	// An empty AccountID at this point means a fresh / factory-reset
	// device that needs pairing before presets and streaming work.
	summary.IsPaired = summary.AccountID != ""
}

// checkCurrentConfig reads and validates the current speaker configuration
func (m *Manager) checkCurrentConfig(summary *MigrationSummary, deviceIP string) (string, error) {
	path := SoundTouchSdkPrivateCfgPath
	client := m.NewSSH(deviceIP)

	// Check if .original exists
	if _, checkErr := client.Run(fmt.Sprintf("[ -f %s.original ]", path)); checkErr == nil {
		if originalConfig, _ := client.Run(fmt.Sprintf("cat %s.original", path)); originalConfig != "" {
			summary.OriginalConfig = originalConfig
		}
	}

	// Try to read current config
	config, err := client.Run(fmt.Sprintf("cat %s", path))
	if err == nil && config != "" {
		summary.SSHSuccess = true
		return config, nil
	}

	// Fallback: try base64 if cat returned empty string but file has size > 0
	if config == "" {
		if fileInfo, _ := client.Run(fmt.Sprintf("ls -l %s", path)); fileInfo != "" {
			if b64Config, configErr := client.Run(fmt.Sprintf("base64 %s", path)); configErr == nil && b64Config != "" {
				// File exists but couldn't read content properly
				summary.SSHSuccess = true
				summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err)

				return "", fmt.Errorf("config file exists but couldn't read content")
			}
		}
	}

	// If SSH failed or file couldn't be read, check if SSH connection works at all
	if _, sshErr := client.Run("ls /"); sshErr == nil {
		summary.SSHSuccess = true
		if err != nil {
			summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err)
		} else {
			summary.CurrentConfig = config // Might be empty
		}
	} else {
		summary.SSHSuccess = false
		summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", sshErr)
	}

	return "", err
}

// applyProxyOptions modifies planned config based on proxy options.
// Each field accepts: "proxied" (route through proxyURL), "original" (keep current value), or unset (use targetURL via plannedCfg default).
func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, options map[string]string, currentCfg *PrivateCfg) {
	if currentCfg == nil {
		return
	}

	if options["marge"] == "proxied" && currentCfg.MargeServerUrl != "" {
		plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
	} else if options["marge"] == "original" && currentCfg.MargeServerUrl != "" {
		plannedCfg.MargeServerUrl = currentCfg.MargeServerUrl
	}

	if options["stats"] == "proxied" && currentCfg.StatsServerUrl != "" {
		plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)
	} else if options["stats"] == "original" && currentCfg.StatsServerUrl != "" {
		plannedCfg.StatsServerUrl = currentCfg.StatsServerUrl
	}

	if options["sw_update"] == "proxied" && currentCfg.SwUpdateUrl != "" {
		plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl)
	} else if options["sw_update"] == "original" && currentCfg.SwUpdateUrl != "" {
		plannedCfg.SwUpdateUrl = currentCfg.SwUpdateUrl
	}

	if options["bmx"] == "proxied" && currentCfg.BmxRegistryUrl != "" {
		plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl)
	} else if options["bmx"] == "original" && currentCfg.BmxRegistryUrl != "" {
		plannedCfg.BmxRegistryUrl = currentCfg.BmxRegistryUrl
	}
}

// applyURLOverrides applies per-field literal URL overrides from the
// migration options map (marge_url / stats_url / sw_update_url /
// bmx_url) on top of an already-populated PrivateCfg. Empty or missing
// entries leave the field unchanged.
//
// These overrides win over the legacy "self/proxied/original" semantic
// applied by applyProxyOptions: if the user picked a literal URL, the
// migration honors it verbatim. The XML and Telnet write paths and
// the GetMigrationSummary read path all call this so the planned
// preview matches what migration actually writes.
func applyURLOverrides(cfg *PrivateCfg, options map[string]string) {
	if cfg == nil || options == nil {
		return
	}

	if v := options["marge_url"]; v != "" {
		cfg.MargeServerUrl = v
	}

	if v := options["stats_url"]; v != "" {
		cfg.StatsServerUrl = v
	}

	if v := options["sw_update_url"]; v != "" {
		cfg.SwUpdateUrl = v
	}

	if v := options["bmx_url"]; v != "" {
		cfg.BmxRegistryUrl = v
	}
}

// checkCACertTrusted decides whether the device already trusts *this* service's
// CA.
//
// When the service CA is available (Manager.Crypto set), the certificate
// *payload* is authoritative: a previous migration may have left our label
// (CALabel) in the bundle even though the actual CA has since changed — e.g. the
// service CA was regenerated after its data dir was recreated without a
// persistent volume. Matching the label alone would then falsely report the
// device as trusted and skip re-installing the new CA, leaving the speaker
// unable to validate TLS to the service (the symptom seen in #517). So we match
// the current CA's payload and deliberately ignore the (possibly stale) label.
//
// Only when the CA can't be read (e.g. a CLI caller without Crypto) do we fall
// back to the label, which is the best signal available there.
func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string) {
	client := m.NewSSH(deviceIP)
	bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"

	if m.Crypto != nil {
		if certData, ok := m.firstCACertBodyLine(); ok {
			// Payload present -> trusted; absent -> not trusted (re-install),
			// regardless of a possibly-stale label.
			if _, err := client.Run(fmt.Sprintf("grep -F %q %s", certData, bundlePath)); err == nil {
				summary.CACertTrusted = true
			}

			return
		}
	}

	// Fallback for callers without the CA at hand: match our injected label.
	output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath))
	if err == nil && strings.Contains(output, CALabel) {
		summary.CACertTrusted = true
	}
}

// firstCACertBodyLine returns the first base64 body line of the service CA
// certificate, used as a cheap fingerprint to check whether this exact CA is
// already present in a device's trust bundle. Returns false when the cert can't
// be read or has no body line.
func (m *Manager) firstCACertBodyLine() (string, bool) {
	caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
	if err != nil {
		return "", false
	}

	for _, line := range strings.Split(string(caCertPEM), "\n") {
		if line != "" && !strings.Contains(line, "BEGIN CERTIFICATE") && !strings.Contains(line, "END CERTIFICATE") {
			return line, true
		}
	}

	return "", false
}

// MigrateSpeaker configures the speaker at the given IP to use this service.
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) {
	if targetURL == "" {
		targetURL = m.ServerURL
	}

	if method == "" {
		method = MigrationMethodXML
	}

	// Telnet is SSH-free by design — skip the SSH-based off-device backup and
	// rw pre-flight, both of which would fail on devices that haven't been
	// rooted via remote_services.
	if method == MigrationMethodTelnet {
		urls := telnetURLsFromOptions(targetURL, options)
		return m.migrateViaTelnet(deviceIP, targetURL, urls)
	}

	var logs string

	// 0. Off-device backup for safety
	if backupErr := m.BackupConfigOffDevice(deviceIP); backupErr != nil {
		logs += fmt.Sprintf("Warning: Failed to create off-device backup: %v\n", backupErr)
		// We continue, but this is a warning
	} else {
		logs += "Successfully created off-device backup of current configuration.\n"
	}

	// 0b. Pre-flight check for SSH /rw permissions
	client := m.NewSSH(deviceIP)

	rwCmd := "(rw || mount -o remount,rw /)"
	if rwTest, rwErr := client.Run(rwCmd); rwErr != nil {
		return logs, fmt.Errorf("pre-flight check failed: cannot gain write access (cmd: %s, output: %s): %w", rwCmd, rwTest, rwErr)
	}

	logs += "Pre-flight: Write access verified.\n"

	switch method {
	case MigrationMethodHosts:
		out, err := m.migrateViaHosts(deviceIP, targetURL)
		return logs + out, err

	case MigrationMethodResolvConf:
		if err := m.checkDNSPreFlight(); err != nil {
			return logs, err
		}

		out, err := m.migrateViaResolvConf(deviceIP, targetURL)

		return logs + out, err

	case MigrationMethodXML:
		out, err := m.migrateViaXML(deviceIP, targetURL, proxyURL, options, client, rwCmd)
		return logs + out, err

	default:
		return logs, fmt.Errorf("unsupported migration method: %s", method)
	}
}

func (m *Manager) checkDNSPreFlight() error {
	// CLI / remote callers construct a Manager without a DataStore — they
	// can't introspect AfterTouch's settings from here. Skip the local
	// check in that case; the caller is responsible for verifying the
	// remote service's DNS state (the CLI hits GET /setup/settings).
	if m.DataStore == nil {
		return nil
	}

	// Pre-flight check: DNS server must be enabled and bound to port 53
	settings, err := m.DataStore.GetSettings()
	if err != nil {
		return fmt.Errorf("failed to retrieve settings: %w", err)
	}

	if !settings.DNSEnabled {
		return fmt.Errorf("DNS discovery server is not enabled. Please enable it in Settings before using /etc/resolv.conf migration")
	}

	if !strings.HasSuffix(settings.DNSBindAddr, ":53") && settings.DNSBindAddr != "53" {
		return fmt.Errorf("DNS discovery server is bound to %s, but port 53 is required for /etc/resolv.conf migration", settings.DNSBindAddr)
	}

	// Also check the actual running state if callback is available
	if m.GetDNSRunning != nil {
		isRunning, bindAddr := m.GetDNSRunning()
		if !isRunning {
			return fmt.Errorf("DNS discovery server is configured but not actually running on %s. Please check logs for binding errors", bindAddr)
		}

		if !strings.HasSuffix(bindAddr, ":53") && bindAddr != "53" {
			// This shouldn't happen based on previous check, but for completeness
			return fmt.Errorf("DNS discovery server is running on %s, but port 53 is required", bindAddr)
		}
	}

	return nil
}

func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options map[string]string, client SSHClient, rwCmd string) (string, error) {
	var logs string

	out, err := m.EnsureRemoteServices(deviceIP)
	logs += "Ensuring remote services:\n" + out + "\n"

	if err != nil {
		// Log but continue migration? Or fail? The requirement is "to ensure stable 'remote_services'"
		// Let's log it.
		fmt.Printf("Warning: failed to ensure remote services: %v\n", err)
	}

	cfg := PrivateCfg{
		MargeServerUrl:             targetURL,
		StatsServerUrl:             targetURL,
		SwUpdateUrl:                fmt.Sprintf("%s/updates/soundtouch", targetURL),
		UsePandoraProductionServer: true,
		IsZeroconfEnabled:          true,
		SaveMargeCustomerReport:    false,
		BmxRegistryUrl:             fmt.Sprintf("%s/bmx/registry/v1/services", targetURL),
	}

	// If we can read current config, apply per-field options
	if curCfg, curCfgErr := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); curCfgErr == nil && curCfg != "" {
		logs += "Read current configuration\n"

		var currentCfg PrivateCfg
		if xml.Unmarshal([]byte(curCfg), &currentCfg) == nil {
			if proxyURL == "" {
				proxyURL = targetURL
			}

			if options != nil {
				m.applyProxyOptions(&cfg, proxyURL, options, &currentCfg)
			} else if proxyURL != "" {
				cfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
				cfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)
				cfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl)
				cfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl)
			}
		}
	}

	// Per-field literal URL overrides take precedence over the
	// proxy/original modes applied above — see applyURLOverrides.
	applyURLOverrides(&cfg, options)

	xmlContent, err := xml.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return logs, fmt.Errorf("failed to marshal XML: %w", err)
	}

	// Add XML header
	xmlContent = append([]byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"), xmlContent...)

	// 0. Backup original config if it doesn't exist
	remotePath := SoundTouchSdkPrivateCfgPath

	if backupOut, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
		logs += fmt.Sprintf("Backing up original config to %s.original (check: %s)\n", remotePath, backupOut)
		fmt.Printf("Backing up original config to %s.original\n", remotePath)

		if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil {
			logs += fmt.Sprintf("cp backup failed: %v (output: %s)\n", err, output)
			fmt.Printf("cp backup failed: %v (output: %s)\n", err, output)

			if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" {
				if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
					logs += "failed to upload backup config: " + err.Error() + "\n"
					return logs, fmt.Errorf("cannot create backup of %s before migration: %w", remotePath, err)
				}

				logs += "Uploaded backup config via fallback\n"
			} else {
				return logs, fmt.Errorf("cannot create backup of %s before migration: failed to read original config", remotePath)
			}
		} else {
			logs += "Copied backup config to .original\n"
		}
	} else {
		logs += "Backup .original already exists\n"
	}

	// 1. Upload the configuration
	out, _ = client.Run(rwCmd)

	logs += rwCmd + ": " + out + "\n"
	if err := client.UploadContent(xmlContent, remotePath); err != nil {
		return logs, fmt.Errorf("failed to upload config: %w", err)
	}

	logs += "Uploaded new configuration to " + remotePath + "\n"

	// 2. Verify the configuration on device
	if verification, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil {
		if !strings.Contains(verification, cfg.MargeServerUrl) {
			return logs, fmt.Errorf("verification failed: uploaded config on %s does not contain expected margeServerUrl", deviceIP)
		}

		logs += "Verified configuration on device\n"
	} else {
		logs += fmt.Sprintf("Warning: could not verify configuration on device: %v\n", err)
	}

	// 3. Inject CA Certificate (optional but recommended)
	summary := &MigrationSummary{}
	m.checkCACertTrusted(summary, deviceIP)

	if !summary.CACertTrusted {
		out, err := m.TrustCACert(deviceIP)

		logs += "Trusting CA:\n" + out + "\n"

		if err != nil {
			fmt.Printf("Warning: failed to trust CA: %v\n", err)
		}
	}

	logs += m.resyncBoseURLsAfterXML(deviceIP, telnetURLs{
		Marge:       cfg.MargeServerUrl,
		Stats:       cfg.StatsServerUrl,
		SwUpdate:    cfg.SwUpdateUrl,
		BmxRegistry: cfg.BmxRegistryUrl,
	})

	return logs, nil
}

// resyncBoseURLsAfterXML re-applies all four boseurls over telnet so the
// runtime URL layer matches the XML just written by migrateViaXML.
//
// The XML migration only updates the persisted SoundTouchSdkPrivateCfg.xml; it
// does not touch the runtime/persistence layer that `getpdo
// CurrentSystemConfiguration` reports. When SSH was bootstrapped via #471
// (`enable-ssh`), that layer still points at the placeholder boseurls
// (https://aftertouch.invalid), so the preflight cross-check keeps warning that
// the URLs differ between transports until a reboot.
//
// All four fields are re-applied, not just marge/swUpdate: the closing
// `envswitch boseurls set` commit persists whatever is currently in the
// runtime layer at the moment it runs, not only its own two arguments (see
// docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md). Committing while
// stats/bmx are still stale in the runtime layer freezes those stale values
// into the persistence layer permanently — a later reboot loads that frozen
// persistence layer, not the XML file, so nothing short of a factory reset
// clears it again. Re-applying the real boseurls over telnet :17000
// reconciles all four immediately.
//
// Best-effort: telnet may be unavailable (no port 17000, or it was closed via
// --close-17000), in which case a reboot still reconciles the layers, so this
// only returns a note and never fails the migration. Returns the log lines to
// append.
func (m *Manager) resyncBoseURLsAfterXML(deviceIP string, urls telnetURLs) string {
	if m.NewTelnet == nil {
		return ""
	}

	rlogs, rerr := m.setAllBoseURLsViaTelnet(deviceIP, urls)
	if rerr != nil {
		return fmt.Sprintf("Note: could not re-sync boseurls over telnet (%v); a device reboot will reconcile the runtime layer.\n", rerr)
	}

	return "Re-applied boseurls over telnet so the runtime layer matches the new configuration:\n" + rlogs
}

// BackupConfigOffDevice creates a local backup of the speaker's configuration files in the DataStore.
func (m *Manager) BackupConfigOffDevice(deviceIP string) error {
	if m.DataStore == nil {
		return fmt.Errorf("datastore not configured")
	}

	client := m.NewSSH(deviceIP)

	// We need the serial number and account identifier to find the right directory in DataStore
	info, err := m.GetLiveDeviceInfo(deviceIP)
	if err != nil {
		return fmt.Errorf("failed to get device info: %w", err)
	}

	accountID := info.MargeAccountUUID
	deviceID := info.SerialNumber

	if deviceID == "" {
		deviceID = info.DeviceID
	}

	if deviceID == "" {
		deviceID = deviceIP
	}

	if accountID == "" {
		// Try to find account ID from existing device entries if info didn't have it
		devices, _ := m.DataStore.ListAllDevices()
		for i := range devices {
			if devices[i].DeviceSerialNumber == info.SerialNumber || (info.DeviceID != "" && devices[i].DeviceID == info.DeviceID) {
				accountID = devices[i].AccountID
				break
			}
		}
	}

	if accountID == "" {
		accountID = "default"
	}

	deviceDir := m.DataStore.AccountDeviceDir(accountID, deviceID)
	if err := os.MkdirAll(deviceDir, 0755); err != nil {
		return fmt.Errorf("failed to create device directory: %w", err)
	}

	// 1. Backup SoundTouchSdkPrivateCfg.xml
	if config, err := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); err == nil && config != "" {
		backupPath := filepath.Join(deviceDir, "SoundTouchSdkPrivateCfg.xml.bak")
		if err := os.WriteFile(backupPath, []byte(config), 0644); err != nil {
			return fmt.Errorf("failed to write config backup: %w", err)
		}
	}

	// 2. Backup /etc/hosts
	if hosts, err := client.Run("cat /etc/hosts"); err == nil && hosts != "" {
		backupPath := filepath.Join(deviceDir, "hosts.bak")
		if err := os.WriteFile(backupPath, []byte(hosts), 0644); err != nil {
			return fmt.Errorf("failed to write hosts backup: %w", err)
		}
	}

	return nil
}

// BackupConfig creates a backup of the current configuration on the speaker.
func (m *Manager) BackupConfig(deviceIP string) (string, error) {
	client := m.NewSSH(deviceIP)
	remotePath := SoundTouchSdkPrivateCfgPath
	rwCmd := "(rw || mount -o remount,rw /)"

	// Check if .original already exists
	if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil {
		return "", fmt.Errorf("backup already exists at %s.original", remotePath)
	}

	// Try to copy on the device first (more reliable), ensuring filesystem is writable
	output, cpErr := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath))
	if cpErr == nil {
		return output, nil
	}

	logs := output + "\n"
	fmt.Printf("Direct cp failed: %v (output: %s), falling back to cat+upload\n", cpErr, output)

	// Fallback to cat + upload
	config, err := client.Run(fmt.Sprintf("cat %s", remotePath))

	logs += "cat " + remotePath + ": " + config + "\n"
	if err != nil || config == "" {
		return logs, fmt.Errorf("failed to read current config: %w", err)
	}

	// Ensure rw before upload fallback
	out, _ := client.Run(rwCmd)

	logs += rwCmd + ": " + out + "\n"
	if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
		return logs, fmt.Errorf("failed to upload backup config: %w", err)
	}

	logs += "Uploaded backup to " + remotePath + ".original\n"

	return logs, nil
}

// EnsureRemoteServices ensures that remote services are enabled on the device.
// It tries to create an empty file in one of the known valid locations.
func (m *Manager) EnsureRemoteServices(deviceIP string) (string, error) {
	client := m.NewSSH(deviceIP)
	rwCmd := "(rw || mount -o remount,rw /)"

	// Try locations in order of preference
	locations := []string{
		"/etc/remote_services",
		"/mnt/nv/remote_services",
		"/tmp/remote_services",
	}

	var logs string

	for _, loc := range locations {
		// Try to make filesystem writable for each location that might need it
		// Combining rw && touch ensures it's attempted in the same sequence
		out, err := client.Run(fmt.Sprintf("%s && touch %s", rwCmd, loc))

		logs += fmt.Sprintf("touch %s (with rw): %s\n", loc, out)
		if err == nil {
			return logs, nil
		}
		// If rw && touch failed, try just touch (e.g. for /tmp which doesn't need rw)
		out, err = client.Run(fmt.Sprintf("touch %s", loc))

		logs += fmt.Sprintf("touch %s: %s\n", loc, out)
		if err == nil {
			return logs, nil
		}
	}

	return logs, fmt.Errorf("failed to enable remote services in any of the locations: %v", locations)
}

// ProbeCABundles returns the live CA bundle and the .original factory backup
// from the speaker at deviceIP via a single SSH round-trip. sshOK is false
// when SSH is unreachable or authentication failed; in that case both bundle
// strings are empty. Either bundle string may be empty if the corresponding
// file does not exist on the device (e.g. .original is absent on speakers
// that have never had the AfterTouch CA injected).
//
// Used by the health check to verify bundle integrity without exposing the
// unexported speakerProbe type.
func (m *Manager) ProbeCABundles(deviceIP string) (current, original string, sshOK bool) {
	probe := m.probeSpeakerSSH(deviceIP)

	return probe.Files["/etc/pki/tls/certs/ca-bundle.crt"],
		probe.Files["/etc/pki/tls/certs/ca-bundle.crt.original"],
		probe.SSHOK
}

// RestoreCABundleFromOriginal copies the .original factory backup over the
// live ca-bundle.crt on the speaker. It is called by the health-check fix
// executor when check (1) — "original certs are contained in current bundle"
// — fails, before re-injecting the AfterTouch CA via TrustCACert.
//
// The operation requires SSH access and a read-write root filesystem; both
// are attempted via the same remount path as TrustCACertFromBytes.
func (m *Manager) RestoreCABundleFromOriginal(deviceIP string) error {
	if m.NewSSH == nil {
		return errors.New("RestoreCABundleFromOriginal: no SSH factory configured")
	}

	bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
	client := m.NewSSH(deviceIP)

	if _, err := client.Run("(rw || mount -o remount,rw /)"); err != nil {
		return fmt.Errorf("remount rw: %w", err)
	}

	if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", bundlePath)); err != nil {
		return fmt.Errorf("original backup %s.original not found on speaker — run `setup install-ca` first", bundlePath)
	}

	if _, err := client.Run(fmt.Sprintf("cp %s.original %s", bundlePath, bundlePath)); err != nil {
		return fmt.Errorf("restore from original: %w", err)
	}

	return nil
}

// TrustCACert injects the local CA certificate into the device's shared
// trust store. The cert is read from disk via Manager.Crypto — used by
// the in-process migration flow where the CLI and the certmanager share
// a filesystem. Remote/CLI callers without Crypto should fetch the cert
// over HTTP and use TrustCACertFromBytes instead.
func (m *Manager) TrustCACert(deviceIP string) (string, error) {
	if m.Crypto == nil {
		return "", errors.New("TrustCACert: Manager.Crypto is nil — remote callers should fetch the CA via /setup/ca.crt and call TrustCACertFromBytes (e.g. `soundtouch-cli setup install-ca`)")
	}

	caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
	if err != nil {
		return "", fmt.Errorf("failed to read CA certificate: %w", err)
	}

	return m.TrustCACertFromBytes(deviceIP, caCertPEM)
}

// TrustCACertFromBytes injects the supplied PEM-encoded CA bundle into
// the speaker's shared trust store. Identical to TrustCACert except the
// cert bytes come from the caller — used by the remote CLI which fetches
// /setup/ca.crt over HTTP and never touches Manager.Crypto.
//
// The write path is two-phase to keep the live bundle never half-written:
//
//  1. Upload the modified bundle to <bundlePath>.aftertouch.tmp (a sibling
//     on the same filesystem, so the same rw remount covers it).
//  2. Read the tmp back over SSH, validate that every PEM block parses
//     and that the AfterTouch CA sentinel brackets exactly one certificate,
//     then atomically rename the tmp into place via `mv`. On any failure
//     between steps 1 and 2 the tmp is unlinked and the live bundle is
//     untouched — there is no rollback semantics to reason about.
//
// The .original backup written on first install is retained as
// defense-in-depth (a user can manually restore from it if anything outside
// this code path corrupts the live bundle), but it is no longer the
// primary safety net for our own writes. See issue #262 for the original
// failure-mode reporter.
func (m *Manager) TrustCACertFromBytes(deviceIP string, caCertPEM []byte) (string, error) {
	if !strings.Contains(string(caCertPEM), "BEGIN CERTIFICATE") {
		return "", fmt.Errorf("CA payload does not contain a PEM certificate")
	}

	client := m.NewSSH(deviceIP)
	rwCmd := "(rw || mount -o remount,rw /)"

	var logs string

	bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
	tmpPath := bundlePath + ".aftertouch.tmp"
	out, _ := client.Run(rwCmd)
	logs += rwCmd + ": " + out + "\n"

	// Backup bundle if it doesn't exist
	if _, backupErr := client.Run(fmt.Sprintf("[ -f %s.original ]", bundlePath)); backupErr != nil {
		out, _ := client.Run(fmt.Sprintf("cp %s %s.original", bundlePath, bundlePath))
		logs += fmt.Sprintf("cp %s %s.original: %s\n", bundlePath, bundlePath, out)
	}

	// Check if the label already exists in the bundle
	bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath))

	logs += "cat " + bundlePath + " (check existing)\n"
	if err != nil {
		return logs, fmt.Errorf("failed to read bundle: %w", err)
	}

	if strings.Contains(bundleContent, CALabel) {
		// Rebuild the bundle without our previously-injected CA so the
		// fresh one replaces the old. Older AfterTouch releases are
		// reported to have appended the CA on every install without
		// stripping the previous one, so live bundles can carry
		// several stale copies — stripAfterTouchEntries collapses
		// them all and reports the count so we can log a single line
		// of cleanup rather than failing validation.
		stripped := stripAfterTouchEntries(bundleContent)
		bundleContent = stripped.CleanedBundle

		if stripped.RemovedEntries > 1 {
			logs += fmt.Sprintf("Cleaned up %d duplicate AfterTouch CA entries from existing bundle\n", stripped.RemovedEntries)
		}

		if stripped.UnpairedSentinel {
			logs += "Warning: existing bundle had an unpaired AfterTouch sentinel; content after it was dropped along with the orphan. If anything legitimate was after the sentinel, restore from " + bundlePath + ".original.\n"
		}
	} else if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") {
		bundleContent += "\n"
	}

	labeledCert := fmt.Sprintf("\n%s\n%s%s\n", CALabel, string(caCertPEM), CALabel)
	newBundleContent := bundleContent + labeledCert

	// Pre-upload validation: catch construction-time bugs (mangled PEM,
	// missing sentinel, etc.) before any SSH write. The live bundle is
	// untouched at this point.
	if _, vErr := validateCABundleBytes([]byte(newBundleContent)); vErr != nil {
		return logs, fmt.Errorf("constructed bundle failed validation, live bundle untouched: %w", vErr)
	}

	if vErr := validateAfterTouchLabelBracketing([]byte(newBundleContent)); vErr != nil {
		return logs, fmt.Errorf("constructed bundle has malformed AfterTouch sentinel, live bundle untouched: %w", vErr)
	}

	// Phase 1: upload to a sibling tmp file on the same filesystem.
	if err := client.UploadContent([]byte(newBundleContent), tmpPath); err != nil {
		return logs, fmt.Errorf("failed to upload bundle to %s: %w", tmpPath, err)
	}

	logs += "Uploaded candidate bundle to " + tmpPath + "\n"

	// Phase 2: read the tmp back and verify the bytes survived transport.
	// On any failure here, unlink the tmp; the live bundle was never
	// touched, so no rollback is required.
	verifyContent, verifyErr := client.Run(fmt.Sprintf("cat %s", tmpPath))
	if verifyErr != nil {
		_, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath))
		return logs, fmt.Errorf("failed to read back candidate bundle %s for verification, live bundle untouched: %w", tmpPath, verifyErr)
	}

	if _, vErr := validateCABundleBytes([]byte(verifyContent)); vErr != nil {
		_, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath))
		return logs, fmt.Errorf("verification of %s failed (post-upload PEM parse), live bundle untouched: %w", tmpPath, vErr)
	}

	if vErr := validateAfterTouchLabelBracketing([]byte(verifyContent)); vErr != nil {
		_, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath))
		return logs, fmt.Errorf("verification of %s failed (post-upload sentinel bracketing), live bundle untouched: %w", tmpPath, vErr)
	}

	logs += "Verified candidate bundle at " + tmpPath + "\n"

	// Atomic replace. On the device's local filesystem this is a
	// rename(2) — observers see either the pre- or post-bundle, never
	// a half-written one.
	mvCmd := fmt.Sprintf("mv %s %s", tmpPath, bundlePath)
	if mvOut, mvErr := client.Run(mvCmd); mvErr != nil {
		_, _ = client.Run(fmt.Sprintf("rm -f %s", tmpPath))
		return logs, fmt.Errorf("failed to atomically replace bundle (%s -> %s, output=%q): %w", tmpPath, bundlePath, mvOut, mvErr)
	}

	logs += mvCmd + "\n"

	return logs, nil
}

func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
	client := m.NewSSH(deviceIP)
	rwCmd := "(rw || mount -o remount,rw /)"

	var logs string

	// 1. Parse targetURL to get IP for /etc/hosts
	parsedURL, err := url.Parse(targetURL)
	if err != nil {
		return "", fmt.Errorf("failed to parse target URL: %w", err)
	}

	hostName := parsedURL.Hostname()
	if hostName == "" || hostName == "localhost" {
		// Use a better guess if needed, but for now expect valid IP/hostname
		return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
	}

	hostIP, err := m.resolveIP(hostName, client)
	if err != nil {
		return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err)
	}

	logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)

	// 2. Prepare /etc/hosts entries
	domains := []string{
		"streaming.bose.com",
		"updates.bose.com",
		"stats.bose.com",
		"bmx.bose.com",
		"content.api.bose.io",
		"events.api.bosecm.com",
		// app_key validation for /speaker audio notifications (TTS). Without
		// these redirects the speaker validates against the dead Bose cloud and
		// reports an invalid app key. Both the prod and dev hosts are seeded
		// (firmware may use either). DNS interception already covers them via
		// the bosecm.com substring; seed here for /etc/hosts migrations too.
		"audionotification.api.bosecm.com",
		"audionotificationdev.api.bosecm.com",
		"bose-prod.apigee.net",
		"worldwide.bose.com",
		"media.bose.io",
		"downloads.bose.com",
		"voice.api.bose.io",
	}

	hostsContent, err := client.Run("cat /etc/hosts")

	logs += "cat /etc/hosts: " + hostsContent + "\n"
	if err != nil {
		return logs, fmt.Errorf("failed to read /etc/hosts: %w", err)
	}

	hostsContent = m.generateHostsContent(hostsContent, domains, hostIP)

	// 3. Upload new /etc/hosts
	out, _ := client.Run(rwCmd)
	logs += rwCmd + ": " + out + "\n"
	// Backup /etc/hosts if it doesn't exist
	if _, err := client.Run("[ -f /etc/hosts.original ]"); err != nil {
		out, _ := client.Run("cp /etc/hosts /etc/hosts.original")
		logs += "cp /etc/hosts /etc/hosts.original: " + out + "\n"
	}

	if err := client.UploadContent([]byte(hostsContent), "/etc/hosts"); err != nil {
		return logs, fmt.Errorf("failed to update /etc/hosts: %w", err)
	}

	logs += "Uploaded updated /etc/hosts\n"

	// 4. Verify /etc/hosts on device
	if err := m.verifyHosts(client, domains, hostIP, deviceIP); err != nil {
		return logs, err
	}

	logs += "Verified /etc/hosts on device\n"

	fmt.Printf("Updated /etc/hosts on %s:\n%s\n", sanitizeLog(deviceIP), sanitizeLog(hostsContent))

	// 5. Inject CA Certificate
	summary := &MigrationSummary{}
	m.checkCACertTrusted(summary, deviceIP)

	if !summary.CACertTrusted {
		out, err := m.TrustCACert(deviceIP)

		logs += "Trusting CA:\n" + out + "\n"
		if err != nil {
			return logs, err
		}
	} else {
		logs += "CA certificate already trusted, skipping injection\n"

		fmt.Printf("CA certificate already trusted on %s, skipping injection\n", deviceIP)
	}

	return logs, nil
}

func (m *Manager) generateHostsContent(currentContent string, domains []string, hostIP string) string {
	lines := strings.Split(currentContent, "\n")

	var newLines []string

	domainFound := make(map[string]bool)

	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
			newLines = append(newLines, line)
			continue
		}

		fields := strings.Fields(trimmed)
		if len(fields) >= 2 {
			domain := fields[1]
			isBoseDomain := false

			for _, d := range domains {
				if d == domain {
					isBoseDomain = true
					break
				}
			}

			if isBoseDomain {
				// Update existing entry with new IP
				newLines = append(newLines, fmt.Sprintf("%s\t%s", hostIP, domain))
				domainFound[domain] = true

				continue
			}
		}

		newLines = append(newLines, line)
	}

	// Add missing domains
	for _, domain := range domains {
		if !domainFound[domain] {
			newLines = append(newLines, fmt.Sprintf("%s\t%s", hostIP, domain))
		}
	}

	hostsContent := strings.Join(newLines, "\n")
	if !strings.HasSuffix(hostsContent, "\n") {
		hostsContent += "\n"
	}

	return hostsContent
}

func (m *Manager) verifyHosts(client SSHClient, domains []string, hostIP, deviceIP string) error {
	verification, err := client.Run("cat /etc/hosts")
	if err != nil {
		return fmt.Errorf("could not verify /etc/hosts on device: %w", err)
	}

	for _, domain := range domains {
		if !strings.Contains(verification, domain) || !strings.Contains(verification, hostIP) {
			return fmt.Errorf("verification failed: /etc/hosts on %s does not contain expected redirection for %s", deviceIP, domain)
		}
	}

	return nil
}

func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, error) {
	client := m.NewSSH(deviceIP)
	rwCmd := "(rw || mount -o remount,rw /)"

	var logs string

	// 1. Resolve target hostname to IP
	parsedURL, err := url.Parse(targetURL)
	if err != nil {
		return "", fmt.Errorf("failed to parse target URL: %w", err)
	}

	hostName := parsedURL.Hostname()
	if hostName == "" || hostName == "localhost" {
		return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
	}

	hostIP, err := m.resolveIP(hostName, client)
	if err != nil {
		return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err)
	}

	logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)

	// 2. Prepare /mnt/nv/soundtouch-service/aftertouch.resolv.conf content
	resolvContent := fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)

	// 3. Upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf
	// Ensure /mnt/nv/soundtouch-service exists
	_, _ = client.Run("mkdir -p /mnt/nv/soundtouch-service")

	if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"); uploadErr != nil {
		return logs, fmt.Errorf("failed to upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf: %w", uploadErr)
	}

	logs += "Uploaded /mnt/nv/soundtouch-service/aftertouch.resolv.conf\n"

	// 4. Update /mnt/nv/rc.local with idempotent patch
	patchOut, err := m.updateRcLocalWithDNSHook(client)
	logs += patchOut

	if err != nil {
		return logs, err
	}

	// 5. Cleanup legacy file
	_, _ = client.Run("rm -f /mnt/nv/aftertouch.resolv.conf")

	// 6. Apply patch immediately to /etc/udhcpc.d/50default
	rwOut, _ := client.Run(rwCmd)
	logs += rwCmd + ": " + rwOut + "\n"

	hookMarker := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
	targetDHCPFile := "/etc/udhcpc.d/50default"
	dhcpPatchOut, err := m.patchDHCPFile(client, targetDHCPFile, hookMarker)
	logs += dhcpPatchOut

	if err != nil {
		logs += fmt.Sprintf("Warning: could not apply/verify patch on %s: %v\n", targetDHCPFile, err)
	}

	// Apply patch immediately to /opt/Bose/udhcpc.script if it exists
	targetScript := "/opt/Bose/udhcpc.script"
	if _, err := client.Run(fmt.Sprintf("[ -f %s ]", targetScript)); err == nil {
		scriptPatchOut, err := m.patchUdhcpcScript(client, targetScript, hookMarker)
		logs += scriptPatchOut

		if err != nil {
			logs += fmt.Sprintf("Warning: could not apply/verify patch on %s: %v\n", targetScript, err)
		}
	}

	// 6. Inject CA Certificate
	summary := &MigrationSummary{}
	m.checkCACertTrusted(summary, deviceIP)

	if !summary.CACertTrusted {
		out, err := m.TrustCACert(deviceIP)

		logs += "Trusting CA:\n" + out + "\n"
		if err != nil {
			return logs, err
		}
	} else {
		logs += "CA certificate already trusted, skipping injection\n"
	}

	return logs, nil
}

func (m *Manager) updateRcLocalWithDNSHook(client SSHClient) (string, error) {
	var logs string

	rcLocalPath := "/mnt/nv/rc.local"
	targetDHCPFile := "/etc/udhcpc.d/50default"
	hookMarker := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"

	// Check if rc.local exists and read it
	currentRcLocal, rcErr := client.Run(fmt.Sprintf("cat %s", rcLocalPath))
	if rcErr != nil {
		currentRcLocal = ""
	}

	if strings.Contains(currentRcLocal, hookMarker) {
		return fmt.Sprintf("%s already contains Aftertouch hook logic\n", rcLocalPath), nil
	}

	patchStartMarker := "# --- Aftertouch DNS hook START ---"
	patchEndMarker := "# --- Aftertouch DNS hook END ---"

	patchLogic := fmt.Sprintf(`
%s
# prioritizes our custom nameserver if it exists
if [ -f "%s" ]; then
    if [ -f "%s" ] && ! grep -q "%s" "%s"; then
        logger -t "aftertouch" "Patching %s with Aftertouch DNS hook"
        sed -i '/echo "search \$domain"/a \        [ -f '"%s"' ] && cat '"%s"' && dns=""' "%s"
    fi
    targetScript="/opt/Bose/udhcpc.script"
    if [ -f "$targetScript" ] && ! grep -q "%s" "$targetScript"; then
        logger -t "aftertouch" "Patching $targetScript with Aftertouch DNS hook"
        sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \                [ -f '"%s"' ] && cat '"%s"' >> '"\$RESOLV_CONF"' && dns=""' "$targetScript"
    fi
fi
%s
`, patchStartMarker, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker, patchEndMarker)

	newRcLocal := currentRcLocal
	// Remove old-style DNS hook if it exists
	if strings.Contains(newRcLocal, "# Aftertouch DNS hook") && !strings.Contains(newRcLocal, patchStartMarker) {
		// Old removal: filter out lines between the marker and the first 'fi'
		lines := strings.Split(newRcLocal, "\n")

		var filteredLines []string

		skip := false

		for _, line := range lines {
			if strings.Contains(line, "# Aftertouch DNS hook") {
				skip = true
				continue
			}

			if skip && strings.TrimSpace(line) == "fi" {
				skip = false
				continue
			}

			if !skip {
				filteredLines = append(filteredLines, line)
			}
		}

		newRcLocal = strings.Join(filteredLines, "\n")
	}

	// Remove existing marker-based hook if it exists (for update)
	if strings.Contains(newRcLocal, patchStartMarker) {
		startIdx := strings.Index(newRcLocal, patchStartMarker)

		endIdx := strings.Index(newRcLocal, patchEndMarker)
		if startIdx != -1 && endIdx != -1 {
			newRcLocal = newRcLocal[:startIdx] + newRcLocal[endIdx+len(patchEndMarker):]
		}
	}
	// Remove "cat: can't open..." error message if it was accidentally saved in the file
	if strings.Contains(newRcLocal, "cat: can't open") {
		newRcLocal = ""
	}

	if !strings.HasPrefix(newRcLocal, "#!/bin/sh") {
		newRcLocal = "#!/bin/sh\n" + strings.TrimPrefix(newRcLocal, "#!/bin/sh")
	}

	if !strings.HasSuffix(newRcLocal, "\n") {
		newRcLocal += "\n"
	}

	newRcLocal += patchLogic

	if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil {
		return logs, fmt.Errorf("failed to update %s: %w", rcLocalPath, err)
	}

	logs += fmt.Sprintf("Updated %s with DNS hook logic\n", rcLocalPath)

	// Make it executable
	_, _ = client.Run(fmt.Sprintf("chmod +x %s", rcLocalPath))

	return logs, nil
}

func (m *Manager) patchDHCPFile(client SSHClient, targetDHCPFile, hookMarker string) (string, error) {
	var logs string

	// Backup if it doesn't exist
	if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err != nil {
		out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetDHCPFile, targetDHCPFile))
		logs += fmt.Sprintf("cp %s %s.original: %s\n", targetDHCPFile, targetDHCPFile, out)
	} else {
		// If backup exists, revert to it first to ensure we start from a clean state
		_, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetDHCPFile, targetDHCPFile))
	}

	// Run the patch logic via SSH to apply it now
	patchCmd := fmt.Sprintf("sed -i '/echo \"search \\$domain\"/a \\        [ -f '\"%s\"' ] && cat '\"%s\"' && dns=\"\"' %s", hookMarker, hookMarker, targetDHCPFile)
	if _, err := client.Run(patchCmd); err != nil {
		return logs, fmt.Errorf("failed to apply patch immediately to %s: %w", targetDHCPFile, err)
	}

	logs += fmt.Sprintf("Applied patch to %s\n", targetDHCPFile)

	// Verify patch on 50default
	if verification, err := client.Run(fmt.Sprintf("grep -q \"%s\" %s && echo \"OK\"", hookMarker, targetDHCPFile)); err == nil && strings.TrimSpace(verification) == "OK" {
		logs += fmt.Sprintf("Verified patch on %s\n", targetDHCPFile)
	} else {
		return logs, fmt.Errorf("could not verify patch on %s: %w", targetDHCPFile, err)
	}

	return logs, nil
}

func (m *Manager) patchUdhcpcScript(client SSHClient, targetScript, hookMarker string) (string, error) {
	var logs string

	// Backup if it doesn't exist
	if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err != nil {
		out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetScript, targetScript))
		logs += fmt.Sprintf("cp %s %s.original: %s\n", targetScript, targetScript, out)
	} else {
		// If backup exists, revert to it first to ensure we start from a clean state
		_, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetScript, targetScript))
	}

	patchCmdScript := fmt.Sprintf("sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a \\                [ -f '\"%s\"' ] && cat '\"%s\"' >> '\"\\$RESOLV_CONF\"' && dns=\"\"' %s", hookMarker, hookMarker, targetScript)
	if _, err := client.Run(patchCmdScript); err != nil {
		return logs, fmt.Errorf("failed to apply patch immediately to %s: %w", targetScript, err)
	}

	logs += fmt.Sprintf("Applied patch to %s\n", targetScript)

	// Verify patch on udhcpc.script
	if verification, err := client.Run(fmt.Sprintf("grep -q \"%s\" %s && echo \"OK\"", hookMarker, targetScript)); err == nil && strings.TrimSpace(verification) == "OK" {
		logs += fmt.Sprintf("Verified patch on %s\n", targetScript)
	} else {
		return logs, fmt.Errorf("could not verify patch on %s: %w", targetScript, err)
	}

	return logs, nil
}

// RevertMigration reverts the speaker to its original Bose cloud configuration.
func (m *Manager) RevertMigration(deviceIP string) (string, error) {
	client := m.NewSSH(deviceIP)

	// This function alone makes ~17 client.Run/UploadContent calls across
	// its sub-steps below. Dialing a fresh SSH connection per call (the
	// default when Connect isn't used) was confirmed on real hardware to
	// overwhelm a resource-constrained speaker; Connect+defer Close keeps
	// it to one connection for the whole revert instead.
	if err := client.Connect(); err != nil {
		return "", fmt.Errorf("failed to connect for revert: %w", err)
	}

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

	rwCmd := "(rw || mount -o remount,rw /)"

	var logs string

	// 1. Revert SoundTouchSdkPrivateCfg.xml
	out, err := m.revertXMLConfig(client, rwCmd)

	logs += out
	if err != nil {
		return logs, err
	}

	// 2. Revert /etc/hosts
	logs += m.revertHosts(client, rwCmd)

	// 2b. Revert /etc/resolv.conf
	logs += m.revertResolvConf(client, rwCmd)

	// 2c. Revert Aftertouch DNS Hook
	logs += m.revertAftertouchHook(client, rwCmd)

	// 3. Remove CA certificate from trust store if it exists
	logs += m.revertCACert(client, rwCmd)

	return logs, nil
}

func (m *Manager) revertXMLConfig(client SSHClient, rwCmd string) (string, error) {
	var logs string

	remotePath := SoundTouchSdkPrivateCfgPath
	if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil {
		logs += fmt.Sprintf("Reverting %s from backup\n", remotePath)
		fmt.Printf("Reverting %s from backup\n", remotePath)
		out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, remotePath, remotePath))

		logs += fmt.Sprintf("cp %s.original %s: %s\n", remotePath, remotePath, out)
		if err != nil {
			return logs, fmt.Errorf("failed to revert %s: %w", remotePath, err)
		}
	} else {
		return logs, fmt.Errorf("backup %s.original not found, cannot revert", remotePath)
	}

	return logs, nil
}

func (m *Manager) revertHosts(client SSHClient, rwCmd string) string {
	var logs string

	hostsPath := "/etc/hosts"
	if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", hostsPath)); err == nil {
		logs += fmt.Sprintf("Reverting %s from backup\n", hostsPath)
		fmt.Printf("Reverting %s from backup\n", hostsPath)
		out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, hostsPath, hostsPath))

		logs += fmt.Sprintf("cp %s.original %s: %s\n", hostsPath, hostsPath, out)
		if err != nil {
			fmt.Printf("Warning: failed to revert %s: %v\n", hostsPath, err)
		}
	}

	return logs
}

func (m *Manager) revertResolvConf(client SSHClient, rwCmd string) string {
	var logs string

	resolvPath := "/etc/resolv.conf"
	if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", resolvPath)); err == nil {
		logs += fmt.Sprintf("Reverting %s from backup\n", resolvPath)
		fmt.Printf("Reverting %s from backup\n", resolvPath)

		// Try to remove immutable flag if it was set
		_, _ = client.Run(fmt.Sprintf("chattr -i %s", resolvPath))

		out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, resolvPath, resolvPath))

		logs += fmt.Sprintf("cp %s.original %s: %s\n", resolvPath, resolvPath, out)
		if err != nil {
			fmt.Printf("Warning: failed to revert %s: %v\n", resolvPath, err)
		}
	}

	return logs
}

func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string {
	var logs string

	aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
	legacyConfPath := "/mnt/nv/aftertouch.resolv.conf"
	rcLocalPath := "/mnt/nv/rc.local"
	targetDHCPFile := "/etc/udhcpc.d/50default"

	for _, p := range []string{aftertouchConfPath, legacyConfPath} {
		if _, err := client.Run(fmt.Sprintf("[ -f %s ]", p)); err == nil {
			logs += fmt.Sprintf("Removing %s\n", p)
			fmt.Printf("Removing %s\n", p)
			_, _ = client.Run(fmt.Sprintf("rm %s", p))
		}
	}

	logs += m.removeRcLocalHooks(client, rcLocalPath)

	if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err == nil {
		logs += fmt.Sprintf("Reverting %s from backup\n", targetDHCPFile)
		fmt.Printf("Reverting %s from backup\n", targetDHCPFile)
		out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, targetDHCPFile, targetDHCPFile))

		logs += fmt.Sprintf("cp %s.original %s: %s\n", targetDHCPFile, targetDHCPFile, out)
		if err != nil {
			fmt.Printf("Warning: failed to revert %s: %v\n", targetDHCPFile, err)
		}
	}

	targetScript := "/opt/Bose/udhcpc.script"
	if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err == nil {
		logs += fmt.Sprintf("Reverting %s from backup\n", targetScript)
		fmt.Printf("Reverting %s from backup\n", targetScript)
		out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, targetScript, targetScript))

		logs += fmt.Sprintf("cp %s.original %s: %s\n", targetScript, targetScript, out)
		if err != nil {
			fmt.Printf("Warning: failed to revert %s: %v\n", targetScript, err)
		}
	}

	return logs
}

func (m *Manager) removeRcLocalHooks(client SSHClient, rcLocalPath string) string {
	var logs string

	patchStartMarker := "# --- Aftertouch DNS hook START ---"
	patchEndMarker := "# --- Aftertouch DNS hook END ---"
	spotifyPatchStartMarker := "# --- Aftertouch Spotify hook START ---"
	spotifyPatchEndMarker := "# --- Aftertouch Spotify hook END ---"
	aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
	legacyAftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf"

	currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath))
	if err != nil {
		return ""
	}

	// Remove "cat: can't open..." error message if it was accidentally saved in the file
	if strings.Contains(currentRcLocal, "cat: can't open") {
		logs += fmt.Sprintf("Removing corrupted %s\n", rcLocalPath)
		_, _ = client.Run(fmt.Sprintf("rm %s", rcLocalPath))

		return logs
	}

	modified := false

	if strings.Contains(currentRcLocal, patchStartMarker) {
		logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
		fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath)

		startIdx := strings.Index(currentRcLocal, patchStartMarker)
		endIdx := strings.Index(currentRcLocal, patchEndMarker)

		if startIdx != -1 && endIdx != -1 {
			currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(patchEndMarker):]
			modified = true
		}
	} else if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, legacyAftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") {
		logs += fmt.Sprintf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath)
		fmt.Printf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath)

		lines := strings.Split(currentRcLocal, "\n")

		var newLines []string

		skip := false

		for _, line := range lines {
			if strings.Contains(line, "# Aftertouch DNS hook") {
				skip = true
				continue
			}

			if skip && strings.TrimSpace(line) == "fi" {
				skip = false
				continue
			}

			if !skip {
				newLines = append(newLines, line)
			}
		}

		currentRcLocal = strings.Join(newLines, "\n")
		modified = true
	}

	if strings.Contains(currentRcLocal, spotifyPatchStartMarker) {
		logs += fmt.Sprintf("Removing Spotify hook logic from %s\n", rcLocalPath)
		fmt.Printf("Removing Spotify hook logic from %s\n", rcLocalPath)

		startIdx := strings.Index(currentRcLocal, spotifyPatchStartMarker)
		endIdx := strings.Index(currentRcLocal, spotifyPatchEndMarker)

		if startIdx != -1 && endIdx != -1 {
			currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(spotifyPatchEndMarker):]
			modified = true
		}
	}

	if modified {
		if err := client.UploadContent([]byte(currentRcLocal), rcLocalPath); err != nil {
			fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, err)
		}
	}

	return logs
}

func (m *Manager) revertCACert(client SSHClient, rwCmd string) string {
	var logs string

	bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
	if bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath)); err == nil && strings.Contains(bundleContent, CALabel) {
		logs += fmt.Sprintf("Removing local CA certificate from %s\n", bundlePath)
		fmt.Printf("Removing local CA certificate from %s\n", bundlePath)

		lines := strings.Split(bundleContent, "\n")

		var newLines []string

		inOurCA := false

		for _, line := range lines {
			if strings.Contains(line, CALabel) {
				inOurCA = !inOurCA
				continue
			}

			if !inOurCA {
				newLines = append(newLines, line)
			}
		}

		bundleContent = strings.Join(newLines, "\n")
		if bundleContent != "" && !strings.HasSuffix(bundleContent, "\n") {
			bundleContent += "\n"
		}

		out, _ := client.Run(rwCmd)

		logs += rwCmd + ": " + out + "\n"

		if err := client.UploadContent([]byte(bundleContent), bundlePath); err != nil {
			logs += "Warning: failed to remove CA from " + bundlePath + ": " + err.Error() + "\n"
			fmt.Printf("Warning: failed to remove CA from %s: %v\n", bundlePath, err)
		} else {
			logs += "Uploaded updated bundle (CA removed)\n"
		}
	}

	return logs
}

// RemoveRemoteServices removes remote services from the device by deleting the known remote_services files.
func (m *Manager) RemoveRemoteServices(deviceIP string) (string, error) {
	client := m.NewSSH(deviceIP)
	rwCmd := "(rw || mount -o remount,rw /)"

	locations := []string{
		"/etc/remote_services",
		"/mnt/nv/remote_services",
		"/tmp/remote_services",
	}

	var (
		logs   string
		errors []error
	)

	for _, loc := range locations {
		// Try to make filesystem writable and remove the file
		out, err := client.Run(fmt.Sprintf("%s && rm -v %s", rwCmd, loc))

		logs += fmt.Sprintf("Removing %s: %s\n", loc, out)
		if err != nil {
			// If rw && rm failed, try just rm (e.g. for /tmp)
			out, err = client.Run(fmt.Sprintf("rm -v %s", loc))

			logs += fmt.Sprintf("Fallback removing %s: %s\n", loc, out)
			if err != nil {
				errors = append(errors, fmt.Errorf("failed to remove %s: %w", loc, err))
			}
		}
	}

	if len(errors) == len(locations) {
		return logs, fmt.Errorf("failed to remove remote services from any location: %v", errors)
	}

	return logs, nil
}

// RebootMethod selects the transport used to reboot a speaker.
type RebootMethod string

const (
	// RebootMethodSSH reboots via SSH `reboot` (the original behavior). Requires
	// a rooted device (remote_services unlocked).
	RebootMethodSSH RebootMethod = "ssh"
	// RebootMethodTelnet reboots via the device's port-17000 diagnostic shell
	// using `sys reboot`. Requires no SSH access.
	RebootMethodTelnet RebootMethod = "telnet"
)

// Reboot reboots the speaker at the given IP using the requested transport.
// An empty method defaults to RebootMethodSSH, preserving prior behavior.
func (m *Manager) Reboot(deviceIP string, method RebootMethod) (string, error) {
	if method == "" {
		method = RebootMethodSSH
	}

	switch method {
	case RebootMethodSSH:
		return m.rebootViaSSH(deviceIP)
	case RebootMethodTelnet:
		return m.rebootViaTelnet(deviceIP)
	default:
		return "", fmt.Errorf("unsupported reboot method: %s", method)
	}
}

func (m *Manager) rebootViaSSH(deviceIP string) (string, error) {
	client := m.NewSSH(deviceIP)
	rwCmd := "(rw || mount -o remount,rw /)"

	fmt.Printf("Rebooting speaker at %s via SSH\n", deviceIP)

	out, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd))
	if err != nil {
		return out, fmt.Errorf("failed to reboot speaker: %w", err)
	}

	return out, nil
}

func (m *Manager) rebootViaTelnet(deviceIP string) (string, error) {
	if m.NewTelnet == nil {
		return "", errors.New("telnet reboot not configured: Manager.NewTelnet is nil")
	}

	fmt.Printf("Rebooting speaker at %s via telnet\n", deviceIP)

	t := m.NewTelnet(deviceIP)
	if err := t.Dial(); err != nil {
		return "", fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err)
	}

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

	// We deliberately don't wait for a response — the device closes the socket
	// as part of rebooting, and SendCommand would surface that as an error
	// even though the reboot itself succeeded. Treat any short read or close
	// as "command was accepted".
	resp, err := t.SendCommand("sys reboot")
	if err != nil {
		// A read error after the write is the expected case (socket dies on
		// reboot). Only surface real transport failures; treat the rest as
		// success and let the caller verify by polling :8090/info.
		if isLikelyRebootCloseError(err) {
			return resp + "\n[connection closed by reboot]", nil
		}

		return resp, fmt.Errorf("failed to send sys reboot: %w", err)
	}

	return resp, nil
}

// isLikelyRebootCloseError returns true if err looks like the socket closed
// because the device started rebooting, rather than a real connectivity
// problem. We are intentionally generous here: the user already opted into
// rebooting, so a closed socket is expected.
func isLikelyRebootCloseError(err error) bool {
	msg := err.Error()
	for _, marker := range []string{"EOF", "closed", "connection reset", "broken pipe", "timed out"} {
		if strings.Contains(msg, marker) {
			return true
		}
	}

	return false
}

// TestDomain is the fake domain used for preliminary redirection tests.
const TestDomain = "custom-test-api.bose.fake"

// CALabel is the label used to identify the local CA certificate in the trust store.
const CALabel = "# AfterTouch"

// TestHostsRedirection performs a preliminary check to see if /etc/hosts redirection works.
func (m *Manager) TestHostsRedirection(deviceIP, targetURL string) (string, error) {
	client := m.NewSSH(deviceIP)
	rwCmd := "(rw || mount -o remount,rw /)"

	hostIP, parsedURL, err := m.parseTargetURLAndResolveIP(targetURL, client)
	if err != nil {
		return "", err
	}

	testDomain := TestDomain
	testEntry := fmt.Sprintf("%s\t%s", hostIP, testDomain)

	if addErr := m.addTemporaryHostEntry(client, deviceIP, testDomain, testEntry, rwCmd); addErr != nil {
		return "", addErr
	}

	defer m.cleanupTemporaryHostEntry(client, testDomain, rwCmd)

	output, err := m.runHTTPRedirectionTest(client, parsedURL, testDomain)
	if err != nil {
		return output, err
	}

	httpsOutput, httpsErr := m.runHTTPSRedirectionTest(client, testDomain)

	combinedOutput := output + "\n---\n" + httpsOutput
	if httpsErr != nil {
		return combinedOutput, fmt.Errorf("hosts redirection HTTPS test failed: %w", httpsErr)
	}

	return combinedOutput, nil
}

// TestDNSRedirection performs a check from the device to see if DNS queries are intercepted by the AfterTouch service.
func (m *Manager) TestDNSRedirection(deviceIP, targetURL string) (string, error) {
	client := m.NewSSH(deviceIP)

	hostIP, _, err := m.parseTargetURLAndResolveIP(targetURL, client)
	if err != nil {
		return "", err
	}

	// Use a raw DNS query via nc (netcat) to test DNS resolution from the device,
	// because BusyBox nslookup might not support custom ports.
	testDomain := "aftertouch.test"

	// Fetch configured DNS port if available
	dnsPort := "53"

	if m.DataStore != nil {
		if dsSettings, getSettingsErr := m.DataStore.GetSettings(); getSettingsErr == nil && dsSettings.DNSBindAddr != "" {
			if lastColon := strings.LastIndex(dsSettings.DNSBindAddr, ":"); lastColon != -1 {
				port := dsSettings.DNSBindAddr[lastColon+1:]
				if _, atoiErr := strconv.Atoi(port); atoiErr == nil {
					dnsPort = port
				}
			}
		}
	}

	// Raw DNS query for aftertouch.test (Type A, Class IN)
	// Transaction ID: 0xAAAA, Flags: 0x0100 (Standard query), Questions: 1, Answer RRs: 0, Authority RRs: 0, Additional RRs: 0
	// Query: aftertouch.test, Type: A, Class: IN
	// For TCP, we need a 2-byte length prefix: 0x0021 (33 bytes)
	dnsQueryHex := "\\x00\\x21\\xaa\\xaa\\x01\\x00\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x0aaftertouch\\x04test\\x00\\x00\\x01\\x00\\x01"
	// We use TCP (default for nc) because BusyBox nc might not support -u,
	// and our DNS server listens on both TCP and UDP.
	// DNS over TCP response also has a 2-byte length prefix, but tail -c 4 will still get the IP from the end.
	ncCmd := fmt.Sprintf("echo -ne '%s' | nc -w 5 %s %s | tail -c 4 | od -An -tu1", dnsQueryHex, hostIP, dnsPort)

	output, err := client.Run(ncCmd)
	if err == nil {
		// Parse the IP from od output: " 192 168 178 122"
		fields := strings.Fields(output)
		if len(fields) == 4 {
			resolvedIP := fmt.Sprintf("%s.%s.%s.%s", fields[0], fields[1], fields[2], fields[3])
			if resolvedIP == hostIP {
				return fmt.Sprintf("Success: Raw DNS query for %s returned %s via nc to %s:%s", testDomain, resolvedIP, hostIP, dnsPort), nil
			}

			return output, fmt.Errorf("DNS redirection test failed: nc returned %s, expected %s", resolvedIP, hostIP)
		}
	}

	// Fallback to nslookup if nc fails (maybe nc is missing or it's standard port 53)
	serverAddr := hostIP
	if dnsPort != "53" {
		serverAddr = fmt.Sprintf("%s:%s", hostIP, dnsPort)
	}

	nslookupCmd := fmt.Sprintf("nslookup %s %s", testDomain, serverAddr)
	nslookupOutput, nslookupErr := client.Run(nslookupCmd)

	if nslookupErr == nil && strings.Contains(nslookupOutput, hostIP) {
		return nslookupOutput, nil
	}

	return fmt.Sprintf("nc Output: %s (err: %v)\nnslookup Output: %s (err: %v)", output, err, nslookupOutput, nslookupErr),
		fmt.Errorf("DNS redirection test failed: both nc and nslookup failed to resolve %s", testDomain)
}

func (m *Manager) parseTargetURLAndResolveIP(targetURL string, client SSHClient) (string, *url.URL, error) {
	parsedURL, err := url.Parse(targetURL)
	if err != nil {
		return "", nil, fmt.Errorf("failed to parse target URL: %w", err)
	}

	hostName := parsedURL.Hostname()
	if hostName == "" || hostName == "localhost" {
		return "", nil, fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
	}

	hostIP, err := m.resolveIP(hostName, client)
	if err != nil {
		return "", nil, fmt.Errorf("cannot resolve target hostname: %w", err)
	}

	return hostIP, parsedURL, nil
}

func (m *Manager) addTemporaryHostEntry(client SSHClient, deviceIP, testDomain, testEntry, rwCmd string) error {
	hostsContent, err := client.Run("cat /etc/hosts")
	if err != nil {
		return fmt.Errorf("failed to read /etc/hosts: %w", err)
	}

	if strings.Contains(hostsContent, testDomain) {
		lines := strings.Split(hostsContent, "\n")

		var newLines []string

		for _, line := range lines {
			if line != "" && !strings.Contains(line, testDomain) {
				newLines = append(newLines, line)
			}
		}

		hostsContent = strings.Join(newLines, "\n")
		if len(newLines) > 0 {
			hostsContent += "\n"
		}
	}

	_, _ = client.Run(rwCmd)

	if hostsContent != "" && !strings.HasSuffix(hostsContent, "\n") {
		hostsContent += "\n"
	}

	newHostsContent := hostsContent + testEntry + "\n"
	if uploadErr := client.UploadContent([]byte(newHostsContent), "/etc/hosts"); uploadErr != nil {
		return fmt.Errorf("failed to add test entry to /etc/hosts: %w", uploadErr)
	}

	fmt.Printf("Updated /etc/hosts on %s with test entry:\n%s\n", sanitizeLog(deviceIP), sanitizeLog(newHostsContent))

	return nil
}

func (m *Manager) cleanupTemporaryHostEntry(client SSHClient, testDomain, rwCmd string) {
	currentContent, _ := client.Run("cat /etc/hosts")
	lines := strings.Split(currentContent, "\n")

	var newLines []string

	for _, line := range lines {
		if line != "" && !strings.Contains(line, testDomain) {
			newLines = append(newLines, line)
		}
	}

	finalContent := strings.Join(newLines, "\n")
	if len(newLines) > 0 {
		finalContent += "\n"
	}

	_, _ = client.Run(rwCmd)
	_ = client.UploadContent([]byte(finalContent), "/etc/hosts")
}

func (m *Manager) runHTTPRedirectionTest(client SSHClient, parsedURL *url.URL, testDomain string) (string, error) {
	httpTestURL := fmt.Sprintf("http://%s:%s/health", testDomain, parsedURL.Port())
	if parsedURL.Port() == "" || parsedURL.Port() == "80" {
		httpTestURL = fmt.Sprintf("http://%s/health", testDomain)
	}

	cmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L %s", httpTestURL)

	output, err := client.Run(cmd)
	if err != nil {
		return output, fmt.Errorf("hosts redirection HTTP test failed: %w", err)
	}

	return output, nil
}

func (m *Manager) runHTTPSRedirectionTest(client SSHClient, testDomain string) (string, error) {
	httpsPort := os.Getenv("HTTPS_PORT")
	if httpsPort == "" {
		httpsPort = "8443"
	}

	httpsTestURL := fmt.Sprintf("https://%s:%s/health", testDomain, httpsPort)
	if httpsPort == "443" {
		httpsTestURL = fmt.Sprintf("https://%s/health", testDomain)
	}

	caPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
	if err != nil {
		return "", fmt.Errorf("failed to read CA cert for HTTPS test: %w", err)
	}

	caPath := "/tmp/soundtouch-test-ca.crt"
	if err := client.UploadContent(caPEM, caPath); err != nil {
		return "", fmt.Errorf("failed to upload temporary CA for HTTPS test: %w", err)
	}

	defer func() {
		_, _ = client.Run("rm " + caPath)
	}()

	httpsCmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L --cacert %s %s", caPath, httpsTestURL)

	return client.Run(httpsCmd)
}

// TestConnection performs a connection check from the device to the server.
func (m *Manager) TestConnection(deviceIP, targetURL string, useExplicitCA bool) (string, error) {
	client := m.NewSSH(deviceIP)

	caPath := ""

	if useExplicitCA {
		// Temporary upload CA to device
		caPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
		if err != nil {
			return "", fmt.Errorf("failed to read CA cert: %w", err)
		}

		caPath = "/tmp/soundtouch-test-ca.crt"
		if err := client.UploadContent(caPEM, caPath); err != nil {
			return "", fmt.Errorf("failed to upload temporary CA: %w", err)
		}

		defer func() {
			_, _ = client.Run("rm " + caPath)
		}()
	}

	cmd := fmt.Sprintf("curl --max-time 15 --connect-timeout 10 -v -s -L %s", targetURL)
	if useExplicitCA {
		cmd += " --cacert " + caPath
	}

	output, err := client.Run(cmd)
	if err != nil {
		return output, fmt.Errorf("connection test failed: %w", err)
	}

	return output, nil
}

// GetResolvedIP returns the resolved IP for a hostname, attempting to resolve it from any connected device first.
func (m *Manager) GetResolvedIP(host string) string {
	ip, _ := m.resolveIP(host, nil)
	return ip
}

// ErrResolvedFromServiceOnly is returned (wrapped) by resolveIP when the
// service-side DNS fallback produced an IP but the device-side SSH ping
// either wasn't attempted or didn't yield a usable result. The error
// carries the resolved IP — callers that don't need an authoritative
// device-side answer (preview/summary builders) can errors.Is()-check
// and treat the IP as informational. Apply-path callers that DO need
// authoritative resolution can bail.
var ErrResolvedFromServiceOnly = errors.New("resolved from service, not from device")

// resolveIP resolves a hostname to an IP address.
// It first tries to resolve from the device via SSH ping (authoritative for migration).
// If that fails, it falls back to resolving from the service itself, returning the
// resolved IP wrapped with ErrResolvedFromServiceOnly so callers can distinguish
// "authoritative device-side answer" from "best-effort service-side fallback".
func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
	if net.ParseIP(host) != nil {
		return host, nil
	}

	// 1. Try resolving FROM the device via SSH (authoritative: gives the IP the device will actually use)
	if client != nil {
		// Use ping to resolve hostname on the device.
		// Busybox ping output usually looks like: PING host (1.2.3.4): 56 data bytes
		output, err := client.Run(fmt.Sprintf("ping -c 1 %s", host))
		if err == nil {
			// Extract IP from parentheses: (1.2.3.4)
			start := strings.Index(output, "(")

			end := strings.Index(output, ")")
			if start != -1 && end > start {
				ip := output[start+1 : end]
				if net.ParseIP(ip) != nil {
					fmt.Printf("Resolved %s to %s from device\n", sanitizeLog(host), sanitizeLog(ip))
					return ip, nil
				}
			}
		}
	}

	// 2. Fallback: resolve FROM the service itself (unreliable for migration — NAT/split-DNS may differ)
	ips, err := net.LookupIP(host)
	if err != nil || len(ips) == 0 {
		return "", fmt.Errorf("cannot resolve %q: SSH ping from device failed and service-side DNS lookup also failed", host)
	}

	// Prefer IPv4
	var resolved string

	for _, ip := range ips {
		if ip.To4() != nil {
			resolved = ip.String()
			break
		}
	}

	if resolved == "" {
		resolved = ips[0].String()
	}

	return resolved, fmt.Errorf("%w: %q → %s (NAT or split-DNS may differ from what the device would see)",
		ErrResolvedFromServiceOnly, host, resolved)
}

// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
func (m *Manager) SyncDeviceData(deviceIP string) error {
	// 1. Fetch info to get Serial Number (account identifier)
	info, err := m.GetLiveDeviceInfo(deviceIP)
	if err != nil {
		return fmt.Errorf("failed to get device info: %w", err)
	}

	log.Printf("Starting sync for device at %s: Name='%s', DeviceID='%s', SerialNumber='%s'",
		sanitizeLog(deviceIP), sanitizeLog(info.Name), sanitizeLog(info.DeviceID), sanitizeLog(info.SerialNumber))

	accountID := ""

	// Use deviceID from /info as canonical identifier (MAC address)
	deviceID := info.DeviceID
	if deviceID == "" {
		log.Printf("No deviceID found in /info response for device '%s' at %s", sanitizeLog(info.Name), sanitizeLog(deviceIP))
		return fmt.Errorf("no deviceID found in /info response for device at %s - cannot sync without canonical device identifier", deviceIP)
	}

	log.Printf("Using deviceID '%s' for sync operations (MAC address from /info)", sanitizeLog(deviceID))

	if info.MargeAccountUUID != "" {
		accountID = info.MargeAccountUUID
	}

	if accountID == "" {
		// Try to find account ID from existing device entries if info didn't have it
		devices, _ := m.DataStore.ListAllDevices()
		for i := range devices {
			if devices[i].DeviceSerialNumber == info.SerialNumber || devices[i].DeviceID == info.DeviceID {
				accountID = devices[i].AccountID
				break
			}
		}
	}

	if accountID == "" {
		accountID = "default"
	}

	// 2. Fetch Presets from :8090
	m.syncPresets(deviceIP, accountID, deviceID)

	// 3. Fetch Recents from :8090
	m.syncRecents(deviceIP, accountID, deviceID)

	// 4. Fetch Sources
	m.syncSources(deviceIP, accountID, deviceID)

	// 5. Nudge the device to re-render its source list. After a factory
	// reset (issue #234) the speaker's /sources only lists the always-on
	// local entries until it receives a <sourcesUpdated/> notification;
	// the reporter's workaround was to POST this by hand. Wiring it into
	// the sync flow means the user gets the visible recovery for free
	// after they click Data Sync — re-pairing (which the wizard already
	// detects + prompts for) is the orthogonal half of the fix.
	m.notifySpeakerSourcesUpdated(deviceIP, deviceID)

	// 6. Create off-device backup of system configuration
	_ = m.BackupConfigOffDevice(deviceIP)

	return nil
}

func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
	presetsURL := fmt.Sprintf("http://%s:8090/presets", deviceIP)
	if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
		presetsURL = fmt.Sprintf("http://%s/presets", deviceIP)
	}

	log.Printf("[SYNC] Syncing presets for %s", sanitizeLog(deviceIP))

	resp, err := m.HTTPGet(presetsURL)
	if err != nil {
		log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), err)
		return
	}

	defer func() { _ = resp.Body.Close() }()

	var ps models.Presets
	if decodeErr := xml.NewDecoder(resp.Body).Decode(&ps); decodeErr != nil {
		return
	}

	var servicePresets []models.ServicePreset

	for _, p := range ps.Preset {
		// IsEmpty catches both placeholder shapes a SoundTouch device
		// can emit: self-closing <preset/> (issue #308) and
		// <ContentItem source="INVALID_SOURCE"/>. Neither carries
		// real playable data and persisting them would surface as
		// junk entries in the admin web UI.
		if p.IsEmpty() {
			continue
		}

		createdOn := ""
		if p.CreatedOn != nil {
			createdOn = strconv.FormatInt(*p.CreatedOn, 10)
		}

		updatedOn := ""
		if p.UpdatedOn != nil {
			updatedOn = strconv.FormatInt(*p.UpdatedOn, 10)
		}

		servicePresets = append(servicePresets, models.ServicePreset{
			ServiceContentItem: models.ServiceContentItem{
				ID:            strconv.Itoa(p.ID),
				Name:          p.ContentItem.ItemName,
				Source:        p.ContentItem.Source,
				Type:          p.ContentItem.Type,
				Location:      p.ContentItem.Location,
				SourceAccount: p.ContentItem.SourceAccount,
				SourceID:      "", // Preset doesn't have SourceID in ContentItem usually
				IsPresetable:  strconv.FormatBool(p.ContentItem.IsPresetable),
			},
			ID:           strconv.Itoa(p.ID),
			ButtonNumber: strconv.Itoa(p.ID),
			ContainerArt: p.ContentItem.ContainerArt,
			CreatedOn:    createdOn,
			UpdatedOn:    updatedOn,
		})
	}

	_ = m.DataStore.SavePresets(accountID, deviceID, servicePresets)
}

func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
	recentsURL := fmt.Sprintf("http://%s:8090/recents", deviceIP)
	if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
		recentsURL = fmt.Sprintf("http://%s/recents", deviceIP)
	}

	resp, err := m.HTTPGet(recentsURL)
	if err != nil {
		return
	}

	defer func() { _ = resp.Body.Close() }()

	var rr models.RecentsResponse
	if decodeErr := xml.NewDecoder(resp.Body).Decode(&rr); decodeErr != nil {
		return
	}

	var serviceRecents []models.ServiceRecent

	for _, r := range rr.Items {
		if r.ContentItem == nil {
			continue
		}

		serviceRecents = append(serviceRecents, models.ServiceRecent{
			ServiceContentItem: models.ServiceContentItem{
				ID:            r.ID,
				Name:          r.ContentItem.ItemName,
				Source:        r.ContentItem.Source,
				Type:          r.ContentItem.Type,
				Location:      r.ContentItem.Location,
				SourceAccount: r.ContentItem.SourceAccount,
				SourceID:      "", // RecentsResponseItem doesn't have SourceID usually
				IsPresetable:  strconv.FormatBool(r.ContentItem.IsPresetable),
				ContainerArt:  r.ContentItem.ContainerArt,
			},
			DeviceID: r.DeviceID,
			UtcTime:  strconv.FormatInt(r.UTCTime, 10),
		})
	}

	_ = m.DataStore.SaveRecents(accountID, deviceID, serviceRecents)
}

func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
	client := m.NewSSH(deviceIP)

	sourcesXML, err := client.Run("cat /mnt/nv/BoseApp-Persistence/1/Sources.xml")
	if err == nil && sourcesXML != "" {
		var srs struct {
			Sources []models.ConfiguredSource `xml:"source"`
		}
		if xmlErr := xml.Unmarshal([]byte(sourcesXML), &srs); xmlErr == nil {
			// After unmarshaling from SSH, ensure legacy fields are synced for internal use
			for i := range srs.Sources {
				s := &srs.Sources[i]
				s.SourceKeyType = s.SourceKey.Type
				s.SourceKeyAccount = s.SourceKey.Account
			}

			// Drop device-local/transient sources without a resolvable
			// sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP).
			// Persisting them causes /full to emit an empty
			// <sourceproviderid> which the speaker rejects as INVALID_SOURCE
			// (#334).
			srs.Sources = filterServableSources(srs.Sources, deviceID)

			_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, srs.Sources)

			return
		}
	}

	// Fallback to :8090/sources
	sourcesURL := fmt.Sprintf("http://%s:8090/sources", deviceIP)
	if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
		sourcesURL = fmt.Sprintf("http://%s/sources", deviceIP)
	}

	resp, err := m.HTTPGet(sourcesURL)
	if err != nil {
		return
	}

	defer func() { _ = resp.Body.Close() }()

	var srs models.Sources
	if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr == nil {
		var configuredSources []models.ConfiguredSource

		for _, s := range srs.SourceItem {
			cs := models.ConfiguredSource{
				DisplayName: s.DisplayName,
				Secret:      "",
				SecretType:  "",
			}
			if s.Status == "READY" {
				cs.SecretType = "token"
			}

			if s.Source == constants.ProviderSpotify {
				cs.SecretType = "token_version_3"
			}

			cs.SourceKey.Type = s.Source
			cs.SourceKey.Account = s.SourceAccount
			// Also set legacy fields for now
			cs.SourceKeyType = s.Source
			cs.SourceKeyAccount = s.SourceAccount

			configuredSources = append(configuredSources, cs)
		}

		// Drop device-local/transient sources without a resolvable
		// sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP).
		// Persisting them causes /full to emit an empty <sourceproviderid>
		// which the speaker rejects as INVALID_SOURCE (#334).
		configuredSources = filterServableSources(configuredSources, deviceID)

		_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
	}
}

// filterServableSources returns a copy of srcs containing only sources that
// have a resolvable sourceproviderid. Device-local / transient slots without
// one (STORED_MUSIC_MEDIA_RENDERER, UPNP, INVALID_SOURCE, …) are silently
// dropped and logged as a single summary line.
func filterServableSources(srcs []models.ConfiguredSource, deviceID string) []models.ConfiguredSource {
	var servable []models.ConfiguredSource

	var dropped []string

	for i := range srcs {
		if marge.HasResolvableProviderID(srcs[i]) {
			servable = append(servable, srcs[i])
		} else {
			dropped = append(dropped, sanitizeLog(srcs[i].SourceKeyType))
		}
	}

	if len(dropped) > 0 {
		log.Printf("[SYNC] device %s: dropping %d unresolvable source(s) on import (types: %v)",
			sanitizeLog(deviceID), len(dropped), dropped)
	}

	return servable
}

// notifySpeakerSourcesUpdated POSTs the <sourcesUpdated/> notification
// to /notification on the device, mirroring the manual workaround
// documented in issue #234. The device responds by re-evaluating its
// /sources catalogue — after a factory reset that's what makes TUNEIN /
// LOCAL_INTERNET_RADIO / DEEZER / linked Spotify accounts reappear in
// the list. The wizard's pair-account flow restores playback (it
// recreates the Marge.xml token); this nudge restores the *visible*
// source list. Both are needed for a full #234 recovery; this is the
// half AfterTouch can automate without user input.
//
// Delegates the HTTP plumbing to pkg/client.Client.NotifySourcesUpdated,
// which is the same path handlers_mgmt.go uses after music-service
// account changes — keeping the wire-shape definition in one place
// (pkg/models.NewSourcesUpdatedNotification).
//
// Fire-and-forget: a network failure (or the device returning an
// unexpected response) doesn't fail the surrounding sync. The sync's
// persisted state is already on disk by the time we fire the
// notification; whether the device acts on it is observable on the
// next sync.
func (m *Manager) notifySpeakerSourcesUpdated(deviceIP, deviceID string) {
	c := client.NewClientFromHost(deviceIP)
	if err := c.NotifySourcesUpdated(deviceID); err != nil {
		log.Printf("[SYNC] notify %s: %v", sanitizeLog(deviceIP), err)
		return
	}

	log.Printf("[SYNC] notify %s sourcesUpdated -> ok", sanitizeLog(deviceIP))
}
