package control

import (
	"bytes"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	cfgpkg "github.com/jan/fm-rds-tx/internal/config"
	"github.com/jan/fm-rds-tx/internal/ingest"
	"github.com/jan/fm-rds-tx/internal/output"
)

func TestHealthz(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
	if rec.Code != 200 {
		t.Fatalf("status: %d", rec.Code)
	}
}

func TestStatus(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
	if rec.Code != 200 {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	json.Unmarshal(rec.Body.Bytes(), &body)
	if body["service"] != "fm-rds-tx" {
		t.Fatal("missing service")
	}
	if _, ok := body["preEmphasisTauUS"]; !ok {
		t.Fatal("missing preEmphasisTauUS")
	}
}

func TestStatusReportsRuntimeIndicator(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{stats: map[string]any{"runtimeIndicator": "degraded", "runtimeAlert": "late buffers"}})
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
	if rec.Code != 200 {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	json.Unmarshal(rec.Body.Bytes(), &body)
	if body["runtimeIndicator"] != "degraded" {
		t.Fatalf("expected runtimeIndicator degraded, got %v", body["runtimeIndicator"])
	}
	if body["runtimeAlert"] != "late buffers" {
		t.Fatalf("expected runtimeAlert late buffers, got %v", body["runtimeAlert"])
	}
}

func TestStatusReportsQueueStats(t *testing.T) {
	cfg := cfgpkg.Default()
	queueStats := output.QueueStats{
		Capacity:  cfg.Runtime.FrameQueueCapacity,
		Depth:     1,
		FillLevel: 0.25,
		Health:    output.QueueHealthLow,
	}
	srv := NewServer(cfg)
	srv.SetTXController(&fakeTXController{stats: map[string]any{"queue": queueStats}})
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
	if rec.Code != 200 {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("unmarshal queue stats: %v", err)
	}
	queueRaw, ok := body["queue"]
	if !ok {
		t.Fatalf("missing queue in status")
	}
	queueMap, ok := queueRaw.(map[string]any)
	if !ok {
		t.Fatalf("queue stats type mismatch: %T", queueRaw)
	}
	if queueMap["capacity"] != float64(queueStats.Capacity) {
		t.Fatalf("queue capacity mismatch: want %v got %v", queueStats.Capacity, queueMap["capacity"])
	}
	if queueMap["health"] != string(queueStats.Health) {
		t.Fatalf("queue health mismatch: want %s got %v", queueStats.Health, queueMap["health"])
	}
}

func TestStatusReportsRuntimeState(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{stats: map[string]any{"state": "faulted"}})
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
	if rec.Code != 200 {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("unmarshal runtime state: %v", err)
	}
	if body["runtimeState"] != "faulted" {
		t.Fatalf("expected runtimeState faulted, got %v", body["runtimeState"])
	}
}

func TestDryRunEndpoint(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/dry-run", nil))
	if rec.Code != 200 {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	json.Unmarshal(rec.Body.Bytes(), &body)
	if body["mode"] != "dry-run" {
		t.Fatal("wrong mode")
	}
}

func TestConfigPatch(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisTauUS":75}`)
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
	if rec.Code != 200 {
		t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
	}
}

func TestConfigPatchFailedLiveUpdateDoesNotMutateSnapshot(t *testing.T) {
	cfg := cfgpkg.Default()
	srv := NewServer(cfg)
	srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})

	body := []byte(`{"stereoMode":"SSB"}`)
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
	}

	cfgRec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(cfgRec, httptest.NewRequest(http.MethodGet, "/config", nil))
	if cfgRec.Code != http.StatusOK {
		t.Fatalf("config status: %d", cfgRec.Code)
	}
	var got cfgpkg.Config
	if err := json.Unmarshal(cfgRec.Body.Bytes(), &got); err != nil {
		t.Fatalf("unmarshal config: %v", err)
	}
	if got.FM.StereoMode != cfg.FM.StereoMode {
		t.Fatalf("snapshot mutated on failed live update: got %q want %q", got.FM.StereoMode, cfg.FM.StereoMode)
	}
}

func TestConfigPatchRejectsOversizeBody(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	payload := bytes.Repeat([]byte("x"), maxConfigBodyBytes+32)
	body := append([]byte(`{"ps":"`), payload...)
	body = append(body, []byte(`"}`)...)
	req := newConfigPostRequest(body)
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusRequestEntityTooLarge {
		t.Fatalf("expected 413, got %d response=%q", rec.Code, rec.Body.String())
	}
}

