package ntfysink

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/kami/maven/internal/delivery"
	"github.com/kami/maven/internal/loop"
)

// recordingServer — captures the last request so tests assert the wire shape.
type recordingServer struct {
	mu      chan struct{}
	method  string
	path    string
	body    string
	auth    string
	title   string
	prio    string
	status  int
	respond string
}

func newRecordingServer(t *testing.T, status int, respond string) *recordingServer {
	t.Helper()
	rs := &recordingServer{status: status, respond: respond, mu: make(chan struct{}, 1)}
	rs.mu <- struct{}{}
	return rs
}

func (rs *recordingServer) handler() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)
		<-rs.mu
		rs.method = r.Method
		rs.path = r.URL.Path
		rs.body = string(b)
		rs.auth = r.Header.Get("Authorization")
		rs.title = r.Header.Get("Title")
		rs.prio = r.Header.Get("Priority")
		rs.mu <- struct{}{}
		w.WriteHeader(rs.status)
		if rs.respond != "" {
			_, _ = w.Write([]byte(rs.respond))
		}
	})
}

func (rs *recordingServer) snapshot() (method, path, body, auth, title, prio string) {
	<-rs.mu
	method, path, body, auth, title, prio = rs.method, rs.path, rs.body, rs.auth, rs.title, rs.prio
	rs.mu <- struct{}{}
	return
}

func nudgeSendable(sev loop.Severity, summary string) delivery.Sendable {
	return delivery.Sendable{
		Channel:  delivery.ChannelNtfy,
		Kind:     delivery.KindNudge,
		Severity: sev,
		RuleName: "service_down",
		Body:     "the backup service on homesrv is down - check journalctl",
		Summary:  summary,
		Ts:       time.Now(),
	}
}

func reminderSendable(summary string) delivery.Sendable {
	return delivery.Sendable{
		Channel:    delivery.ChannelNtfy,
		Kind:       delivery.KindReminder,
		ReminderID: 42,
		Body:       "full reminder body with detail",
		Summary:    summary,
		Ts:         time.Now(),
	}
}

// ----------------------------- config ---------------------------------------

func TestNewRejectsEmptyBaseURL(t *testing.T) {
	_, err := New(Config{Topic: "maven"})
	if err == nil {
		t.Fatal("want error for empty BaseURL")
	}
}

func TestNewRejectsEmptyTopic(t *testing.T) {
	_, err := New(Config{BaseURL: "http://localhost:8085"})
	if err == nil {
		t.Fatal("want error for empty Topic")
	}
}

func TestNewDefaultTimeout(t *testing.T) {
	s, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	if s.hc.Timeout != DefaultTimeout {
		t.Fatalf("default timeout: want %v, got %v", DefaultTimeout, s.hc.Timeout)
	}
}

// ----------------------------- send shape ----------------------------------

func TestSendPostsToTopicPath(t *testing.T) {
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, err := New(Config{BaseURL: srv.URL, Topic: "maven"})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil {
		t.Fatalf("Send: %v", err)
	}
	method, path, _, _, _, _ := rs.snapshot()
	if method != http.MethodPost {
		t.Fatalf("method: want POST, got %s", method)
	}
	if path != "/maven" {
		t.Fatalf("path: want /maven, got %s", path)
	}
}

func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
	// the minimal-body rule: away channels get Summary, never Body. the sink
	// must post Summary so a phraser bug (Body leaking detail) can't exfil.
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
	if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, body, _, _, _ := rs.snapshot()
	if body != "cert expiring soon" {
		t.Fatalf("body: want summary 'cert expiring soon', got %q", body)
	}
}

func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
	// #368: this used to fall back to the full body. ntfy leaves the box, so
	// an empty summary gets a fixed generic line plus the rule name instead.
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	s := nudgeSendable(loop.Sev3, "")
	sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
	if err := sink.Send(context.Background(), s); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, body, _, _, _ := rs.snapshot()
	want := delivery.GenericAwayMessage + ": service_down"
	if body != want {
		t.Fatalf("body: want %q, got %q", want, body)
	}
}

func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
	// with nothing at all to say we still send the generic line — an away
	// channel can never carry detail, but it also never goes out blank.
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
	s := nudgeSendable(loop.Sev3, "")
	s.Body = ""
	s.RuleName = ""
	if err := sink.Send(context.Background(), s); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, body, _, _, _ := rs.snapshot()
	if body != delivery.GenericAwayMessage {
		t.Fatalf("body: want %q, got %q", delivery.GenericAwayMessage, body)
	}
}

func TestSendSetsBasicAuth(t *testing.T) {
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(Config{
		BaseURL:  srv.URL,
		Topic:    "maven",
		Username: "maven",
		Password: "secret",
	})
	if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, _, auth, _, _ := rs.snapshot()
	if auth == "" {
		t.Fatal("want Basic auth header, got empty")
	}
	if !strings.HasPrefix(auth, "Basic ") {
		t.Fatalf("auth: want 'Basic ...', got %q", auth)
	}
}

func TestSendNoAuthWhenUsernameEmpty(t *testing.T) {
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
	if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, _, auth, _, _ := rs.snapshot()
	if auth != "" {
		t.Fatalf("want no auth header, got %q", auth)
	}
}

func TestSendTitleIsMaven(t *testing.T) {
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
	if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, _, _, title, _ := rs.snapshot()
	if title != "maven" {
		t.Fatalf("title: want 'maven', got %q", title)
	}
}

// ----------------------------- priority mapping ----------------------------

func TestPrioritySev3IsHigh(t *testing.T) {
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
	_ = sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert"))
	_, _, _, _, _, prio := rs.snapshot()
	if prio != "4" {
		t.Fatalf("sev3 priority: want 4 (high), got %s", prio)
	}
}

func TestPrioritySev4IsMax(t *testing.T) {
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
	_ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
	_, _, _, _, _, prio := rs.snapshot()
	if prio != "5" {
		t.Fatalf("sev4 priority: want 5 (max), got %s", prio)
	}
}

func TestPriorityReminderIsHigh(t *testing.T) {
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
	_ = sink.Send(context.Background(), reminderSendable("wake up"))
	_, _, _, _, _, prio := rs.snapshot()
	if prio != "4" {
		t.Fatalf("reminder priority: want 4 (high), got %s", prio)
	}
}

// ----------------------------- error handling ------------------------------

func TestSendReturnsErrorOnNon2xx(t *testing.T) {
	rs := newRecordingServer(t, http.StatusForbidden, `{"error":"forbidden"}`)
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
	err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
	if err == nil {
		t.Fatal("want error on 403")
	}
	if !strings.Contains(err.Error(), "403") {
		t.Fatalf("error should mention status 403, got: %v", err)
	}
}

func TestSendContextCancelReturnsError(t *testing.T) {
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
	defer cancel()
	err := sink.Send(ctx, nudgeSendable(loop.Sev3, "down"))
	if err == nil {
		t.Fatal("want error on canceled context")
	}
}

func TestSendConnectionRefusedReturnsError(t *testing.T) {
	sink, _ := New(Config{BaseURL: "http://127.0.0.1:1", Topic: "maven", Timeout: time.Second})
	err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
	if err == nil {
		t.Fatal("want error on connection refused")
	}
}
