package service

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
)

func TestRenderWebhookBody(t *testing.T) {
	msg := notifyMsg{Title: "任务失败", Text: "line1\nline\"2\""}
	tests := []struct {
		name string
		tpl  string
		want string
	}{
		{
			name: "空模板用缺省 Slack 形状",
			tpl:  "",
			want: `{"text": "任务失败\nline1\nline\"2\""}`,
		},
		{
			name: "自定义模板占位替换并转义",
			tpl:  `{"msgtype":"text","text":{"content":"{{text}}"}}`,
			want: `{"msgtype":"text","text":{"content":"line1\nline\"2\""}}`,
		},
		{
			name: "无占位原样输出",
			tpl:  `{"fixed":1}`,
			want: `{"fixed":1}`,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := renderWebhookBody(tt.tpl, msg)
			if got != tt.want {
				t.Errorf("renderWebhookBody = %s, want %s", got, tt.want)
			}
			if !json.Valid([]byte(got)) {
				t.Errorf("渲染结果不是合法 JSON: %s", got)
			}
		})
	}
}

// jsonCapture 收集一次 HTTP 请求的路径、认证头与 JSON body。
type jsonCapture struct {
	mu     sync.Mutex
	path   string
	auth   string
	fields map[string]string
}

// newCaptureServer 起假渠道服务端,固定返回 status。
func newCaptureServer(t *testing.T, status int) (*httptest.Server, *jsonCapture) {
	t.Helper()
	rec := &jsonCapture{}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rec.mu.Lock()
		defer rec.mu.Unlock()
		rec.path = r.URL.Path
		rec.auth = r.Header.Get("Authorization")
		rec.fields = map[string]string{}
		_ = json.NewDecoder(r.Body).Decode(&rec.fields)
		w.WriteHeader(status)
	}))
	t.Cleanup(srv.Close)
	return srv, rec
}

func TestSendNtfy(t *testing.T) {
	srv, rec := newCaptureServer(t, http.StatusOK)
	cfg := NtfyChannel{Server: srv.URL, Topic: "oci", Token: "tk-1"}
	msg := notifyMsg{Title: "标题", Text: "正文"}
	if err := sendNtfy(context.Background(), srv.Client(), cfg, msg); err != nil {
		t.Fatalf("sendNtfy: %v", err)
	}
	if rec.auth != "Bearer tk-1" {
		t.Errorf("Authorization = %q, want Bearer tk-1", rec.auth)
	}
	want := map[string]string{"topic": "oci", "title": "标题", "message": "正文"}
	for k, v := range want {
		if rec.fields[k] != v {
			t.Errorf("body[%s] = %q, want %q", k, rec.fields[k], v)
		}
	}
}

func TestSendBark(t *testing.T) {
	srv, rec := newCaptureServer(t, http.StatusOK)
	cfg := BarkChannel{Server: srv.URL, DeviceKey: "dk-1"}
	if err := sendBark(context.Background(), srv.Client(), cfg, notifyMsg{Title: "标题", Text: "正文"}); err != nil {
		t.Fatalf("sendBark: %v", err)
	}
	if rec.path != "/push" {
		t.Errorf("path = %q, want /push", rec.path)
	}
	want := map[string]string{"title": "标题", "body": "正文", "device_key": "dk-1"}
	for k, v := range want {
		if rec.fields[k] != v {
			t.Errorf("body[%s] = %q, want %q", k, rec.fields[k], v)
		}
	}
}

func TestPostJSONNon2xx(t *testing.T) {
	srv, _ := newCaptureServer(t, http.StatusBadGateway)
	err := postJSON(context.Background(), srv.Client(), srv.URL, "", []byte(`{}`))
	if err == nil || !strings.Contains(err.Error(), "502") {
		t.Fatalf("postJSON err = %v, want contains 502", err)
	}
}

func TestValidNotifyURL(t *testing.T) {
	tests := []struct {
		raw     string
		wantErr bool
	}{
		{"https://example.com/hook", false},
		{"http://10.0.0.1:8080/x", false},
		{"ftp://example.com", true},
		{"not-a-url", true},
		{"", true},
	}
	for _, tt := range tests {
		if err := validNotifyURL(tt.raw); (err != nil) != tt.wantErr {
			t.Errorf("validNotifyURL(%q) err = %v, wantErr %v", tt.raw, err, tt.wantErr)
		}
	}
}