func TestConfigPatchRejectsMissingContentType(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`)))
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusUnsupportedMediaType {
		t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code)
	}
}

func TestConfigPatchRejectsNonJSONContentType(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`)))
	req.Header.Set("Content-Type", "text/plain")
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusUnsupportedMediaType {
		t.Fatalf("expected 415 for non-JSON Content-Type, got %d", rec.Code)
	}
}

func TestIngestSavePersistsAndSchedulesReload(t *testing.T) {
	cfg := cfgpkg.Default()
	cfg.Ingest.Kind = "icecast"
	cfg.Ingest.Icecast.URL = "https://example.invalid/live"
	srv := NewServer(cfg)

	dir := t.TempDir()
	configPath := filepath.Join(dir, "saved.json")
	reloadDone := make(chan struct{}, 1)
	srv.SetConfigSaver(func(next cfgpkg.Config) error {
		return cfgpkg.Save(configPath, next)
	})
	srv.SetHardReload(func() {
		select {
		case reloadDone <- struct{}{}:
		default:
		}
	})

	nextIngest := cfgpkg.Default().Ingest
	nextIngest.Kind = "srt"
	nextIngest.PrebufferMs = 1000
	nextIngest.StallTimeoutMs = 2500
	nextIngest.Reconnect.Enabled = true
	nextIngest.Reconnect.InitialBackoffMs = 500
	nextIngest.Reconnect.MaxBackoffMs = 5000
	nextIngest.SRT.URL = "srt://0.0.0.0:9000?mode=listener"
	body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
	if err != nil {
		t.Fatalf("marshal body: %v", err)
	}
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
	if rec.Code != http.StatusOK {
		t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
	}
	select {
	case <-reloadDone:
	case <-time.After(2 * time.Second):
		t.Fatal("expected hard reload callback")
	}
	saved, err := cfgpkg.Load(configPath)
	if err != nil {
		t.Fatalf("load saved config: %v", err)
	}
	if saved.Ingest.Kind != "srt" {
		t.Fatalf("expected saved ingest kind srt, got %q", saved.Ingest.Kind)
	}
	if saved.Ingest.SRT.URL != "srt://0.0.0.0:9000?mode=listener" {
		t.Fatalf("expected saved ingest.srt.url, got %q", saved.Ingest.SRT.URL)
	}
}

func TestIngestSaveRejectsWhenSaverMissing(t *testing.T) {
	cfg := cfgpkg.Default()
	cfg.Ingest.Kind = "icecast"
	cfg.Ingest.Icecast.URL = "https://example.invalid/live"
	srv := NewServer(cfg)
	rec := httptest.NewRecorder()
	nextIngest := cfgpkg.Default().Ingest
	nextIngest.Kind = "icecast"
	nextIngest.Icecast.URL = "https://example.invalid/live"
	body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
	if err != nil {
		t.Fatalf("marshal body: %v", err)
	}
	srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
	if rec.Code != http.StatusServiceUnavailable {
		t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String())
	}
}

func TestIngestSaveUsesValidationErrors(t *testing.T) {
	cfg := cfgpkg.Default()
	cfg.Ingest.Kind = "icecast"
	cfg.Ingest.Icecast.URL = "https://example.invalid/live"
	srv := NewServer(cfg)
	dir := t.TempDir()
	configPath := filepath.Join(dir, "saved.json")
	srv.SetConfigSaver(func(next cfgpkg.Config) error {
		return cfgpkg.Save(configPath, next)
	})
	rec := httptest.NewRecorder()
	nextIngest := cfgpkg.Default().Ingest
	nextIngest.Kind = "srt"
	nextIngest.SRT.URL = ""
	body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
	if err != nil {
		t.Fatalf("marshal body: %v", err)
	}
	srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
	}
	if !strings.Contains(rec.Body.String(), "ingest.srt.url is required") {
		t.Fatalf("expected existing validation error, got %q", rec.Body.String())
	}
	if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("expected no config file to be written, stat err=%v", err)
	}
}

