package delivery

import (
	"context"
	"testing"
	"time"

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

// This file walks every cell of the docs/design.md § "Delivery / channel routing"
// table, once as the pure table and once through the dispatcher, so a change
// to either side has to break a named cell.
//
//	              present         away
//	sev1-2 (care) voice           drop
//	sev3   (soft) voice           ntfy, once
//	sev4   (hard) voice + ntfy    telegram, repeat til ack

type tableCell struct {
	name     string
	sev      loop.Severity
	presence store.Bucket
	want     []Channel
}

func allTableCells() []tableCell {
	return []tableCell{
		{"sev1 present", loop.Sev1, store.Present, []Channel{ChannelVoice}},
		{"sev2 present", loop.Sev2, store.Present, []Channel{ChannelVoice}},
		{"sev3 present", loop.Sev3, store.Present, []Channel{ChannelVoice}},
		{"sev4 present", loop.Sev4, store.Present, []Channel{ChannelVoice, ChannelNtfy}},
		{"sev1 away", loop.Sev1, store.Away, []Channel{ChannelDrop}},
		{"sev2 away", loop.Sev2, store.Away, []Channel{ChannelDrop}},
		{"sev3 away", loop.Sev3, store.Away, []Channel{ChannelNtfy}},
		{"sev4 away", loop.Sev4, store.Away, []Channel{ChannelTelegram}},
	}
}

func sameChannels(got, want []Channel) bool {
	if len(got) != len(want) {
		return false
	}
	for i := range got {
		if got[i] != want[i] {
			return false
		}
	}
	return true
}

func TestChannelsForEveryTableCell(t *testing.T) {
	for _, c := range allTableCells() {
		t.Run(c.name, func(t *testing.T) {
			got := ChannelsFor(c.sev, c.presence)
			if !sameChannels(got, c.want) {
				t.Fatalf("%s: want %v, got %v", c.name, c.want, got)
			}
		})
	}
}

// TestDispatchNudgeEveryTableCell — the same eight cells end to end: exactly
// the wanted channels get a send, and every other channel gets none.
func TestDispatchNudgeEveryTableCell(t *testing.T) {
	for _, c := range allTableCells() {
		t.Run(c.name, func(t *testing.T) {
			voice, ntfy, telegram := &fakeSink{}, &fakeSink{}, &fakeSink{}
			rec := &fakeNudgeRecorder{}
			d := NewDispatcher(Config{
				Voice: voice, Ntfy: ntfy, Telegram: telegram,
				Ack: newFakeAck(), Nudges: rec,
			})

			out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
				Candidate: candidate("some_rule", c.sev, c.presence),
				Body:      "full detail body",
				Summary:   "short form",
			}, refNow())
			if err != nil {
				t.Fatalf("dispatch: %v", err)
			}

			sent := map[Channel]int{
				ChannelVoice:    len(voice.sends),
				ChannelNtfy:     len(ntfy.sends),
				ChannelTelegram: len(telegram.sends),
			}
			for ch, n := range sent {
				want := 0
				for _, w := range c.want {
					if w == ch {
						want = 1
					}
				}
				if n != want {
					t.Fatalf("%s: channel %s got %d sends, want %d", c.name, ch, n, want)
				}
			}

			// one dispatch and one nudge row per real (non-drop) channel.
			wantDispatches := 0
			for _, w := range c.want {
				if w != ChannelDrop {
					wantDispatches++
				}
			}
			if len(out) != wantDispatches {
				t.Fatalf("%s: want %d dispatches, got %d", c.name, wantDispatches, len(out))
			}
			if len(rec.rows) != wantDispatches {
				t.Fatalf("%s: want %d nudge rows, got %d", c.name, wantDispatches, len(rec.rows))
			}
		})
	}
}

// TestSev3AwayIsNtfyExactlyOnce — "ntfy, once": one send, and nothing on the
// sendable asks for a repeat, so the daemon's repeat driver has no reason to
// pick it up.
func TestSev3AwayIsNtfyExactlyOnce(t *testing.T) {
	ntfy := &fakeSink{}
	ack := newFakeAck()
	d := NewDispatcher(Config{Ntfy: ntfy, Telegram: &fakeSink{}, Ack: ack})

	out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
		Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
		Body:      "cert detail", Summary: "cert expiring",
	}, refNow())
	if err != nil {
		t.Fatalf("dispatch: %v", err)
	}
	if len(ntfy.sends) != 1 {
		t.Fatalf("sev3 away: want exactly 1 ntfy send, got %d", len(ntfy.sends))
	}
	if out[0].Sendable.RepeatUntilAck {
		t.Fatalf("sev3 away must not repeat til ack")
	}
	if _, ok := ack.lastSent["cert_expiring"]; ok {
		t.Fatalf("sev3 away must not enter the ack/repeat tracker")
	}
}

