package notify

import (
	"io"
	"log"
	"strings"
	"sync"
	"testing"
)

// capturedMail is one seam-captured send: recipient, subject and headers.
type capturedMail struct {
	to      string
	subject string
	headers map[string]string
}

// captureSeam installs a capturing sendEmailFn and returns the capture slice pointer.
func captureSeam(d *Dispatcher) *[]capturedMail {
	var mu sync.Mutex
	sent := &[]capturedMail{}
	d.sendEmailFn = func(to, subject, _ string, headers map[string]string) error {
		mu.Lock()
		defer mu.Unlock()
		*sent = append(*sent, capturedMail{to: to, subject: subject, headers: headers})
		return nil
	}
	return sent
}

func mailsFor(sent []capturedMail, to string) []capturedMail {
	var out []capturedMail
	for _, m := range sent {
		if m.to == to {
			out = append(out, m)
		}
	}
	return out
}

// TestRecovery_PairedCustomerMail (Scenario A — the doodoo21 case, fixed): a customer who was
// mailed node_down gets the node_recovered mail; the operator gets the ✅ mail; both are logged.
func TestRecovery_PairedCustomerMail(t *testing.T) {
	st := newDispStore(t)
	if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"node_down"}, 6); err != nil {
		t.Fatal(err)
	}
	d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
	sent := captureSeam(d)

	// The down edge: customer (enabled) + operator both mailed.
	d.ProcessEvent("c1", "node_down", "error", "No report received for 1h", "{}", "hub")
	if len(*sent) != 2 {
		t.Fatalf("down edge must mail operator + customer, got %d sends", len(*sent))
	}

	// The recovery edge.
	d.ProcessEvent("c1", "node_recovered", "info", "Reports resumed (was down for 3h48m)", "{}", "hub")

	cust := mailsFor(*sent, "cust@example.com")
	if len(cust) != 2 {
		t.Fatalf("customer must get down + recovery, got %d customer mails", len(cust))
	}
	recMail := cust[1]
	if !strings.Contains(recMail.subject, "Információ: A szerver újra elérhető.") {
		t.Fatalf("customer recovery subject wrong: %q", recMail.subject)
	}
	op := mailsFor(*sent, "op@felhom.eu")
	if len(op) != 2 {
		t.Fatalf("operator must get down + recovery, got %d", len(op))
	}
	if !strings.Contains(op[1].subject, "✅") || !strings.Contains(op[1].subject, "node_recovered") {
		t.Fatalf("operator recovery subject must carry ✅ + node_recovered: %q", op[1].subject)
	}

	// notification_log holds sent rows on both channels for node_recovered.
	entries, err := st.GetRecentNotifications("c1", 10)
	if err != nil {
		t.Fatal(err)
	}
	var gotCust, gotOp bool
	for _, e := range entries {
		if e.EventType == "node_recovered" && e.Status == "sent" {
			switch e.Channel {
			case "customer":
				gotCust = true
			case "operator":
				gotOp = true
			}
		}
	}
	if !gotCust || !gotOp {
		t.Fatalf("node_recovered sent rows missing (customer=%v operator=%v)", gotCust, gotOp)
	}
}

// TestRecovery_UnpairedStaysCustomerSilent (Scenario B): no customer-channel down evidence → the
// operator is mailed, the customer is NOT — even with node_recovered in enabled_events (the
// pairing rule is THE customer gate; enabled_events is deliberately ignored for recovery).
// Companion red-proof: removing the pairing check in processRecovery makes this fail.
func TestRecovery_UnpairedStaysCustomerSilent(t *testing.T) {
	st := newDispStore(t)
	if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"node_down", "node_recovered"}, 6); err != nil {
		t.Fatal(err)
	}
	d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
	sent := captureSeam(d)

	d.ProcessEvent("c1", "node_recovered", "info", "Reports resumed", "{}", "hub")

	if n := len(mailsFor(*sent, "cust@example.com")); n != 0 {
		t.Fatalf("unpaired recovery must NOT mail the customer, got %d", n)
	}
	if n := len(mailsFor(*sent, "op@felhom.eu")); n != 1 {
		t.Fatalf("operator must still get the recovery mail, got %d", n)
	}
}

// TestRecovery_FlapDamping (Scenario C): down mailed → recovery mailed → second down SUPPRESSED by
// the customer cooldown → second recovery must NOT mail the customer (the suppressed down left no
// fresh pairing evidence), and the operator recovery mail obeys the 1h per-type cooldown.
func TestRecovery_FlapDamping(t *testing.T) {
	st := newDispStore(t)
	if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"node_down"}, 6); err != nil {
		t.Fatal(err)
	}
	d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
	sent := captureSeam(d)

	d.ProcessEvent("c1", "node_down", "error", "down", "{}", "hub")      // down #1: cust+op mailed
	d.ProcessEvent("c1", "node_recovered", "info", "up", "{}", "hub")    // recovery #1: cust+op mailed
	d.ProcessEvent("c1", "node_down", "error", "down", "{}", "hub")      // down #2 10min later: customer
	//                                                                      cooldown suppresses (op too, 1h)
	// Clear the recovery CUSTOMER cooldown so the pairing gate — not the cooldown — decides #2:
	d.mu.Lock()
	delete(d.custCooldowns, "c1:node_recovered")
	d.mu.Unlock()
	d.ProcessEvent("c1", "node_recovered", "info", "up", "{}", "hub") // recovery #2

	cust := mailsFor(*sent, "cust@example.com")
	if len(cust) != 2 { // down #1 + recovery #1 only
		t.Fatalf("second recovery must not mail the customer (no fresh pairing), customer mails=%d: %+v", len(cust), cust)
	}
	// Operator: down #1 + recovery #1; down #2 and recovery #2 suppressed by the 1h op cooldown.
	if op := mailsFor(*sent, "op@felhom.eu"); len(op) != 2 {
		t.Fatalf("operator recovery must obey the 1h cooldown, op mails=%d", len(op))
	}
}