func TestRuntimeWithoutDriver(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
	if rec.Code != 200 {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("unmarshal runtime: %v", err)
	}
	if _, ok := body["ingest"]; ok {
		t.Fatalf("expected ingest payload to be absent when ingest runtime is not configured")
	}
	if _, ok := body["engine"]; ok {
		t.Fatalf("expected engine payload to be absent when tx controller is not configured")
	}
}

func TestRuntimeIncludesIngestStats(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetIngestRuntime(&fakeIngestRuntime{
		stats: ingest.Stats{
			Active:  ingest.SourceDescriptor{ID: "stdin-main", Kind: "stdin-pcm"},
			Runtime: ingest.RuntimeStats{State: "running"},
		},
	})
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("unmarshal runtime: %v", err)
	}
	ingest, ok := body["ingest"].(map[string]any)
	if !ok {
		t.Fatalf("expected ingest stats, got %T", body["ingest"])
	}
	active, ok := ingest["active"].(map[string]any)
	if !ok {
		t.Fatalf("expected ingest.active map, got %T", ingest["active"])
	}
	if active["id"] != "stdin-main" {
		t.Fatalf("unexpected ingest active id: %v", active["id"])
	}
}

func TestRuntimeIncludesDetailedIngestSourceAndRuntimeStats(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetIngestRuntime(&fakeIngestRuntime{
		stats: ingest.Stats{
			Active: ingest.SourceDescriptor{
				ID:   "icecast-main",
				Kind: "icecast",
				Origin: &ingest.SourceOrigin{
					Kind:     "url",
					Endpoint: "http://example.org/live",
				},
			},
			Source: ingest.SourceStats{
				State:      "reconnecting",
				Connected:  false,
				Reconnects: 3,
				LastError:  "dial tcp timeout",
			},
			Runtime: ingest.RuntimeStats{
				State:         "degraded",
				ConvertErrors: 2,
				WriteBlocked:  true,
			},
		},
	})
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("unmarshal runtime: %v", err)
	}
	ingestPayload, ok := body["ingest"].(map[string]any)
	if !ok {
		t.Fatalf("expected ingest payload map, got %T", body["ingest"])
	}
	source, ok := ingestPayload["source"].(map[string]any)
	if !ok {
		t.Fatalf("expected ingest.source map, got %T", ingestPayload["source"])
	}
	if source["state"] != "reconnecting" {
		t.Fatalf("source state mismatch: got %v", source["state"])
	}
	if source["reconnects"] != float64(3) {
		t.Fatalf("source reconnects mismatch: got %v", source["reconnects"])
	}
	if source["lastError"] != "dial tcp timeout" {
		t.Fatalf("source lastError mismatch: got %v", source["lastError"])
	}
	active, ok := ingestPayload["active"].(map[string]any)
	if !ok {
		t.Fatalf("expected ingest.active map, got %T", ingestPayload["active"])
	}
	origin, ok := active["origin"].(map[string]any)
	if !ok {
		t.Fatalf("expected ingest.active.origin map, got %T", active["origin"])
	}
	if origin["kind"] != "url" {
		t.Fatalf("origin kind mismatch: got %v", origin["kind"])
	}
	if origin["endpoint"] != "http://example.org/live" {
		t.Fatalf("origin endpoint mismatch: got %v", origin["endpoint"])
	}
	runtimePayload, ok := ingestPayload["runtime"].(map[string]any)
	if !ok {
		t.Fatalf("expected ingest.runtime map, got %T", ingestPayload["runtime"])
	}
	if runtimePayload["state"] != "degraded" {
		t.Fatalf("runtime state mismatch: got %v", runtimePayload["state"])
	}
	if runtimePayload["convertErrors"] != float64(2) {
		t.Fatalf("runtime convertErrors mismatch: got %v", runtimePayload["convertErrors"])
	}
	if runtimePayload["writeBlocked"] != true {
		t.Fatalf("runtime writeBlocked mismatch: got %v", runtimePayload["writeBlocked"])
	}
}

func TestRuntimeOmitsEngineWhenControllerReturnsNilStats(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{returnNilStats: true})
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("unmarshal runtime: %v", err)
	}
	if _, ok := body["engine"]; ok {
		t.Fatalf("expected engine field to be omitted when TXStats returns nil")
	}
}