// TestSev4AwayRepeatsUntilAcked — "telegram, repeat til ack": the initial send
// arms the ack clock, the repeat driver re-sends while un-acked, and an ack
// stops it.
func TestSev4AwayRepeatsUntilAcked(t *testing.T) {
	telegram := &fakeSink{}
	ack := newFakeAck()
	d := NewDispatcher(Config{Telegram: telegram, Ack: ack})
	ctx := context.Background()

	if _, err := d.DispatchNudge(ctx, PhrasedNudge{
		Candidate: candidate("disk_low", loop.Sev4, store.Away),
		Body:      "disk detail", Summary: "disk low on homesrv",
	}, refNow()); err != nil {
		t.Fatalf("dispatch: %v", err)
	}

	// two intervals pass, still un-acked → two more sends.
	for i := 1; i <= 2; i++ {
		at := refNow().Add(time.Duration(i) * 10 * time.Minute)
		if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, at, 5*time.Minute, "disk detail", "disk low on homesrv"); err != nil {
			t.Fatalf("repeat %d: %v", i, err)
		}
	}
	if len(telegram.sends) != 3 {
		t.Fatalf("want 1 initial + 2 repeats = 3 telegram sends, got %d", len(telegram.sends))
	}

	// acked → no further sends, however long we wait.
	_ = ack.MarkAcked(ctx, "disk_low")
	if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, refNow().Add(time.Hour), 5*time.Minute, "b", "s"); err != nil {
		t.Fatalf("repeat after ack: %v", err)
	}
	if len(telegram.sends) != 3 {
		t.Fatalf("ack must stop the repeat; got %d sends", len(telegram.sends))
	}
}

// TestAwayChannelsGetMinimalBody — what leaves the box is the short form, for
// every away cell of the table. messageForChannel is the last-mile choice both
// away sinks make too.
func TestAwayChannelsGetMinimalBody(t *testing.T) {
	detail := "disk /mnt/hdd1 on homesrv at 97% — 12GB free, biggest offender /var/lib/docker"
	short := "disk low on homesrv"

	for _, ch := range []Channel{ChannelNtfy, ChannelTelegram} {
		t.Run(string(ch), func(t *testing.T) {
			msg := messageForChannel(Sendable{Channel: ch, Body: detail, Summary: short})
			if msg != short {
				t.Fatalf("%s message: want %q, got %q", ch, short, msg)
			}
		})
	}
	if got := messageForChannel(Sendable{Channel: ChannelVoice, Body: detail, Summary: short}); got != detail {
		t.Fatalf("voice is local and gets the full body, got %q", got)
	}
}

// Both away-detail gaps this file used to describe as skipped tests are now
// fixed and asserted for real in dispatcher_test.go:
// TestSev4AwaySendableCarriesNoDetail and TestAwaySendsGenericLineWhenSummaryEmpty.
// An empty Summary no longer means "send the whole body" — it means a short
// generic line — so the old expectation here was wrong as well as duplicated.

// TestCareAwayDropIsRecorded — docs/design.md's drop is a decision ("a missed water
// nudge is noise, a missed backup failure isn't"), so it should be visible
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
// attempt, no log — nothing an operator can see afterwards. now it leaves a
// 'dropped' outbox row.
func TestCareAwayDropIsRecorded(t *testing.T) {
	ob := &fakeOutbox{}
	d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob})

	if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
		Candidate: candidate("water", loop.Sev1, store.Away),
		Body:      "drink water", Summary: "water",
	}, refNow()); err != nil {
		t.Fatalf("dispatch: %v", err)
	}
	if len(ob.attempts) != 1 || ob.attempts[0].channel != string(ChannelDrop) {
		t.Fatalf("care-away drop should leave a visible record, got %+v", ob.attempts)
	}
}
