package daemon import ( "bytes" "context" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) type syncBuffer struct { mu sync.Mutex b bytes.Buffer } func (s *syncBuffer) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() return s.b.Write(p) } func (s *syncBuffer) String() string { s.mu.Lock() defer s.mu.Unlock() return s.b.String() } func TestHealth(t *testing.T) { srv := httptest.NewServer(Handler(t.TempDir())) defer srv.Close() res, err := http.Get(srv.URL + "/health") if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("status %d", res.StatusCode) } b, _ := io.ReadAll(res.Body) if string(b) != "ok\n" { t.Fatalf("body %q", b) } } func TestHealthMethodNotAllowed(t *testing.T) { srv := httptest.NewServer(Handler(t.TempDir())) defer srv.Close() req, _ := http.NewRequest(http.MethodPost, srv.URL+"/health", nil) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusMethodNotAllowed { t.Fatalf("status %d", res.StatusCode) } } func TestLivez(t *testing.T) { srv := httptest.NewServer(Handler(t.TempDir())) defer srv.Close() res, err := http.Get(srv.URL + "/livez") if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("status %d", res.StatusCode) } b, _ := io.ReadAll(res.Body) if string(b) != "ok\n" { t.Fatalf("body %q", b) } } func TestReadyzOK(t *testing.T) { srv := httptest.NewServer(Handler(t.TempDir())) defer srv.Close() res, err := http.Get(srv.URL + "/readyz") if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("status %d", res.StatusCode) } b, _ := io.ReadAll(res.Body) if string(b) != "ok\n" { t.Fatalf("body %q", b) } } func TestReadyzMethodNotAllowed(t *testing.T) { srv := httptest.NewServer(Handler(t.TempDir())) defer srv.Close() req, _ := http.NewRequest(http.MethodPost, srv.URL+"/readyz", nil) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusMethodNotAllowed { t.Fatalf("status %d", res.StatusCode) } } func TestReadyzMissingStatsDir(t *testing.T) { statsDir := filepath.Join(t.TempDir(), "absent") srv := httptest.NewServer(readiness(statsDir, "")) defer srv.Close() res, err := http.Get(srv.URL) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status %d want 503", res.StatusCode) } } func TestReadyzStatsDirNotDirectory(t *testing.T) { f := filepath.Join(t.TempDir(), "file") if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { t.Fatal(err) } srv := httptest.NewServer(readiness(f, "")) defer srv.Close() res, err := http.Get(srv.URL) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status %d want 503", res.StatusCode) } } func TestReadyzStatsDirNotWritable(t *testing.T) { dir := t.TempDir() if err := os.Chmod(dir, 0o555); err != nil { t.Fatal(err) } defer func() { _ = os.Chmod(dir, 0o755) }() srv := httptest.NewServer(readiness(dir, "")) defer srv.Close() res, err := http.Get(srv.URL) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status %d want 503", res.StatusCode) } } func TestReadyzAuthDBDirNotWritable(t *testing.T) { statsDir := t.TempDir() authDir := t.TempDir() authDB := filepath.Join(authDir, "auth.db") if err := os.Chmod(authDir, 0o555); err != nil { t.Fatal(err) } defer func() { _ = os.Chmod(authDir, 0o755) }() srv := httptest.NewServer(readiness(statsDir, authDB)) defer srv.Close() res, err := http.Get(srv.URL) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status %d want 503", res.StatusCode) } } func TestReportHTTPTable(t *testing.T) { fixtures := filepath.Join("..", "..", "fixtures") srv := httptest.NewServer(Handler(fixtures)) defer srv.Close() tests := []struct { name string query string wantCode int wantCTPfx string bodyNeedle []string }{ { name: "plaintext", query: "category=Host&metric=Boots&limit=3&output-format=Plaintext", wantCode: http.StatusOK, wantCTPfx: "text/plain", bodyNeedle: []string{"Host"}, }, { name: "markdown aliases", query: "Category=Host&Metric=Uptime&limit=2&OutputFormat=Markdown", wantCode: http.StatusOK, wantCTPfx: "text/markdown", bodyNeedle: []string{"# Top", "```"}, }, { name: "html", query: "OutputFormat=HTML&limit=2", wantCode: http.StatusOK, wantCTPfx: "text/html", bodyNeedle: []string{"", "
"},
		},
		{
			name:      "gemtext",
			query:     "output-format=Gemtext&limit=2",
			wantCode:  http.StatusOK,
			wantCTPfx: "text/gemini",
		},
		{
			name:     "bad category",
			query:    "category=Nope",
			wantCode: http.StatusBadRequest,
		},
		{
			name:     "invalid limit",
			query:    "limit=notnum",
			wantCode: http.StatusBadRequest,
		},
		{
			name:     "invalid all bool",
			query:    "all=nope",
			wantCode: http.StatusBadRequest,
		},
		{
			name:     "downtime on non host",
			query:    "category=Kernel&metric=Downtime&limit=2",
			wantCode: http.StatusBadRequest,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			res, err := http.Get(srv.URL + "/report?" + tt.query)
			if err != nil {
				t.Fatal(err)
			}
			defer res.Body.Close()
			if res.StatusCode != tt.wantCode {
				t.Fatalf("status %d want %d", res.StatusCode, tt.wantCode)
			}
			if tt.wantCTPfx != "" {
				ct := res.Header.Get("Content-Type")
				if !strings.HasPrefix(ct, tt.wantCTPfx) {
					t.Fatalf("Content-Type %q want prefix %q", ct, tt.wantCTPfx)
				}
			}
			b, _ := io.ReadAll(res.Body)
			body := string(b)
			for _, sub := range tt.bodyNeedle {
				if !strings.Contains(body, sub) {
					t.Fatalf("body missing %q: %q", sub, body)
				}
			}
		})
	}
}