func TestRuntimeReportsFaultHistory(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	history := []map[string]any{
		{
			"time":     "2026-04-06T00:00:00Z",
			"reason":   "queueCritical",
			"severity": "faulted",
			"message":  "queue critical",
		},
	}
	srv.SetTXController(&fakeTXController{stats: map[string]any{"faultHistory": history}})
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
	if rec.Code != 200 {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("unmarshal runtime: %v", err)
	}
	engineRaw, ok := body["engine"].(map[string]any)
	if !ok {
		t.Fatalf("runtime engine missing")
	}
	histRaw, ok := engineRaw["faultHistory"].([]any)
	if !ok {
		t.Fatalf("faultHistory missing or wrong type: %T", engineRaw["faultHistory"])
	}
	if len(histRaw) != len(history) {
		t.Fatalf("faultHistory length mismatch: want %d got %d", len(history), len(histRaw))
	}
}
func TestRuntimeReportsTransitionHistory(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	history := []map[string]any{{
		"time":     "2026-04-06T00:00:00Z",
		"from":     "running",
		"to":       "degraded",
		"severity": "warn",
	}}
	srv.SetTXController(&fakeTXController{stats: map[string]any{"transitionHistory": history}})
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
	if rec.Code != 200 {
		t.Fatalf("status: %d", rec.Code)
	}
	var body map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("unmarshal runtime: %v", err)
	}
	engineRaw, ok := body["engine"].(map[string]any)
	if !ok {
		t.Fatalf("runtime engine missing")
	}
	histRaw, ok := engineRaw["transitionHistory"].([]any)
	if !ok {
		t.Fatalf("transitionHistory missing or wrong type: %T", engineRaw["transitionHistory"])
	}
	if len(histRaw) != len(history) {
		t.Fatalf("transitionHistory length mismatch: want %d got %d", len(history), len(histRaw))
	}
}

func TestRuntimeFaultResetRejectsGet(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/runtime/fault/reset", nil)
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusMethodNotAllowed {
		t.Fatalf("expected 405 for fault reset GET, got %d", rec.Code)
	}
}

func TestRuntimeFaultResetRequiresController(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusServiceUnavailable {
		t.Fatalf("expected 503 without controller, got %d", rec.Code)
	}
}

func TestRuntimeFaultResetControllerError(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{resetErr: errors.New("boom")})
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusConflict {
		t.Fatalf("expected 409 when controller rejects, got %d", rec.Code)
	}
}

func TestRuntimeFaultResetSuccess(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{})
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != 200 {
		t.Fatalf("expected 200 on success, got %d", rec.Code)
	}
	var body map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("unmarshal response: %v", err)
	}
	if ok, _ := body["ok"].(bool); !ok {
		t.Fatalf("expected ok true, got %v", body["ok"])
	}
}

func TestRuntimeFaultResetRejectsBody(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{})
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", bytes.NewReader([]byte("nope")))
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("expected 400 when body present, got %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "request must not include a body") {
		t.Fatalf("unexpected response body: %q", rec.Body.String())
	}
}

func TestAudioStreamRequiresSource(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil))
	req.Header.Set("Content-Type", "application/octet-stream")
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusServiceUnavailable {
		t.Fatalf("expected 503 when audio stream missing, got %d", rec.Code)
	}
}

func TestAudioStreamPushesPCM(t *testing.T) {
	cfg := cfgpkg.Default()
	srv := NewServer(cfg)
	ingress := &fakeAudioIngress{}
	srv.SetAudioIngress(ingress)
	pcm := []byte{0, 0, 0, 0}
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm))
	req.Header.Set("Content-Type", "application/octet-stream")
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != 200 {
		t.Fatalf("expected 200, got %d", rec.Code)
	}
	var body map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("unmarshal response: %v", err)
	}
	if ok, _ := body["ok"].(bool); !ok {
		t.Fatalf("expected ok true, got %v", body["ok"])
	}
	frames, _ := body["frames"].(float64)
	if frames != 1 {
		t.Fatalf("expected 1 frame, got %v", frames)
	}
	if ingress.totalFrames != 1 {
		t.Fatalf("expected ingress frames=1, got %d", ingress.totalFrames)
	}
}

func TestAudioStreamRejectsNonPost(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/audio/stream", nil)
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusMethodNotAllowed {
		t.Fatalf("expected 405 for audio stream GET, got %d", rec.Code)
	}
}