// fakeSMTP 起一个最小 SMTP 服务端,按标准会话应答并收集 DATA 报文。
func fakeSMTP(t *testing.T) (addr string, got *strings.Builder) {
	t.Helper()
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	t.Cleanup(func() { ln.Close() })
	got = &strings.Builder{}
	go func() {
		conn, err := ln.Accept()
		if err != nil {
			return
		}
		defer conn.Close()
		fmt.Fprint(conn, "220 fake ESMTP\r\n")
		r := bufio.NewReader(conn)
		for {
			line, err := r.ReadString('\n')
			if err != nil {
				return
			}
			cmd := strings.ToUpper(strings.TrimSpace(line))
			switch {
			case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
				fmt.Fprint(conn, "250-fake\r\n250 AUTH PLAIN\r\n")
			case strings.HasPrefix(cmd, "AUTH"):
				fmt.Fprint(conn, "235 ok\r\n")
			case strings.HasPrefix(cmd, "MAIL"), strings.HasPrefix(cmd, "RCPT"):
				fmt.Fprint(conn, "250 ok\r\n")
			case cmd == "DATA":
				fmt.Fprint(conn, "354 go\r\n")
				readSMTPData(r, got)
				fmt.Fprint(conn, "250 ok\r\n")
			case cmd == "QUIT":
				fmt.Fprint(conn, "221 bye\r\n")
				return
			default:
				fmt.Fprint(conn, "250 ok\r\n")
			}
		}
	}()
	return ln.Addr().String(), got
}

// readSMTPData 读取 DATA 段直到单独一行 "."。
func readSMTPData(r *bufio.Reader, got *strings.Builder) {
	for {
		line, err := r.ReadString('\n')
		if err != nil || strings.TrimRight(line, "\r\n") == "." {
			return
		}
		got.WriteString(line)
	}
}

func TestSendSMTP(t *testing.T) {
	addr, got := fakeSMTP(t)
	host, portStr, _ := net.SplitHostPort(addr)
	var port int
	fmt.Sscanf(portStr, "%d", &port)
	cfg := SMTPChannel{
		Host: host, Port: port,
		Username: "u", Password: "p",
		From: "a@x.com", To: "b@y.com",
	}
	err := sendSMTP(context.Background(), cfg, notifyMsg{Title: "任务失败", Text: "第一行\n第二行"})
	if err != nil {
		t.Fatalf("sendSMTP: %v", err)
	}
	mail := got.String()
	for _, want := range []string{"From: a@x.com", "To: b@y.com", "Subject: =?utf-8?q?", "第一行\r\n第二行"} {
		if !strings.Contains(mail, want) {
			t.Errorf("mail 缺少 %q:\n%s", want, mail)
		}
	}
}

// TestDispatchFanout 验证多渠道并发投递:一个渠道失败不影响其他渠道送达。
func TestDispatchFanout(t *testing.T) {
	okSrv, okRec := newCaptureServer(t, http.StatusOK)
	badSrv, badRec := newCaptureServer(t, http.StatusInternalServerError)
	settings, _ := newSettingEnv(t)
	ctx := context.Background()
	mustUpdateChannel(t, settings, ChannelWebhook, UpdateNotifyChannelInput{Enabled: true, URL: badSrv.URL})
	mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Enabled: true, Server: okSrv.URL, Topic: "oci"})
	n := NewNotifier(settings)

	err := n.dispatch(ctx, notifyMsg{Title: "T", Text: "body"})
	if err == nil || !strings.Contains(err.Error(), "webhook") {
		t.Fatalf("dispatch err = %v, want webhook 失败", err)
	}
	if badRec.fields == nil {
		t.Error("webhook 渠道未收到请求")
	}
	if okRec.fields["message"] != "body" {
		t.Errorf("ntfy 渠道未正常送达: %v", okRec.fields)
	}
}

// TestDispatchSkipsDisabled 验证未启用/未配置的渠道静默跳过且不报错。
func TestDispatchSkipsDisabled(t *testing.T) {
	srv, rec := newCaptureServer(t, http.StatusOK)
	settings, _ := newSettingEnv(t)
	mustUpdateChannel(t, settings, ChannelWebhook, UpdateNotifyChannelInput{Enabled: false, URL: srv.URL})
	n := NewNotifier(settings)
	if err := n.dispatch(context.Background(), notifyMsg{Title: "T", Text: "x"}); err != nil {
		t.Fatalf("dispatch: %v", err)
	}
	if rec.fields != nil {
		t.Error("未启用渠道不应收到请求")
	}
}

