package telegramsink

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"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
	contentType string
	auth        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.contentType = r.Header.Get("Content-Type")
		rs.auth = r.Header.Get("Authorization")
		rs.mu <- struct{}{}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(rs.status)
		if rs.respond != "" {
			_, _ = w.Write([]byte(rs.respond))
		} else {
			_, _ = w.Write([]byte(`{"ok":true}`))
		}
	})
}

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

func nudgeSendable(sev loop.Severity, summary string) delivery.Sendable {
	return delivery.Sendable{
		Channel:  delivery.ChannelTelegram,
		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.ChannelTelegram,
		Kind:       delivery.KindReminder,
		ReminderID: 42,
		Body:       "full reminder body with detail",
		Summary:    summary,
		Ts:         time.Now(),
	}
}

// sinkCfg — convenience for tests: BaseURL + minimal required fields.
func sinkCfg(baseURL string) Config {
	return Config{BaseURL: baseURL, BotToken: "123:abc", ChatID: "42"}
}

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

func TestNewRejectsEmptyBotToken(t *testing.T) {
	_, err := New(Config{ChatID: "42"})
	if err == nil {
		t.Fatal("want error for empty BotToken")
	}
}

func TestNewRejectsEmptyChatID(t *testing.T) {
	_, err := New(Config{BotToken: "123:abc"})
	if err == nil {
		t.Fatal("want error for empty ChatID")
	}
}

func TestNewDefaultTimeout(t *testing.T) {
	s, err := New(Config{BotToken: "123:abc", ChatID: "42"})
	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)
	}
}

func TestNewRejectsBadBaseURL(t *testing.T) {
	_, err := New(Config{BotToken: "x", ChatID: "1", BaseURL: "://bad"})
	if err == nil {
		t.Fatal("want error for bad BaseURL")
	}
}

func TestNewRejectsBadProxy(t *testing.T) {
	_, err := New(Config{BotToken: "x", ChatID: "1", Proxy: "://bad"})
	if err == nil {
		t.Fatal("want error for bad Proxy")
	}
}

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

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

	sink, err := New(sinkCfg(srv.URL))
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "homesrv down")); 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 != "/bot123:abc/sendMessage" {
		t.Fatalf("path: want /bot123:abc/sendMessage, got %s", path)
	}
}

func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
	// 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(sinkCfg(srv.URL))
	if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "homesrv down")); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, body, _, _ := rs.snapshot()
	var req sendMessageReq
	if err := json.Unmarshal([]byte(body), &req); err != nil {
		t.Fatalf("unmarshal body: %v (raw=%q)", err, body)
	}
	if req.Text != "homesrv down" {
		t.Fatalf("text: want summary 'homesrv down', got %q", req.Text)
	}
}

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

	s := nudgeSendable(loop.Sev4, "")
	sink, _ := New(sinkCfg(srv.URL))
	if err := sink.Send(context.Background(), s); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, body, _, _ := rs.snapshot()
	var req sendMessageReq
	_ = json.Unmarshal([]byte(body), &req)
	want := delivery.GenericAwayMessage + ": service_down"
	if req.Text != want {
		t.Fatalf("text: want %q, got %q", want, req.Text)
	}
}

func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
	// with nothing at all to say we still send the generic line.
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(sinkCfg(srv.URL))
	s := nudgeSendable(loop.Sev4, "")
	s.Body = ""
	s.RuleName = ""
	if err := sink.Send(context.Background(), s); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, body, _, _ := rs.snapshot()
	var req sendMessageReq
	_ = json.Unmarshal([]byte(body), &req)
	if req.Text != delivery.GenericAwayMessage {
		t.Fatalf("text: want %q, got %q", delivery.GenericAwayMessage, req.Text)
	}
}

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

	cfg := sinkCfg(srv.URL)
	cfg.ChatID = "@maven_alerts"
	sink, _ := New(cfg)
	if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, body, _, _ := rs.snapshot()
	var req sendMessageReq
	if err := json.Unmarshal([]byte(body), &req); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if req.ChatID != "@maven_alerts" {
		t.Fatalf("chat_id: want @maven_alerts, got %q", req.ChatID)
	}
	if !req.ProtectContent {
		t.Fatalf("protect_content: want true, got false")
	}
	if req.DisableNotification {
		t.Fatalf("disable_notification: want false (alarms ring), got true")
	}
}

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

	sink, _ := New(sinkCfg(srv.URL))
	_ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
	_, _, _, ct, _ := rs.snapshot()
	if !strings.Contains(ct, "application/json") {
		t.Fatalf("content-type: want application/json, got %q", ct)
	}
}

func TestSendNoBasicAuthHeader(t *testing.T) {
	// telegram uses the bot token in the URL path, NOT a Basic auth header.
	// a Basic header would be a token-leak surface: proxies log headers, URLs
	// less so.
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(sinkCfg(srv.URL))
	_ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
	_, _, _, _, auth := rs.snapshot()
	if auth != "" {
		t.Fatalf("want no Authorization header (token in URL), got %q", auth)
	}
}

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

func TestSendReturnsErrorOnTelegramError(t *testing.T) {
	// telegram returns non-2xx with JSON {"ok":false,"error_code":401,...} on
	// auth failure. the sink must surface error_code + description.
	rs := newRecordingServer(t, http.StatusUnauthorized, `{"ok":false,"error_code":401,"description":"Unauthorized"}`)
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(sinkCfg(srv.URL))
	err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
	if err == nil {
		t.Fatal("want error on telegram 401")
	}
	if !strings.Contains(err.Error(), "401") {
		t.Fatalf("error should mention error_code 401, got: %v", err)
	}
}