func TestReportMethodNotAllowed(t *testing.T) {
	fixtures := filepath.Join("..", "..", "fixtures")
	srv := httptest.NewServer(Handler(fixtures))
	defer srv.Close()
	req, _ := http.NewRequest(http.MethodPost, srv.URL+"/report?limit=2", nil)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	if res.StatusCode != http.StatusMethodNotAllowed {
		t.Fatalf("status %d", res.StatusCode)
	}
}

func TestReportAggregateFailure(t *testing.T) {
	dir := t.TempDir()
	line := "1:1:Linux 5.13.14-200.fc34.x86_64\n"
	if err := os.WriteFile(filepath.Join(dir, "dup.x.records"), []byte(line), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "dup.y.records"), []byte(line), 0o644); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(Handler(dir))
	defer srv.Close()
	res, err := http.Get(srv.URL + "/report?limit=2")
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	if res.StatusCode != http.StatusInternalServerError {
		t.Fatalf("status %d want 500", res.StatusCode)
	}
}

func TestRunEmptyStatsDir(t *testing.T) {
	err := Run(context.Background(), Config{StatsDir: "", Addr: ":0"})
	if err == nil {
		t.Fatal("expected error")
	}
}

func TestRunEmptyAddr(t *testing.T) {
	err := Run(context.Background(), Config{StatsDir: t.TempDir(), Addr: ""})
	if err == nil {
		t.Fatal("expected error")
	}
}

func TestRunWritesDaemonListenToLogOutput(t *testing.T) {
	var buf syncBuffer
	ctx, cancel := context.WithCancel(context.Background())
	cfg := Config{StatsDir: t.TempDir(), Addr: "127.0.0.1:0", LogOutput: &buf}
	done := make(chan struct{})
	go func() {
		_ = Run(ctx, cfg)
		close(done)
	}()
	deadline := time.After(2 * time.Second)
	for !strings.Contains(buf.String(), "daemon_listen") {
		select {
		case <-deadline:
			t.Fatalf("timeout waiting for daemon_listen, got %q", buf.String())
		case <-time.After(5 * time.Millisecond):
		}
	}
	cancel()
	<-done
}

func TestRunUsesStdoutWhenLogOutputNil(t *testing.T) {
	old := os.Stdout
	pr, pw, err := os.Pipe()
	if err != nil {
		t.Fatal(err)
	}
	os.Stdout = pw
	var logBuf syncBuffer
	copyDone := make(chan struct{})
	go func() {
		_, _ = io.Copy(&logBuf, pr)
		close(copyDone)
	}()
	ctx, cancel := context.WithCancel(context.Background())
	cfg := Config{StatsDir: t.TempDir(), Addr: "127.0.0.1:0", LogOutput: nil}
	runDone := make(chan struct{})
	go func() {
		_ = Run(ctx, cfg)
		close(runDone)
	}()
	deadline := time.After(2 * time.Second)
	for !strings.Contains(logBuf.String(), "daemon_listen") {
		select {
		case <-deadline:
			cancel()
			_ = pw.Close()
			<-runDone
			<-copyDone
			_ = pr.Close()
			os.Stdout = old
			t.Fatalf("timeout, got %q", logBuf.String())
		case <-time.After(5 * time.Millisecond):
		}
	}
	cancel()
	<-runDone
	os.Stdout = old
	if err := pw.Close(); err != nil {
		t.Fatal(err)
	}
	<-copyDone
	if err := pr.Close(); err != nil {
		t.Fatal(err)
	}
}

func TestRunInvalidListenAddress(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	err := Run(ctx, Config{StatsDir: t.TempDir(), Addr: ":999999999"})
	if err == nil {
		t.Fatal("expected listen error")
	}
	if !strings.Contains(err.Error(), "listen") {
		t.Fatalf("expected listen in error: %v", err)
	}
}

