package config

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"
)

type Config struct {
	Audio   AudioConfig   `json:"audio"`
	RDS     RDSConfig     `json:"rds"`
	FM      FMConfig      `json:"fm"`
	Backend BackendConfig `json:"backend"`
	Control ControlConfig `json:"control"`
	Runtime RuntimeConfig `json:"runtime"`
	Ingest  IngestConfig  `json:"ingest"`
}

type AudioConfig struct {
	InputPath     string  `json:"inputPath"`
	Gain          float64 `json:"gain"`
	ToneLeftHz    float64 `json:"toneLeftHz"`
	ToneRightHz   float64 `json:"toneRightHz"`
	ToneAmplitude float64 `json:"toneAmplitude"`
}

type RDSConfig struct {
	Enabled   bool   `json:"enabled"`
	PI        string `json:"pi"`
	PS        string `json:"ps"`
	RadioText string `json:"radioText"`
	PTY       int    `json:"pty"`

	// Traffic
	TP bool `json:"tp"` // Traffic Program — station carries traffic info
	TA bool `json:"ta"` // Traffic Announcement — currently on air

	// Music/Speech & Decoder Info
	MS bool  `json:"ms"` // true=music, false=speech
	DI uint8 `json:"di"` // Decoder Info: bit0=stereo, bit1=artificial head, bit2=compressed, bit3=dynamic PTY

	// Alternative Frequencies (MHz, e.g. [93.3, 95.7])
	AF []float64 `json:"af,omitempty"`

	// Clock-Time (Group 4A)
	CTEnabled         bool `json:"ctEnabled"`
	CTOffsetHalfHours int8 `json:"ctOffsetHalfHours,omitempty"` // 0 = auto from OS

	// Program Type Name (Group 10A) — 8-char custom label
	PTYN string `json:"ptyn,omitempty"`

	// Long Programme Service name (Group 15A) — up to 32 bytes UTF-8.
	// Static station name, complements PS. Receivers may display instead of PS.
	LPS string `json:"lps,omitempty"`

	// RT+ (Groups 3A + 11A) — auto-parse artist/title from RadioText
	RTPlusEnabled   bool   `json:"rtPlusEnabled"`
	RTPlusSeparator string `json:"rtPlusSeparator,omitempty"` // default " - "

	// eRT — Enhanced RadioText (ODA, UTF-8, 128 bytes)
	ERTEnabled bool   `json:"ertEnabled"`
	ERT        string `json:"ert,omitempty"`

	// RDS2 — additional subcarriers
	RDS2Enabled     bool   `json:"rds2Enabled"`
	StationLogoPath string `json:"stationLogoPath,omitempty"`

	// EON — Enhanced Other Networks (Group 14A)
	EON []EONEntryConfig `json:"eon,omitempty"`
}

// EONEntryConfig describes another station for EON transmission.
type EONEntryConfig struct {
	PI  string    `json:"pi"` // hex PI code
	PS  string    `json:"ps"` // 8-char station name
	PTY int       `json:"pty"`
	TP  bool      `json:"tp"`
	TA  bool      `json:"ta"`
	AF  []float64 `json:"af,omitempty"`
}