func TestAudioStreamRejectsMissingContentType(t *testing.T) {
	cfg := cfgpkg.Default()
	srv := NewServer(cfg)
	srv.SetAudioIngress(&fakeAudioIngress{})
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusUnsupportedMediaType {
		t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "Content-Type must be") {
		t.Fatalf("unexpected response body: %q", rec.Body.String())
	}
}

func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) {
	cfg := cfgpkg.Default()
	srv := NewServer(cfg)
	srv.SetAudioIngress(&fakeAudioIngress{})
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
	req.Header.Set("Content-Type", "text/plain")
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusUnsupportedMediaType {
		t.Fatalf("expected 415 for unsupported Content-Type, got %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "Content-Type must be") {
		t.Fatalf("unexpected response body: %q", rec.Body.String())
	}
}

func TestAudioStreamRejectsBodyTooLarge(t *testing.T) {
	orig := audioStreamBodyLimit
	t.Cleanup(func() {
		audioStreamBodyLimit = orig
	})
	audioStreamBodyLimit = 1024
	limit := int(audioStreamBodyLimit)
	body := make([]byte, limit+1)
	srv := NewServer(cfgpkg.Default())
	srv.SetAudioIngress(&fakeAudioIngress{})
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/octet-stream")
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusRequestEntityTooLarge {
		t.Fatalf("expected 413 for oversized body, got %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "request body too large") {
		t.Fatalf("unexpected response body: %q", rec.Body.String())
	}
}

func TestTXStartWithoutController(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil))
	if rec.Code != http.StatusServiceUnavailable {
		t.Fatalf("expected 503, got %d", rec.Code)
	}
}

func TestTXStartRejectsBody(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{})
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("expected 400 when body present, got %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "request must not include a body") {
		t.Fatalf("unexpected response body: %q", rec.Body.String())
	}
}

func TestTXStopRejectsBody(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{})
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/tx/stop", bytes.NewReader([]byte("body")))
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("expected 400 when body present, got %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "request must not include a body") {
		t.Fatalf("unexpected response body: %q", rec.Body.String())
	}
}

func TestConfigPatchUpdatesSnapshot(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{})

	rec := httptest.NewRecorder()
	body := []byte(`{"outputDrive":1.2}`)
	srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
	if rec.Code != 200 {
		t.Fatalf("status: %d", rec.Code)
	}
	var resp map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
		t.Fatalf("unmarshal response: %v", err)
	}
	if live, ok := resp["live"].(bool); !ok || !live {
		t.Fatalf("expected live true, got %v", resp["live"])
	}

	rec = httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
	var cfg cfgpkg.Config
	if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
		t.Fatalf("decode config: %v", err)
	}
	if cfg.FM.OutputDrive != 1.2 {
		t.Fatalf("expected snapshot to reflect new drive, got %v", cfg.FM.OutputDrive)
	}
}