func TestAccessLogImplicitOKStatus(t *testing.T) {
	var buf bytes.Buffer
	log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
	mux := http.NewServeMux()
	mux.HandleFunc("/nohdr", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})
	srv := httptest.NewServer(withAccessLog(log, mux))
	defer srv.Close()
	res, err := http.Get(srv.URL + "/nohdr")
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	if !strings.Contains(buf.String(), "status=200") {
		t.Fatalf("log %q", buf.String())
	}
}

func TestAccessLogLineToWriter(t *testing.T) {
	var buf bytes.Buffer
	h := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
	log := slog.New(h)
	statsDir := t.TempDir()
	store, err := openAuthStore(context.Background(), statsDir, "")
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	srv := httptest.NewServer(withAccessLog(log, routes(statsDir, "", store)))
	defer srv.Close()
	res, err := http.Get(srv.URL + "/health")
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	body := buf.String()
	if !strings.Contains(body, "http_request") || !strings.Contains(body, "method=GET") {
		t.Fatalf("expected http_request line with method=GET, got %q", body)
	}
	if !strings.Contains(body, "path=/health") || !strings.Contains(body, "status=200") {
		t.Fatalf("expected path and status in log, got %q", body)
	}
}

func TestUploadOpenWhenNoKeys(t *testing.T) {
	statsDir := t.TempDir()
	srv := httptest.NewServer(Handler(statsDir))
	defer srv.Close()
	req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/myhost/txt", strings.NewReader("hello"))
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	if res.StatusCode != http.StatusNoContent {
		t.Fatalf("status %d", res.StatusCode)
	}
	b, err := os.ReadFile(filepath.Join(statsDir, "myhost.txt"))
	if err != nil {
		t.Fatal(err)
	}
	if string(b) != "hello" {
		t.Fatalf("file %q", b)
	}
}

func TestUploadRequiresBearerWhenKeysExist(t *testing.T) {
	statsDir := t.TempDir()
	ctx := context.Background()
	store, err := openAuthStore(ctx, statsDir, "")
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if _, err := store.CreateKey(ctx, "myhost"); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(routes(statsDir, "", store))
	defer srv.Close()
	req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/myhost/txt", strings.NewReader("x"))
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	if res.StatusCode != http.StatusUnauthorized {
		t.Fatalf("status %d want 401", res.StatusCode)
	}
}

func TestUploadWithValidBearer(t *testing.T) {
	statsDir := t.TempDir()
	ctx := context.Background()
	store, err := openAuthStore(ctx, statsDir, "")
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	tok, err := store.CreateKey(ctx, "myhost")
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(routes(statsDir, "", store))
	defer srv.Close()
	req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/myhost/os.txt", strings.NewReader("os"))
	req.Header.Set("Authorization", "Bearer "+tok)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	if res.StatusCode != http.StatusNoContent {
		t.Fatalf("status %d", res.StatusCode)
	}
}

func TestUploadWrongHostForbidden(t *testing.T) {
	statsDir := t.TempDir()
	ctx := context.Background()
	store, err := openAuthStore(ctx, statsDir, "")
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	tok, err := store.CreateKey(ctx, "myhost")
	if err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(routes(statsDir, "", store))
	defer srv.Close()
	req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/other/txt", strings.NewReader("x"))
	req.Header.Set("Authorization", "Bearer "+tok)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	if res.StatusCode != http.StatusForbidden {
		t.Fatalf("status %d want 403", res.StatusCode)
	}
}

func TestUploadBadKind(t *testing.T) {
	statsDir := t.TempDir()
	srv := httptest.NewServer(Handler(statsDir))
	defer srv.Close()
	req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/myhost/nope", strings.NewReader("x"))
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	if res.StatusCode != http.StatusBadRequest {
		t.Fatalf("status %d", res.StatusCode)
	}
}

func TestUploadAllKindsWriteExpectedFiles(t *testing.T) {
	statsDir := t.TempDir()
	srv := httptest.NewServer(Handler(statsDir))
	defer srv.Close()
	cases := []struct {
		kind     string
		wantName string
		body     string
	}{
		{"txt", "myhost.txt", "a"},
		{"cur.txt", "myhost.cur.txt", "b"},
		{"records", "myhost.records", "c"},
		{"os.txt", "myhost.os.txt", "d"},
		{"cpuinfo.txt", "myhost.cpuinfo.txt", "e"},
	}
	for _, tc := range cases {
		t.Run(tc.kind, func(t *testing.T) {
			url := srv.URL + "/upload/myhost/" + tc.kind
			req, _ := http.NewRequest(http.MethodPut, url, strings.NewReader(tc.body))
			res, err := http.DefaultClient.Do(req)
			if err != nil {
				t.Fatal(err)
			}
			res.Body.Close()
			if res.StatusCode != http.StatusNoContent {
				t.Fatalf("status %d", res.StatusCode)
			}
			b, err := os.ReadFile(filepath.Join(statsDir, tc.wantName))
			if err != nil {
				t.Fatal(err)
			}
			if string(b) != tc.body {
				t.Fatalf("file %s: got %q want %q", tc.wantName, b, tc.body)
			}
		})
	}
}