type FMConfig struct {
	FrequencyMHz        float64                `json:"frequencyMHz"`
	StereoEnabled       bool                   `json:"stereoEnabled"`
	StereoMode          string                 `json:"stereoMode"`       // "DSB" (standard), "SSB" (experimental LSB), "VSB" (vestigial)
	PilotLevel          float64                `json:"pilotLevel"`       // fraction of ±75kHz deviation (0.09 = 9%, ITU standard)
	RDSInjection        float64                `json:"rdsInjection"`     // fraction of ±75kHz deviation (0.04 = 4%, typical)
	PreEmphasisTauUS    float64                `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off
	OutputDrive         float64                `json:"outputDrive"`
	CompositeRateHz     int                    `json:"compositeRateHz"` // internal DSP/MPX sample rate
	MaxDeviationHz      float64                `json:"maxDeviationHz"`
	LimiterEnabled      bool                   `json:"limiterEnabled"`
	LimiterCeiling      float64                `json:"limiterCeiling"`
	FMModulationEnabled bool                   `json:"fmModulationEnabled"`
	WatermarkEnabled    bool                   `json:"watermarkEnabled"`  // explicit opt-in for STFT program-audio watermarking
	MpxGain             float64                `json:"mpxGain"`           // hardware calibration: scales entire composite output (default 1.0)
	BS412Enabled        bool                   `json:"bs412Enabled"`      // ITU-R BS.412 MPX power limiter (EU requirement)
	BS412ThresholdDBr   float64                `json:"bs412ThresholdDBr"` // power limit in dBr (0 = standard, +3 = relaxed)
	CompositeClipper    CompositeClipperConfig `json:"compositeClipper"`  // ITU-R SM.1268 iterative composite clipper
}

// CompositeClipperConfig controls the broadcast-grade composite MPX clipper.
// When enabled, replaces the simple clip→notch chain with an iterative
// clip-filter-clip processor, optionally with soft-knee clipping and
// look-ahead peak limiting.
type CompositeClipperConfig struct {
	Enabled     bool    `json:"enabled"`     // false = legacy single-pass clip
	Iterations  int     `json:"iterations"`  // clip-filter-clip passes (1-5, default 3)
	SoftKnee    float64 `json:"softKnee"`    // soft-clip transition zone (0=hard, 0.15=moderate, 0.3=gentle)
	LookaheadMs float64 `json:"lookaheadMs"` // look-ahead delay (0=off, 1.0=typical)
}

type BackendConfig struct {
	Kind               string            `json:"kind"`
	Driver             string            `json:"driver,omitempty"`
	Device             string            `json:"device"`
	URI                string            `json:"uri,omitempty"`
	DeviceArgs         map[string]string `json:"deviceArgs,omitempty"`
	OutputPath         string            `json:"outputPath"`
	DeviceSampleRateHz float64           `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz
}

type ControlConfig struct {
	ListenAddress string `json:"listenAddress"`
}

type RuntimeConfig struct {
	FrameQueueCapacity int `json:"frameQueueCapacity"`
}

type IngestConfig struct {
	Kind           string                `json:"kind"`
	PrebufferMs    int                   `json:"prebufferMs"`
	StallTimeoutMs int                   `json:"stallTimeoutMs"`
	Reconnect      IngestReconnectConfig `json:"reconnect"`
	Stdin          IngestPCMConfig       `json:"stdin"`
	HTTPRaw        IngestPCMConfig       `json:"httpRaw"`
	Icecast        IngestIcecastConfig   `json:"icecast"`
	SRT            IngestSRTConfig       `json:"srt"`
	AES67          IngestAES67Config     `json:"aes67"`
}

type IngestReconnectConfig struct {
	Enabled          bool `json:"enabled"`
	InitialBackoffMs int  `json:"initialBackoffMs"`
	MaxBackoffMs     int  `json:"maxBackoffMs"`
}

type IngestPCMConfig struct {
	SampleRateHz int    `json:"sampleRateHz"`
	Channels     int    `json:"channels"`
	Format       string `json:"format"`
}

type IngestIcecastConfig struct {
	URL       string                       `json:"url"`
	Decoder   string                       `json:"decoder"`
	RadioText IngestIcecastRadioTextConfig `json:"radioText"`
}

type IngestIcecastRadioTextConfig struct {
	Enabled      bool   `json:"enabled"`
	Prefix       string `json:"prefix"`
	MaxLen       int    `json:"maxLen"`
	OnlyOnChange bool   `json:"onlyOnChange"`
}

type IngestSRTConfig struct {
	URL          string `json:"url"`
	Mode         string `json:"mode"`
	SampleRateHz int    `json:"sampleRateHz"`
	Channels     int    `json:"channels"`
}

type IngestAES67Config struct {
	SDPPath            string                     `json:"sdpPath"`
	SDP                string                     `json:"sdp"`
	Discovery          IngestAES67DiscoveryConfig `json:"discovery"`
	MulticastGroup     string                     `json:"multicastGroup"`
	Port               int                        `json:"port"`
	InterfaceName      string                     `json:"interfaceName"`
	PayloadType        int                        `json:"payloadType"`
	SampleRateHz       int                        `json:"sampleRateHz"`
	Channels           int                        `json:"channels"`
	Encoding           string                     `json:"encoding"`
	PacketTimeMs       int                        `json:"packetTimeMs"`
	JitterDepthPackets int                        `json:"jitterDepthPackets"`
	ReadBufferBytes    int                        `json:"readBufferBytes"`
}