func TestConfigPatchPersistsRestartRequiredFieldsWhenSaverConfigured(t *testing.T) {
	cfg := cfgpkg.Default()
	srv := NewServer(cfg)

	dir := t.TempDir()
	configPath := filepath.Join(dir, "saved.json")
	if err := cfgpkg.Save(configPath, cfg); err != nil {
		t.Fatalf("seed config: %v", err)
	}
	srv.SetConfigSaver(func(next cfgpkg.Config) error {
		return cfgpkg.Save(configPath, next)
	})

	rec := httptest.NewRecorder()
	body := []byte(`{
		"preEmphasisTauUS":75,
		"audioGain":1.5,
		"pi":"BEEF",
		"pty":10,
		"ms":false,
		"ctEnabled":false,
		"rtPlusEnabled":false,
		"rtPlusSeparator":"/",
		"ptyn":"ALTROCK",
		"lps":"My Radio Station",
		"ertEnabled":true,
		"ert":"Grüezi mitenand",
		"rds2Enabled":true,
		"stationLogoPath":"C:\\logo.png",
		"af":[93.3,95.7],
		"bs412Enabled":true,
		"bs412ThresholdDBr":0.5,
		"mpxGain":1.3,
		"compositeClipperIterations":4,
		"compositeClipperSoftKnee":0.22,
		"compositeClipperLookaheadMs":1.4
	}`)
	srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
	if rec.Code != http.StatusOK {
		t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
	}

	saved, err := cfgpkg.Load(configPath)
	if err != nil {
		t.Fatalf("load saved config: %v", err)
	}
	if saved.FM.PreEmphasisTauUS != 75 {
		t.Fatalf("expected saved preEmphasisTauUS=75, got %v", saved.FM.PreEmphasisTauUS)
	}
	if saved.Audio.Gain != 1.5 {
		t.Fatalf("expected saved audio.gain=1.5, got %v", saved.Audio.Gain)
	}
	if saved.RDS.PI != "BEEF" {
		t.Fatalf("expected saved rds.pi=BEEF, got %q", saved.RDS.PI)
	}
	if saved.RDS.PTY != 10 {
		t.Fatalf("expected saved rds.pty=10, got %v", saved.RDS.PTY)
	}
	if saved.RDS.MS != false {
		t.Fatalf("expected saved rds.ms=false, got %v", saved.RDS.MS)
	}
	if saved.RDS.CTEnabled != false {
		t.Fatalf("expected saved rds.ctEnabled=false, got %v", saved.RDS.CTEnabled)
	}
	if saved.RDS.RTPlusEnabled != false {
		t.Fatalf("expected saved rds.rtPlusEnabled=false, got %v", saved.RDS.RTPlusEnabled)
	}
	if saved.RDS.RTPlusSeparator != "/" {
		t.Fatalf("expected saved rds.rtPlusSeparator='/', got %q", saved.RDS.RTPlusSeparator)
	}
	if saved.RDS.PTYN != "ALTROCK" {
		t.Fatalf("expected saved rds.ptyn=ALTROCK, got %q", saved.RDS.PTYN)
	}
	if saved.RDS.LPS != "My Radio Station" {
		t.Fatalf("expected saved rds.lps, got %q", saved.RDS.LPS)
	}
	if saved.RDS.ERTEnabled != true {
		t.Fatalf("expected saved rds.ertEnabled=true, got %v", saved.RDS.ERTEnabled)
	}
	if saved.RDS.ERT != "Grüezi mitenand" {
		t.Fatalf("expected saved rds.ert, got %q", saved.RDS.ERT)
	}
	if saved.RDS.RDS2Enabled != true {
		t.Fatalf("expected saved rds.rds2Enabled=true, got %v", saved.RDS.RDS2Enabled)
	}
	if saved.RDS.StationLogoPath != "C:\\logo.png" {
		t.Fatalf("expected saved rds.stationLogoPath, got %q", saved.RDS.StationLogoPath)
	}
	if len(saved.RDS.AF) != 2 || saved.RDS.AF[0] != 93.3 || saved.RDS.AF[1] != 95.7 {
		t.Fatalf("expected saved rds.af=[93.3 95.7], got %v", saved.RDS.AF)
	}
	if !saved.FM.BS412Enabled {
		t.Fatalf("expected saved bs412Enabled=true, got false")
	}
	if saved.FM.BS412ThresholdDBr != 0.5 {
		t.Fatalf("expected saved bs412ThresholdDBr=0.5, got %v", saved.FM.BS412ThresholdDBr)
	}
	if saved.FM.MpxGain != 1.3 {
		t.Fatalf("expected saved fm.mpxGain=1.3, got %v", saved.FM.MpxGain)
	}
	if saved.FM.CompositeClipper.Iterations != 4 {
		t.Fatalf("expected saved compositeClipper.iterations=4, got %v", saved.FM.CompositeClipper.Iterations)
	}
	if saved.FM.CompositeClipper.SoftKnee != 0.22 {
		t.Fatalf("expected saved compositeClipper.softKnee=0.22, got %v", saved.FM.CompositeClipper.SoftKnee)
	}
	if saved.FM.CompositeClipper.LookaheadMs != 1.4 {
		t.Fatalf("expected saved compositeClipper.lookaheadMs=1.4, got %v", saved.FM.CompositeClipper.LookaheadMs)
	}
}

func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})

	body := []byte(`{"outputDrive":2.2}`)
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", rec.Code)
	}

	rec = httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
	var cfg cfgpkg.Config
	if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
		t.Fatalf("decode config: %v", err)
	}
	if cfg.FM.OutputDrive != cfgpkg.Default().FM.OutputDrive {
		t.Fatalf("expected snapshot untouched, got %v", cfg.FM.OutputDrive)
	}
}