// TestTestEvent_BothChannels (Scenario F): a "test" event mails the customer (existing copy) AND
// the operator, the operator mail carrying both priority headers; both logged.
func TestTestEvent_BothChannels(t *testing.T) {
	st := newDispStore(t)
	if err := st.SaveNotificationPrefs("c1", "cust@example.com", nil, 6); err != nil {
		t.Fatal(err)
	}
	d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
	sent := captureSeam(d)

	d.ProcessEvent("c1", "test", "info", "", "{}", "hub")

	cust := mailsFor(*sent, "cust@example.com")
	if len(cust) != 1 || !strings.Contains(cust[0].subject, "Teszt értesítés") {
		t.Fatalf("customer test mail wrong: %+v", cust)
	}
	op := mailsFor(*sent, "op@felhom.eu")
	if len(op) != 1 || !strings.Contains(op[0].subject, "✅") || !strings.Contains(op[0].subject, "operator channel OK") {
		t.Fatalf("operator test mail wrong: %+v", op)
	}
	if op[0].headers["X-Priority"] != "1" || op[0].headers["Importance"] != "high" {
		t.Fatalf("operator test mail must carry priority headers, got %v", op[0].headers)
	}
	entries, _ := st.GetRecentNotifications("c1", 10)
	var chans []string
	for _, e := range entries {
		if e.EventType == "test" && e.Status == "sent" {
			chans = append(chans, e.Channel)
		}
	}
	if len(chans) != 2 {
		t.Fatalf("both test sends must be logged, got channels %v", chans)
	}
}

// TestTestEvent_NoPrefsRow_NoPanic: a test event for a customer WITHOUT a notification row must
// not panic (the nil-prefs deref found in v0.71.0 — GetNotificationPrefs returns (nil, nil)) and
// the operator copy still proves the operator channel.
func TestTestEvent_NoPrefsRow_NoPanic(t *testing.T) {
	st := newDispStore(t)
	d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
	sent := captureSeam(d)

	d.ProcessEvent("c1", "test", "info", "", "{}", "hub") // must not panic

	if n := len(mailsFor(*sent, "op@felhom.eu")); n != 1 {
		t.Fatalf("operator test copy must send even without customer prefs, got %d", n)
	}
	if n := len(*sent); n != 1 {
		t.Fatalf("no customer mail possible without prefs, total sends=%d", n)
	}
}

// TestPriorityHeaders (Scenario G): error/critical carry the two headers; warning/info/unknown nil.
// The negative half is the red-proof target: adding headers unconditionally fails the nil cases.
func TestPriorityHeaders(t *testing.T) {
	for _, sev := range []string{"error", "critical"} {
		h := priorityHeaders(sev)
		if h["X-Priority"] != "1" || h["Importance"] != "high" || len(h) != 2 {
			t.Errorf("%s: headers = %v", sev, h)
		}
	}
	for _, sev := range []string{"warning", "info", "", "frobnicate"} {
		if h := priorityHeaders(sev); h != nil {
			t.Errorf("%s must have NO priority headers, got %v", sev, h)
		}
	}
}

// TestPriorityHeaders_EndToEnd (Scenario G, seam level): an error mail carries headers on both
// channels; a warning mail carries none.
func TestPriorityHeaders_EndToEnd(t *testing.T) {
	st := newDispStore(t)
	if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"node_down", "node_stale"}, 6); err != nil {
		t.Fatal(err)
	}
	d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
	sent := captureSeam(d)

	d.ProcessEvent("c1", "node_down", "error", "down", "{}", "hub")
	for _, m := range *sent {
		if m.headers["X-Priority"] != "1" || m.headers["Importance"] != "high" {
			t.Fatalf("error mail to %s must carry priority headers, got %v", m.to, m.headers)
		}
	}

	*sent = (*sent)[:0]
	d.ProcessEvent("c1", "node_stale", "warning", "stale", "{}", "hub")
	if len(*sent) == 0 {
		t.Fatal("warning must still send")
	}
	for _, m := range *sent {
		if m.headers != nil {
			t.Fatalf("warning mail to %s must carry NO headers, got %v", m.to, m.headers)
		}
	}
}

// TestRecovery_SeverityStaysInfo guards the frozen semantics: the recovery branch must not have
// widened severityNotifies — a non-recovery info event is still silent.
func TestRecovery_SeverityStaysInfo(t *testing.T) {
	if severityNotifies("info") {
		t.Fatal("info must remain non-notify — recovery is an eventType branch, not a severity change")
	}
	st := newDispStore(t)
	if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"controller_started"}, 6); err != nil {
		t.Fatal(err)
	}
	d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
	sent := captureSeam(d)
	d.ProcessEvent("c1", "controller_started", "info", "started", "{}", "hub")
	if len(*sent) != 0 {
		t.Fatalf("non-recovery info must stay silent, got %d sends", len(*sent))
	}
}