type IngestAES67DiscoveryConfig struct {
	Enabled       bool   `json:"enabled"`
	StreamName    string `json:"streamName"`
	TimeoutMs     int    `json:"timeoutMs"`
	InterfaceName string `json:"interfaceName"`
	SAPGroup      string `json:"sapGroup"`
	SAPPort       int    `json:"sapPort"`
}

func Default() Config {
	return Config{
		// BUG-C fix: tones off by default (was 0.4 — caused unintended audio output).
		Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0},
		RDS: RDSConfig{
			Enabled:         true,
			PI:              "1234",
			PS:              "FMRTX",
			RadioText:       "fm-rds-tx",
			PTY:             0,
			MS:              true,
			DI:              0x01, // stereo
			CTEnabled:       true,
			RTPlusEnabled:   true,
			RTPlusSeparator: " - ",
		},
		FM: FMConfig{
			FrequencyMHz:        100.0,
			StereoEnabled:       true,
			StereoMode:          "DSB",
			PilotLevel:          0.09,
			RDSInjection:        0.04,
			PreEmphasisTauUS:    50,
			OutputDrive:         0.5,
			CompositeRateHz:     228000,
			MaxDeviationHz:      75000,
			LimiterEnabled:      true,
			LimiterCeiling:      1.0,
			FMModulationEnabled: true,
			MpxGain:             1.0,
			CompositeClipper: CompositeClipperConfig{
				Enabled:     false,
				Iterations:  3,
				SoftKnee:    0.15,
				LookaheadMs: 1.0,
			},
		},
		Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"},
		Control: ControlConfig{ListenAddress: "127.0.0.1:8088"},
		Runtime: RuntimeConfig{FrameQueueCapacity: 3},
		Ingest: IngestConfig{
			Kind:           "none",
			PrebufferMs:    1500,
			StallTimeoutMs: 3000,
			Reconnect: IngestReconnectConfig{
				Enabled:          true,
				InitialBackoffMs: 1000,
				MaxBackoffMs:     15000,
			},
			Stdin: IngestPCMConfig{
				SampleRateHz: 44100,
				Channels:     2,
				Format:       "s16le",
			},
			HTTPRaw: IngestPCMConfig{
				SampleRateHz: 44100,
				Channels:     2,
				Format:       "s16le",
			},
			Icecast: IngestIcecastConfig{
				Decoder: "auto",
				RadioText: IngestIcecastRadioTextConfig{
					Enabled:      false,
					MaxLen:       64,
					OnlyOnChange: true,
				},
			},
			SRT: IngestSRTConfig{
				Mode:         "listener",
				SampleRateHz: 48000,
				Channels:     2,
			},
			AES67: IngestAES67Config{
				Discovery: IngestAES67DiscoveryConfig{
					TimeoutMs: 3000,
				},
				PayloadType:        97,
				SampleRateHz:       48000,
				Channels:           2,
				Encoding:           "L24",
				PacketTimeMs:       1,
				JitterDepthPackets: 8,
				ReadBufferBytes:    1 << 20,
			},
		},
	}
}

// ParsePI parses a hex PI code string. Returns an error for invalid input.
func ParsePI(pi string) (uint16, error) {
	trimmed := strings.TrimSpace(pi)
	if trimmed == "" {
		return 0, fmt.Errorf("rds.pi is required")
	}
	trimmed = strings.TrimPrefix(trimmed, "0x")
	trimmed = strings.TrimPrefix(trimmed, "0X")
	v, err := strconv.ParseUint(trimmed, 16, 16)
	if err != nil {
		return 0, fmt.Errorf("invalid rds.pi: %q", pi)
	}
	return uint16(v), nil
}

func Load(path string) (Config, error) {
	cfg := Default()
	if path == "" {
		return cfg, cfg.Validate()
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return Config{}, err
	}
	if err := json.Unmarshal(data, &cfg); err != nil {
		return Config{}, err
	}
	return cfg, cfg.Validate()
}