func TestRuntimeIncludesControlAudit(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	counts := controlAuditCounts(t, srv)
	keys := []string{"methodNotAllowed", "unsupportedMediaType", "bodyTooLarge", "unexpectedBody"}
	for _, key := range keys {
		if counts[key] != 0 {
			t.Fatalf("expected %s to start at 0, got %d", key, counts[key])
		}
	}
}

func TestControlAuditTracksMethodNotAllowed(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audio/stream", nil))
	if rec.Code != http.StatusMethodNotAllowed {
		t.Fatalf("expected 405 from audio stream GET, got %d", rec.Code)
	}
	counts := controlAuditCounts(t, srv)
	if counts["methodNotAllowed"] != 1 {
		t.Fatalf("expected methodNotAllowed=1, got %d", counts["methodNotAllowed"])
	}
}

func TestControlAuditTracksUnsupportedMediaType(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetAudioIngress(&fakeAudioIngress{})
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusUnsupportedMediaType {
		t.Fatalf("expected 415 for audio stream content type, got %d", rec.Code)
	}
	counts := controlAuditCounts(t, srv)
	if counts["unsupportedMediaType"] != 1 {
		t.Fatalf("expected unsupportedMediaType=1, got %d", counts["unsupportedMediaType"])
	}
}

func TestControlAuditTracksBodyTooLarge(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	limit := int(maxConfigBodyBytes)
	body := []byte("{\"ps\":\"" + strings.Repeat("x", limit+1) + "\"}")
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
	if rec.Code != http.StatusRequestEntityTooLarge {
		t.Fatalf("expected 413 for oversized config body, got %d", rec.Code)
	}
	counts := controlAuditCounts(t, srv)
	if counts["bodyTooLarge"] != 1 {
		t.Fatalf("expected bodyTooLarge=1, got %d", counts["bodyTooLarge"])
	}
}

func TestControlAuditTracksUnexpectedBody(t *testing.T) {
	srv := NewServer(cfgpkg.Default())
	srv.SetTXController(&fakeTXController{})
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("expected 400 for unexpected body, got %d", rec.Code)
	}
	counts := controlAuditCounts(t, srv)
	if counts["unexpectedBody"] != 1 {
		t.Fatalf("expected unexpectedBody=1, got %d", counts["unexpectedBody"])
	}
}

func controlAuditCounts(t *testing.T, srv *Server) map[string]uint64 {
	t.Helper()
	rec := httptest.NewRecorder()
	srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("runtime request failed: %d", rec.Code)
	}
	var payload map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
		t.Fatalf("unmarshal runtime: %v", err)
	}
	raw, ok := payload["controlAudit"].(map[string]any)
	if !ok {
		t.Fatalf("controlAudit missing or wrong type: %T", payload["controlAudit"])
	}
	counts := map[string]uint64{}
	for key, value := range raw {
		num, ok := value.(float64)
		if !ok {
			t.Fatalf("controlAudit %s not numeric: %T", key, value)
		}
		counts[key] = uint64(num)
	}
	return counts
}

func newConfigPostRequest(body []byte) *http.Request {
	req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	return req
}

func newIngestSavePostRequest(body []byte) *http.Request {
	req := httptest.NewRequest(http.MethodPost, "/config/ingest/save", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	return req
}

type fakeTXController struct {
	updateErr      error
	resetErr       error
	stats          map[string]any
	returnNilStats bool
}

type fakeAudioIngress struct {
	totalFrames int
}

type fakeIngestRuntime struct {
	stats ingest.Stats
}

func (f *fakeAudioIngress) WritePCM16(data []byte) (int, error) {
	frames := len(data) / 4
	f.totalFrames += frames
	return frames, nil
}

func (f *fakeIngestRuntime) Stats() ingest.Stats {
	return f.stats
}

func (f *fakeTXController) StartTX() error { return nil }
func (f *fakeTXController) StopTX() error  { return nil }
func (f *fakeTXController) TXStats() map[string]any {
	if f.returnNilStats {
		return nil
	}
	if f.stats != nil {
		return f.stats
	}
	return map[string]any{}
}
func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr }
func (f *fakeTXController) ResetFault() error              { return f.resetErr }
