package main

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"syscall"
	"testing"
	"time"

	"github.com/fernet/fernet-go"
)

// TestStartupSmoke builds the daemon, runs it against a synthesized minimal
// config.ini (fresh crypto keys, an empty repos root), waits for /healthz to
// answer 200, then sends SIGINT and asserts a clean (exit code 0) warm
// shutdown. It exercises the real startup path end to end: config validation,
// crypto init via server.New, web.New (which globs the embedded hashed CSS),
// the middleware chain, and core-go's signal-driven Run loop.
//
// server.New's Run installs its warm-shutdown handler on SIGINT (the SourceHut
// fleet convention); the deployed systemd unit sets KillSignal=SIGINT so
// `systemctl stop` triggers the same graceful path this test verifies.
func TestStartupSmoke(t *testing.T) {
	if testing.Short() {
		t.Skip("smoke test builds a binary; skipped in -short mode")
	}
	if _, err := exec.LookPath("go"); err != nil {
		t.Skipf("go toolchain not available: %v", err)
	}

	// Build the daemon from the current package directory.
	binDir := t.TempDir()
	bin := filepath.Join(binDir, "comparesrht")
	build := exec.Command("go", "build", "-o", bin, ".")
	if out, err := build.CombinedOutput(); err != nil {
		t.Fatalf("go build: %v\n%s", err, out)
	}

	// Synthesize a config.ini with valid crypto keys and an empty repos root.
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		t.Fatalf("generate fernet key: %v", err)
	}
	seed := make([]byte, 32)
	if _, err := rand.Read(seed); err != nil {
		t.Fatalf("generate webhook seed: %v", err)
	}

	confDir := t.TempDir() // becomes the process cwd; LoadConfig finds config.ini here
	reposDir := t.TempDir()
	confBody := fmt.Sprintf(`[sr.ht]
network-key=%s
site-name=sourcehut
environment=development

[webhooks]
private-key=%s

[compare.sr.ht]
origin=http://compare.example

[meta.sr.ht]
origin=http://meta.example

[git.sr.ht]
origin=http://git.example
repos=%s
`, fk.Encode(), base64.StdEncoding.EncodeToString(seed), reposDir)
	if err := os.WriteFile(filepath.Join(confDir, "config.ini"), []byte(confBody), 0o644); err != nil {
		t.Fatalf("write config.ini: %v", err)
	}

	addr := freeAddr(t)

	cmd := exec.Command(bin, "-b", addr)
	cmd.Dir = confDir // so config.LoadConfig picks up ./config.ini
	cmd.Stdout = os.Stderr
	cmd.Stderr = os.Stderr
	if err := cmd.Start(); err != nil {
		t.Fatalf("start daemon: %v", err)
	}
	// Guarantee the child is reaped even if an assertion below fails early.
	defer func() {
		if cmd.ProcessState == nil {
			_ = cmd.Process.Kill()
			_ = cmd.Wait()
		}
	}()

	healthz := "http://" + addr + "/healthz"
	if !waitForOK(healthz, 10*time.Second) {
		t.Fatalf("daemon did not become healthy at %s within timeout", healthz)
	}

	// Warm shutdown: core-go's Run listens for SIGINT.
	if err := cmd.Process.Signal(syscall.SIGINT); err != nil {
		t.Fatalf("signal daemon: %v", err)
	}

	done := make(chan error, 1)
	go func() { done <- cmd.Wait() }()
	select {
	case err := <-done:
		if err != nil {
			t.Fatalf("daemon exited uncleanly after SIGINT: %v", err)
		}
	case <-time.After(10 * time.Second):
		_ = cmd.Process.Kill()
		t.Fatal("daemon did not shut down within 10s of SIGINT")
	}
}

// freeAddr reserves and immediately releases a loopback TCP port, returning its
// host:port. The daemon binds with SO_REUSEPORT so the brief release window is
// harmless.
func freeAddr(t *testing.T) string {
	t.Helper()
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("reserve port: %v", err)
	}
	addr := l.Addr().String()
	_ = l.Close()
	return addr
}

// waitForOK polls url until it returns HTTP 200 or the deadline elapses.
func waitForOK(url string, timeout time.Duration) bool {
	deadline := time.Now().Add(timeout)
	client := &http.Client{Timeout: time.Second}
	for time.Now().Before(deadline) {
		resp, err := client.Get(url)
		if err == nil {
			_ = resp.Body.Close()
			if resp.StatusCode == http.StatusOK {
				return true
			}
		}
		time.Sleep(100 * time.Millisecond)
	}
	return false
}