func Save(path string, cfg Config) error {
	if strings.TrimSpace(path) == "" {
		return fmt.Errorf("config path is required")
	}
	if err := cfg.Validate(); err != nil {
		return err
	}
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	// NEW-1 fix: write to a temp file in the same directory, then rename atomically.
	// A direct os.WriteFile on the target leaves a corrupt file if the process
	// crashes mid-write. os.Rename is atomic on POSIX filesystems.
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, ".fmrtx-config-*.json.tmp")
	if err != nil {
		return fmt.Errorf("config save: create temp: %w", err)
	}
	tmpPath := tmp.Name()
	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		_ = os.Remove(tmpPath)
		return fmt.Errorf("config save: write temp: %w", err)
	}
	if err := tmp.Sync(); err != nil {
		_ = tmp.Close()
		_ = os.Remove(tmpPath)
		return fmt.Errorf("config save: sync temp: %w", err)
	}
	if err := tmp.Close(); err != nil {
		_ = os.Remove(tmpPath)
		return fmt.Errorf("config save: close temp: %w", err)
	}
	if err := os.Rename(tmpPath, path); err != nil {
		_ = os.Remove(tmpPath)
		return fmt.Errorf("config save: rename: %w", err)
	}
	return nil
}

func (c Config) Validate() error {
	if c.Audio.Gain < 0 || c.Audio.Gain > 4 {
		return fmt.Errorf("audio.gain out of range")
	}
	// BUG-B fix: only enforce positive freq when amplitude is non-zero.
	if c.Audio.ToneAmplitude > 0 && (c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0) {
		return fmt.Errorf("audio tone frequencies must be positive when toneAmplitude > 0")
	}
	if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 {
		return fmt.Errorf("audio.toneAmplitude out of range")
	}
	if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 {
		return fmt.Errorf("fm.frequencyMHz out of range")
	}
	if c.FM.PilotLevel < 0 || c.FM.PilotLevel > 0.2 {
		return fmt.Errorf("fm.pilotLevel out of range")
	}
	if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 {
		return fmt.Errorf("fm.rdsInjection out of range")
	}
	if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 10 {
		return fmt.Errorf("fm.outputDrive out of range (0..10)")
	}
	if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 {
		return fmt.Errorf("fm.compositeRateHz out of range")
	}
	if c.FM.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 {
		return fmt.Errorf("fm.preEmphasisTauUS out of range (0=off, 50=EU, 75=US)")
	}
	switch mode := strings.ToUpper(strings.TrimSpace(c.FM.StereoMode)); mode {
	case "DSB", "SSB", "VSB":
	default:
		return fmt.Errorf("fm.stereoMode invalid: %q (expected DSB, SSB, or VSB)", c.FM.StereoMode)
	}
	if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 {
		return fmt.Errorf("fm.maxDeviationHz out of range")
	}
	if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 {
		return fmt.Errorf("fm.limiterCeiling out of range")
	}
	if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 {
		return fmt.Errorf("fm.mpxGain out of range (0.1..5)")
	}
	if c.FM.CompositeClipper.Enabled {
		if c.FM.CompositeClipper.Iterations < 1 || c.FM.CompositeClipper.Iterations > 5 {
			return fmt.Errorf("fm.compositeClipper.iterations out of range (1-5)")
		}
		if c.FM.CompositeClipper.SoftKnee < 0 || c.FM.CompositeClipper.SoftKnee > 0.5 {
			return fmt.Errorf("fm.compositeClipper.softKnee out of range (0-0.5)")
		}
		if c.FM.CompositeClipper.LookaheadMs < 0 || c.FM.CompositeClipper.LookaheadMs > 5 {
			return fmt.Errorf("fm.compositeClipper.lookaheadMs out of range (0-5)")
		}
	}
	if c.Backend.Kind == "" {
		return fmt.Errorf("backend.kind is required")
	}
	if c.Backend.DeviceSampleRateHz < 0 {
		return fmt.Errorf("backend.deviceSampleRateHz must be >= 0")
	}
	if c.Control.ListenAddress == "" {
		return fmt.Errorf("control.listenAddress is required")
	}
	if c.Runtime.FrameQueueCapacity <= 0 {
		return fmt.Errorf("runtime.frameQueueCapacity must be > 0")
	}
	if c.Ingest.Kind == "" {
		c.Ingest.Kind = "none"
	}
	ingestKind := strings.ToLower(strings.TrimSpace(c.Ingest.Kind))
	switch ingestKind {
	case "none", "stdin", "stdin-pcm", "http-raw", "icecast", "srt", "aes67", "aoip", "aoip-rtp":
	default:
		return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind)
	}
	if c.Ingest.PrebufferMs < 0 {
		return fmt.Errorf("ingest.prebufferMs must be >= 0")
	}
	if c.Ingest.StallTimeoutMs < 0 {
		return fmt.Errorf("ingest.stallTimeoutMs must be >= 0")
	}
	if c.Ingest.Reconnect.InitialBackoffMs < 0 || c.Ingest.Reconnect.MaxBackoffMs < 0 {
		return fmt.Errorf("ingest.reconnect backoff must be >= 0")
	}
	if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.InitialBackoffMs <= 0 {
		return fmt.Errorf("ingest.reconnect.initialBackoffMs must be > 0 when reconnect is enabled")
	}
	if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.MaxBackoffMs <= 0 {
		return fmt.Errorf("ingest.reconnect.maxBackoffMs must be > 0 when reconnect is enabled")
	}
	if c.Ingest.Reconnect.MaxBackoffMs > 0 && c.Ingest.Reconnect.InitialBackoffMs > c.Ingest.Reconnect.MaxBackoffMs {
		return fmt.Errorf("ingest.reconnect.initialBackoffMs must be <= maxBackoffMs")
	}
	if c.Ingest.Stdin.SampleRateHz <= 0 || c.Ingest.HTTPRaw.SampleRateHz <= 0 {
		return fmt.Errorf("ingest pcm sampleRateHz must be > 0")
	}
	if (c.Ingest.Stdin.Channels != 1 && c.Ingest.Stdin.Channels != 2) || (c.Ingest.HTTPRaw.Channels != 1 && c.Ingest.HTTPRaw.Channels != 2) {
		return fmt.Errorf("ingest pcm channels must be 1 or 2")
	}
	if strings.ToLower(strings.TrimSpace(c.Ingest.Stdin.Format)) != "s16le" || strings.ToLower(strings.TrimSpace(c.Ingest.HTTPRaw.Format)) != "s16le" {
		return fmt.Errorf("ingest pcm format must be s16le")
	}
	if ingestKind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" {
		return fmt.Errorf("ingest.icecast.url is required when ingest.kind=icecast")
	}
	if ingestKind == "srt" && strings.TrimSpace(c.Ingest.SRT.URL) == "" {
		return fmt.Errorf("ingest.srt.url is required when ingest.kind=srt")
	}
	if ingestKind == "aes67" || ingestKind == "aoip" || ingestKind == "aoip-rtp" {
		hasSDP := strings.TrimSpace(c.Ingest.AES67.SDP) != ""
		hasSDPPath := strings.TrimSpace(c.Ingest.AES67.SDPPath) != ""
		discoveryEnabled := c.Ingest.AES67.Discovery.Enabled || strings.TrimSpace(c.Ingest.AES67.Discovery.StreamName) != ""
		if hasSDP && hasSDPPath {
			return fmt.Errorf("ingest.aes67.sdp and ingest.aes67.sdpPath are mutually exclusive")
		}
		if !hasSDP && !hasSDPPath {
			if strings.TrimSpace(c.Ingest.AES67.MulticastGroup) == "" && !discoveryEnabled {
				return fmt.Errorf("ingest.aes67.multicastGroup is required when ingest.kind=%s", ingestKind)
			}
			if (c.Ingest.AES67.Port <= 0 || c.Ingest.AES67.Port > 65535) && !discoveryEnabled {
				return fmt.Errorf("ingest.aes67.port must be 1..65535")
			}
		}
		if c.Ingest.AES67.Discovery.TimeoutMs < 0 {
			return fmt.Errorf("ingest.aes67.discovery.timeoutMs must be >= 0")
		}
		if c.Ingest.AES67.Discovery.SAPPort < 0 || c.Ingest.AES67.Discovery.SAPPort > 65535 {
			return fmt.Errorf("ingest.aes67.discovery.sapPort must be 0..65535")
		}
		if discoveryEnabled && strings.TrimSpace(c.Ingest.AES67.Discovery.StreamName) == "" {
			return fmt.Errorf("ingest.aes67.discovery.streamName is required when discovery is enabled")
		}
		if discoveryEnabled && c.Ingest.AES67.Port > 65535 {
			return fmt.Errorf("ingest.aes67.port must be 1..65535")
		}
		if c.Ingest.AES67.PayloadType < 0 || c.Ingest.AES67.PayloadType > 127 {
			return fmt.Errorf("ingest.aes67.payloadType must be 0..127")
		}
		if c.Ingest.AES67.SampleRateHz <= 0 {
			return fmt.Errorf("ingest.aes67.sampleRateHz must be > 0")
		}
		if c.Ingest.AES67.Channels != 1 && c.Ingest.AES67.Channels != 2 {
			return fmt.Errorf("ingest.aes67.channels must be 1 or 2")
		}
		if strings.ToUpper(strings.TrimSpace(c.Ingest.AES67.Encoding)) != "L24" {
			return fmt.Errorf("ingest.aes67.encoding must be L24")
		}
		if c.Ingest.AES67.PacketTimeMs <= 0 {
			return fmt.Errorf("ingest.aes67.packetTimeMs must be > 0")
		}
		if c.Ingest.AES67.JitterDepthPackets < 1 {
			return fmt.Errorf("ingest.aes67.jitterDepthPackets must be >= 1")
		}
		if c.Ingest.AES67.ReadBufferBytes < 0 {
			return fmt.Errorf("ingest.aes67.readBufferBytes must be >= 0")
		}
	}
	switch strings.ToLower(strings.TrimSpace(c.Ingest.SRT.Mode)) {
	case "", "listener", "caller", "rendezvous":
	default:
		return fmt.Errorf("ingest.srt.mode unsupported: %s", c.Ingest.SRT.Mode)
	}
	if c.Ingest.SRT.SampleRateHz <= 0 {
		return fmt.Errorf("ingest.srt.sampleRateHz must be > 0")
	}
	if c.Ingest.SRT.Channels != 1 && c.Ingest.SRT.Channels != 2 {
		return fmt.Errorf("ingest.srt.channels must be 1 or 2")
	}
	switch strings.ToLower(strings.TrimSpace(c.Ingest.Icecast.Decoder)) {
	case "", "auto", "native", "ffmpeg", "fallback":
	default:
		return fmt.Errorf("ingest.icecast.decoder unsupported: %s", c.Ingest.Icecast.Decoder)
	}
	if c.Ingest.Icecast.RadioText.MaxLen < 0 || c.Ingest.Icecast.RadioText.MaxLen > 64 {
		return fmt.Errorf("ingest.icecast.radioText.maxLen out of range (0-64)")
	}
	// BUG-D fix: validate PI whenever non-empty, not only when RDS is enabled.
	// An invalid PI stored in config causes a silent failure when RDS is later
	// enabled via live-patch.
	if strings.TrimSpace(c.RDS.PI) != "" {
		if _, err := ParsePI(c.RDS.PI); err != nil {
			return fmt.Errorf("rds config: %w", err)
		}
	} else if c.RDS.Enabled {
		return fmt.Errorf("rds.pi is required when rds.enabled is true")
	}
	if c.RDS.PTY < 0 || c.RDS.PTY > 31 {
		return fmt.Errorf("rds.pty out of range (0-31)")
	}
	if len(c.RDS.PS) > 8 {
		return fmt.Errorf("rds.ps must be <= 8 characters")
	}
	if len(c.RDS.RadioText) > 64 {
		return fmt.Errorf("rds.radioText must be <= 64 characters")
	}
	return nil
}

// EffectiveDeviceRate returns the device sample rate, falling back to composite rate.
func (c Config) EffectiveDeviceRate() float64 {
	if c.Backend.DeviceSampleRateHz > 0 {
		return c.Backend.DeviceSampleRateHz
	}
	return float64(c.FM.CompositeRateHz)
}
