package delivery

import (
	"context"
	"errors"
	"path/filepath"
	"testing"
	"time"

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

// panicSink — a sink that dies mid-send. Models the ugly case: the process is
// still alive, so startup reconciliation will not run, but the attempt row was
// already begun.
type panicSink struct{ calls int }

func (p *panicSink) Send(_ context.Context, _ Sendable) error {
	p.calls++
	panic("sink exploded mid-send")
}

// ------------------------- voice fallthrough, per severity -------------------

// TestVoiceNoSessionFallthroughLeavesOutboxTrail — the fallthrough must be
// visible in the ledger too: the voice attempt closes as failed and the away
// attempt is a separate row, so an operator can see the reroute happened.
func TestVoiceNoSessionFallthroughLeavesOutboxTrail(t *testing.T) {
	cases := []struct {
		name    string
		sev     loop.Severity
		wantAt  []string // channel per outbox attempt, in order
		wantEnd []string // status per attempt, in order
	}{
		{"sev3 falls through to ntfy", loop.Sev3,
			[]string{"voice", "ntfy"}, []string{store.DeliveryFailed, store.DeliverySent}},
		{"sev4 falls through to telegram", loop.Sev4,
			[]string{"voice", "telegram"}, []string{store.DeliveryFailed, store.DeliverySent}},
		// care severities still don't reach an away channel; since #370 the
		// drop itself is a visible row instead of nothing.
		{"sev1 drops instead of falling through", loop.Sev1,
			[]string{"voice", "drop"}, []string{store.DeliveryFailed, store.DeliveryDropped}},
		{"sev2 drops instead of falling through", loop.Sev2,
			[]string{"voice", "drop"}, []string{store.DeliveryFailed, store.DeliveryDropped}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			voice := &fakeSink{err: ErrVoiceNoSession}
			ntfy, telegram := &fakeSink{}, &fakeSink{}
			ob := &fakeOutbox{}
			d := NewDispatcher(Config{
				Voice: voice, Ntfy: ntfy, Telegram: telegram,
				Ack: newFakeAck(), Outbox: ob,
			})

			if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
				Candidate: candidate("some_rule", c.sev, store.Present),
				Body:      "detail", Summary: "short",
			}, refNow()); err != nil {
				t.Fatalf("dispatch: %v", err)
			}
			if len(ob.attempts) != len(c.wantAt) {
				t.Fatalf("want %d outbox attempts, got %d (%+v)", len(c.wantAt), len(ob.attempts), ob.attempts)
			}
			for i, a := range ob.attempts {
				if a.channel != c.wantAt[i] || a.status != c.wantEnd[i] {
					t.Fatalf("attempt %d: want %s/%s, got %s/%s", i, c.wantAt[i], c.wantEnd[i], a.channel, a.status)
				}
			}
			// care severities must not reach an away channel — that would
			// defeat the drop rule.
			if c.sev <= loop.Sev2 && (len(ntfy.sends) != 0 || len(telegram.sends) != 0) {
				t.Fatalf("care nudge escaped to an away channel: ntfy=%d telegram=%d",
					len(ntfy.sends), len(telegram.sends))
			}
		})
	}
}

// ------------------------- crash between Begin and Complete ------------------

// openTestStore — a real store on a temp file. The reconciliation promise is a
// SQL promise, so a fake would only test the fake.
func openTestStore(t *testing.T) *store.Store {
	t.Helper()
	st, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "maven.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })
	return st
}

// attemptStatus reads one attempt row back. Returns ok=false when the row is
// gone, which would itself be a broken promise (a dropped attempt).
//
// It goes through ListDeliveryAttempts rather than raw SQL. This helper used to
// reach past the store into store.DB, which was the tell that the outbox was
// write-only; the reader landed in V-390 and this caller was not moved over.
func attemptStatus(t *testing.T, st *store.Store, id int64) (status string, completed bool, ok bool) {
	t.Helper()
	attempts, err := st.ListDeliveryAttempts(context.Background(), "", 200)
	if err != nil {
		t.Fatalf("ListDeliveryAttempts: %v", err)
	}
	for _, a := range attempts {
		if a.ID == id {
			return a.Status, a.HasComplete, true
		}
	}
	return "", false, false
}