// mustUpdateChannel 保存渠道配置,失败即终止测试。
func mustUpdateChannel(t *testing.T, s *SettingService, chType string, in UpdateNotifyChannelInput) {
	t.Helper()
	if err := s.UpdateNotifyChannel(context.Background(), chType, in); err != nil {
		t.Fatalf("update channel %s: %v", chType, err)
	}
}

func TestUpdateNotifyChannelValidation(t *testing.T) {
	secret := "sec-123456"
	tests := []struct {
		name    string
		chType  string
		in      UpdateNotifyChannelInput
		wantErr string
	}{
		{name: "未知类型", chType: "pigeon", wantErr: "未知通知渠道"},
		{name: "启用 webhook 缺 URL", chType: ChannelWebhook, in: UpdateNotifyChannelInput{Enabled: true}, wantErr: "URL"},
		{name: "webhook 非法协议", chType: ChannelWebhook, in: UpdateNotifyChannelInput{Enabled: true, URL: "ftp://x"}, wantErr: "http(s)"},
		{name: "启用 ntfy 缺 topic", chType: ChannelNtfy, in: UpdateNotifyChannelInput{Enabled: true}, wantErr: "topic"},
		{name: "启用 smtp 缺主机", chType: ChannelSMTP, in: UpdateNotifyChannelInput{Enabled: true, Port: 587}, wantErr: "SMTP"},
		{name: "smtp 端口越界", chType: ChannelSMTP, in: UpdateNotifyChannelInput{Enabled: true, Host: "h", Port: 70000, From: "a@x", To: "b@y"}, wantErr: "端口"},
		{name: "未启用可存草稿", chType: ChannelNtfy, in: UpdateNotifyChannelInput{Enabled: false}},
		{name: "配置齐全可启用", chType: ChannelBark, in: UpdateNotifyChannelInput{Enabled: true, Secret: &secret}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			settings, _ := newSettingEnv(t)
			err := settings.UpdateNotifyChannel(context.Background(), tt.chType, tt.in)
			if tt.wantErr == "" && err != nil {
				t.Fatalf("UpdateNotifyChannel: %v", err)
			}
			if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
				t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
			}
		})
	}
}

// TestNotifyChannelSecretLifecycle 验证密文字段的存取语义:
// 保存后视图只回 set/tail,nil 沿用旧值,空串清除。
func TestNotifyChannelSecretLifecycle(t *testing.T) {
	settings, _ := newSettingEnv(t)
	ctx := context.Background()
	secret := "ntfy-token-9876"
	mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t", Secret: &secret})

	view := mustChannelView(t, settings, ChannelNtfy)
	if !view.SecretSet || view.SecretTail != "9876" {
		t.Fatalf("view = %+v, want SecretSet 且尾号 9876", view)
	}
	cfg, err := settings.ntfyChannel(ctx)
	if err != nil || cfg.Token != secret {
		t.Fatalf("ntfyChannel token = %q err = %v, want 原文", cfg.Token, err)
	}

	// nil 沿用
	mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t2"})
	if cfg, _ := settings.ntfyChannel(ctx); cfg.Token != secret || cfg.Topic != "t2" {
		t.Fatalf("nil Secret 应沿用旧 token,got %+v", cfg)
	}

	// 空串清除
	empty := ""
	mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t2", Secret: &empty})
	if cfg, _ := settings.ntfyChannel(ctx); cfg.Token != "" {
		t.Fatalf("空串 Secret 应清除,got %q", cfg.Token)
	}
	if view := mustChannelView(t, settings, ChannelNtfy); view.SecretSet {
		t.Fatal("清除后 SecretSet 应为 false")
	}
}

// mustChannelView 取单渠道脱敏视图。
func mustChannelView(t *testing.T, s *SettingService, chType string) NotifyChannelView {
	t.Helper()
	views, err := s.NotifyChannels(context.Background())
	if err != nil {
		t.Fatalf("NotifyChannels: %v", err)
	}
	for _, v := range views {
		if v.Type == chType {
			return v
		}
	}
	t.Fatalf("view %s not found", chType)
	return NotifyChannelView{}
}