func TestSendReturnsErrorOnNon2xx(t *testing.T) {
	rs := newRecordingServer(t, http.StatusBadGateway, "bad gateway")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(sinkCfg(srv.URL))
	err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
	if err == nil {
		t.Fatal("want error on 502")
	}
}

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

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

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

// --------------------------- token redaction --------------------------------

// realToken — shaped like a real BotFather token, unlike sinkCfg's "123:abc".
// The redaction tests need something long and distinctive enough that finding
// it in an error message is unambiguous.
const realToken = "7556767480:AAFh0vLU9sg8l7DwXU9y-VZQquSJKW3lsVQ"

// A transport error renders the whole request URL, and telegram accepts the
// token nowhere but the URL path. On 2026-08-01 that put the live bot token in
// `docker compose logs mavend` once a minute while egress was down.
func TestSendTransportErrorRedactsToken(t *testing.T) {
	cases := []struct {
		name string
		run  func(*Sink) error
	}{
		{"connection refused", func(s *Sink) error {
			return s.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
		}},
		{"context cancel", func(s *Sink) error {
			ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
			defer cancel()
			return s.Send(ctx, nudgeSendable(loop.Sev4, "down"))
		}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			cfg := sinkCfg("http://127.0.0.1:1")
			cfg.BotToken = realToken
			cfg.Timeout = time.Second
			sink, _ := New(cfg)

			err := tc.run(sink)
			if err == nil {
				t.Fatal("want a transport error")
			}
			if strings.Contains(err.Error(), realToken) {
				t.Fatalf("token leaked into error: %v", err)
			}
			if !strings.Contains(err.Error(), tokenPlaceholder) {
				t.Fatalf("want %q in the redacted error, got: %v", tokenPlaceholder, err)
			}
		})
	}
}

// The structural branch keeps the error's type so errors.As still matches.
func TestRedactPreservesURLErrorType(t *testing.T) {
	cfg := sinkCfg("http://127.0.0.1:1")
	cfg.BotToken = realToken
	cfg.Timeout = time.Second
	sink, _ := New(cfg)

	err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
	var ue *url.Error
	if !errors.As(err, &ue) {
		t.Fatalf("want *url.Error to survive redaction, got %T: %v", err, err)
	}
	if strings.Contains(ue.URL, realToken) {
		t.Fatalf("token left in url.Error.URL: %s", ue.URL)
	}
}

// Nothing to redact must not disturb the error.
func TestRedactLeavesCleanErrorsAlone(t *testing.T) {
	sink, _ := New(sinkCfg("http://127.0.0.1:1"))
	in := errors.New("dial tcp: no route to host")
	if got := sink.redact(in); got != in {
		t.Fatalf("want the same error back, got %v", got)
	}
	if sink.redact(nil) != nil {
		t.Fatal("want nil for nil")
	}
}

// ----------------------------- proxy seam -----------------------------------

func TestProxyWiredIntoTransport(t *testing.T) {
	// region restriction: homesrv can't reach api.telegram.org directly.
	// the proxy URL configured must land on the http.Transport.Proxy so the
	// stdlib dials the relay first. this is the only thing the sink needs to
	// do for the region cut — Transport.Proxy handles HTTP/HTTPS/SOCKS5.
	sink, err := New(Config{
		BotToken: "123:abc",
		ChatID:   "42",
		Proxy:    "socks5://127.0.0.1:1080",
	})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	tr, ok := sink.hc.Transport.(*http.Transport)
	if !ok {
		t.Fatalf("transport: want *http.Transport, got %T", sink.hc.Transport)
	}
	if tr.Proxy == nil {
		t.Fatal("transport.Proxy not set")
	}
	pu, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "api.telegram.org"}})
	if err != nil {
		t.Fatalf("proxy fn: %v", err)
	}
	if pu == nil || pu.Host != "127.0.0.1:1080" || pu.Scheme != "socks5" {
		t.Fatalf("proxy url: want socks5://127.0.0.1:1080, got %v", pu)
	}
}

func TestSendRoutesThroughProxyWhenConfigured(t *testing.T) {
	// end-to-end: a fake proxy (httptest) records that the transport routes
	// through it. the fake telegram API is rigged to t.Fatalf if reached
	// directly — proving the proxy actually carried the request, not just that
	// Transport.Proxy is set (covered by the previous test).
	proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{"ok":true}`))
	}))
	defer proxySrv.Close()

	telegramSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Fatalf("direct telegram API reached — proxy was bypassed")
	}))
	defer telegramSrv.Close()

	cfg := sinkCfg(telegramSrv.URL)
	cfg.Proxy = proxySrv.URL
	sink, err := New(cfg)
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
		t.Fatalf("Send: %v", err)
	}
	// reaching here means the proxy carried the request; the direct telegram
	// endpoint's handler never fired.
}

// ----------------------------- reminder same shape --------------------------

func TestReminderSendUsesSamePath(t *testing.T) {
	// reminders away route to ntfy, not telegram — but if the daemon ever
	// routes a reminder via telegram (per-reminder override), the sink must
	// accept KindReminder undamaged. exercises the kind-agnostic contract.
	rs := newRecordingServer(t, 200, "")
	srv := httptest.NewServer(rs.handler())
	defer srv.Close()

	sink, _ := New(sinkCfg(srv.URL))
	if err := sink.Send(context.Background(), reminderSendable("wake up")); err != nil {
		t.Fatalf("Send: %v", err)
	}
	_, _, body, _, _ := rs.snapshot()
	var req sendMessageReq
	_ = json.Unmarshal([]byte(body), &req)
	if req.Text != "wake up" {
		t.Fatalf("reminder text: want 'wake up', got %q", req.Text)
	}
}