// TestCrashBetweenBeginAndCompleteBecomesUnknown — simulate the crash window:
// Begin lands, the process dies before Complete. Startup reconciliation must
// turn that row into "unknown" — neither resent nor dropped, because Maven
// cannot know whether the message left the box.
func TestCrashBetweenBeginAndCompleteBecomesUnknown(t *testing.T) {
	st := openTestStore(t)
	ctx := context.Background()
	sink := &fakeSink{}

	// the crash: intent recorded, no completion.
	id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "", "telegram", "hash", refNow())
	if err != nil {
		t.Fatalf("begin: %v", err)
	}
	if s, _, ok := attemptStatus(t, st, id); !ok || s != store.DeliveryPending {
		t.Fatalf("before reconcile: want pending, got %q ok=%v", s, ok)
	}

	// restart.
	n, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow().Add(time.Minute))
	if err != nil {
		t.Fatalf("reconcile: %v", err)
	}
	if n != 1 {
		t.Fatalf("want 1 row reconciled, got %d", n)
	}
	s, completed, ok := attemptStatus(t, st, id)
	if !ok {
		t.Fatal("reconciliation dropped the row; the promise is it is never dropped")
	}
	if s != store.DeliveryUnknown {
		t.Fatalf("want status unknown, got %q", s)
	}
	if !completed {
		t.Fatal("reconciled row should carry a completed_ts")
	}
	// not resent: reconciliation is bookkeeping only, it must never push.
	if len(sink.sends) != 0 {
		t.Fatalf("reconciliation must not resend, got %d sends", len(sink.sends))
	}

	// idempotent: a second restart must not churn the row again.
	n2, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow().Add(2*time.Minute))
	if err != nil {
		t.Fatalf("reconcile again: %v", err)
	}
	if n2 != 0 {
		t.Fatalf("second reconcile should find nothing, got %d", n2)
	}
	if s2, _, _ := attemptStatus(t, st, id); s2 != store.DeliveryUnknown {
		t.Fatalf("unknown must stay unknown, got %q", s2)
	}
}

// TestUnknownIsNeverResolvedToSentOrFailed — the "never guess" half of the
// promise: nothing may quietly turn an unknown into a definite outcome.
func TestUnknownIsNeverResolvedToSentOrFailed(t *testing.T) {
	st := openTestStore(t)
	ctx := context.Background()

	id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "", "telegram", "hash", refNow())
	if err != nil {
		t.Fatalf("begin: %v", err)
	}
	if _, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow()); err != nil {
		t.Fatalf("reconcile: %v", err)
	}
	// a late Complete from the old in-flight send must not win.
	if err := st.CompleteDeliveryAttempt(ctx, id, store.DeliverySent, refNow().Add(time.Minute)); err != nil {
		t.Fatalf("late complete: %v", err)
	}
	if s, _, _ := attemptStatus(t, st, id); s != store.DeliveryUnknown {
		t.Fatalf("late complete overwrote an unknown outcome: %q", s)
	}
}

// ------------------------- the boring failure modes --------------------------

// TestSendTimeoutResolvesTheAttempt — a send that times out is a definite
// failure from Maven's side, so the row must not be left pending.
func TestSendTimeoutResolvesTheAttempt(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel() // the deadline already blew
	ob := &fakeOutbox{}
	d := NewDispatcher(Config{Ntfy: &fakeSink{err: context.DeadlineExceeded}, Outbox: ob})

	if _, err := d.DispatchNudge(ctx, PhrasedNudge{
		Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
		Body:      "detail", Summary: "short",
	}, refNow()); err == nil {
		t.Fatal("want a timeout error to propagate")
	}
	if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryFailed {
		t.Fatalf("timed-out send must close the attempt as failed, got %+v", ob.attempts)
	}
}

// TestCompleteFailureLeavesRowPendingForReconciliation — if Complete itself
// fails, the row stays pending on purpose. That is the correct ambiguous state
// and startup reconciliation is what resolves it.
func TestCompleteFailureLeavesRowPendingForReconciliation(t *testing.T) {
	ob := &fakeOutbox{completeErr: errors.New("db busy")}
	d := NewDispatcher(Config{Ntfy: &fakeSink{}, Outbox: ob})

	if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
		Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
		Body:      "detail", Summary: "short",
	}, refNow()); err != nil {
		t.Fatalf("a failed outbox complete must not fail the dispatch: %v", err)
	}
	if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryPending {
		t.Fatalf("want the row left pending, got %+v", ob.attempts)
	}
}

// The panic gap this file used to describe as a skipped test is fixed and
// asserted for real in dispatcher_test.go:TestPanicMidSendResolvesTheAttempt.
// panicSink stays here because both files use it.
