package store

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

func TestReminderPhraseAndRetrySurviveRestart(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "reminders.db")
	now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)

	s, err := Open(ctx, path)
	if err != nil {
		t.Fatal(err)
	}
	id, err := s.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"позвонить маме"}`, "")
	if err != nil {
		t.Fatal(err)
	}
	due, err := s.DueReminders(ctx, now)
	if err != nil || len(due) != 1 {
		t.Fatalf("initial due reminders = %d, err=%v", len(due), err)
	}
	if err := s.CacheReminderPhrase(ctx, due, "reminder:one", "Позвони маме.", "Позвони маме", "warm"); err != nil {
		t.Fatalf("cache phrase: %v", err)
	}
	if err := s.ScheduleReminderRetry(ctx, due, now); err != nil {
		t.Fatalf("schedule retry: %v", err)
	}
	if err := s.Close(); err != nil {
		t.Fatal(err)
	}

	s, err = Open(ctx, path)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = s.Close() })
	if got, err := s.DueReminders(ctx, now.Add(30*time.Second)); err != nil || len(got) != 0 {
		t.Fatalf("during retry wait: got %d due, err=%v", len(got), err)
	}
	due, err = s.DueReminders(ctx, now.Add(ReminderRetryBase))
	if err != nil || len(due) != 1 {
		t.Fatalf("at retry time: got %d due, err=%v", len(due), err)
	}
	r := due[0]
	if r.ID != id || r.DeliveryGroup != "reminder:one" || r.PhraseBody != "Позвони маме." ||
		r.PhraseSummary != "Позвони маме" || r.PhraseMood != "warm" {
		t.Fatalf("persisted delivery presentation = %+v", r)
	}
	if r.DeliveryAttempts != 1 {
		t.Fatalf("attempts = %d, want 1", r.DeliveryAttempts)
	}
	if want := now.Add(ReminderRetryBase); !r.NextAttemptTs.Equal(want) {
		t.Fatalf("next attempt = %s, want %s", r.NextAttemptTs, want)
	}
}

func TestReminderRetryDelayIsExponentialAndBounded(t *testing.T) {
	wants := []time.Duration{
		time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute,
		16 * time.Minute, 32 * time.Minute, time.Hour, time.Hour, time.Hour,
	}
	for i, want := range wants {
		if got := ReminderRetryDelay(i + 1); got != want {
			t.Errorf("attempt %d delay = %s, want %s", i+1, got, want)
		}
	}
}

func TestCollapsedReminderRetryStateMovesTogether(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
	for _, text := range []string{"полить цветы", "позвонить врачу"} {
		if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
			t.Fatal(err)
		}
	}
	originals, err := s.DueReminders(ctx, now)
	if err != nil || len(originals) != 2 {
		t.Fatalf("due originals = %d, err=%v", len(originals), err)
	}
	if err := s.CacheReminderPhrase(ctx, originals, "reminder:bundle", "У тебя два напоминания.", "Два напоминания", "neutral"); err != nil {
		t.Fatal(err)
	}
	if err := s.ScheduleReminderRetry(ctx, originals, now); err != nil {
		t.Fatal(err)
	}

	rows, err := s.ListReminders(ctx, 10)
	if err != nil || len(rows) != 2 {
		t.Fatalf("list = %d, err=%v", len(rows), err)
	}
	for _, r := range rows {
		if r.DeliveryGroup != "reminder:bundle" || r.PhraseBody != "У тебя два напоминания." || r.DeliveryAttempts != 1 {
			t.Errorf("bundle original did not move with group: %+v", r)
		}
		if !r.NextAttemptTs.Equal(now.Add(time.Minute)) {
			t.Errorf("next attempt = %s, want %s", r.NextAttemptTs, now.Add(time.Minute))
		}
	}
}

func TestRecurringReminderClearsDeliveryStateForNextOccurrence(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	loc := time.FixedZone("MSK", 3*60*60)
	fire := time.Date(2026, 8, 13, 9, 0, 0, 0, loc)
	id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *")
	if err != nil {
		t.Fatal(err)
	}
	due, err := s.DueReminders(ctx, fire)
	if err != nil || len(due) != 1 {
		t.Fatalf("due = %d, err=%v", len(due), err)
	}
	if err := s.CacheReminderPhrase(ctx, due, "reminder:occurrence", "Пора на стендап.", "Стендап", "neutral"); err != nil {
		t.Fatal(err)
	}
	if err := s.ScheduleReminderRetry(ctx, due, fire); err != nil {
		t.Fatal(err)
	}
	if err := s.rescheduleReminderIn(ctx, id, fire.Add(time.Minute), loc); err != nil {
		t.Fatalf("reschedule: %v", err)
	}

	rows, err := s.ListReminders(ctx, 1)
	if err != nil || len(rows) != 1 {
		t.Fatalf("list = %d, err=%v", len(rows), err)
	}
	r := rows[0]
	if r.Status != ReminderPending || r.DeliveryGroup != "" || r.PhraseBody != "" ||
		r.PhraseSummary != "" || r.PhraseMood != "" || r.DeliveryAttempts != 0 || !r.NextAttemptTs.IsZero() {
		t.Fatalf("next occurrence retained old delivery state: %+v", r)
	}
	want := time.Date(2026, 8, 14, 9, 0, 0, 0, loc)
	if !r.NextFireTs.Equal(want) {
		t.Fatalf("next fire = %s, want %s", r.NextFireTs.In(loc), want)
	}
}

func TestReminderTerminalTransitionHasExactlyOneWinner(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)

	for iteration := 0; iteration < 40; iteration++ {
		id, err := s.CreateReminder(ctx, now, "race", "")
		if err != nil {
			t.Fatal(err)
		}
		start := make(chan struct{})
		var wg sync.WaitGroup
		errs := make([]error, 2)
		wg.Add(2)
		go func() {
			defer wg.Done()
			<-start
			errs[0] = s.MarkReminder(ctx, id, ReminderFired)
		}()
		go func() {
			defer wg.Done()
			<-start
			errs[1] = s.CancelReminder(ctx, id)
		}()
		close(start)
		wg.Wait()

		successes := 0
		losers := 0
		winner := ""
		for i, err := range errs {
			switch {
			case err == nil:
				successes++
				winner = []string{ReminderFired, ReminderCancelled}[i]
			case errors.Is(err, ErrReminderState):
				losers++
			default:
				t.Fatalf("iteration %d transition %d: %v", iteration, i, err)
			}
		}
		if successes != 1 || losers != 1 {
			t.Fatalf("iteration %d: successes=%d losers=%d errors=%v", iteration, successes, losers, errs)
		}
		var stored string
		if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, id).Scan(&stored); err != nil {
			t.Fatal(err)
		}
		if stored != winner {
			t.Fatalf("iteration %d: stored %q, successful transition %q", iteration, stored, winner)
		}
	}
}

func TestAmbiguousCollapsedDeliveryIsNotAutomaticallyRepeated(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
	for _, text := range []string{"полить цветы", "позвонить врачу"} {
		if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
			t.Fatal(err)
		}
	}
	originals, err := s.DueReminders(ctx, now)
	if err != nil || len(originals) != 2 {
		t.Fatalf("due originals = %d, err=%v", len(originals), err)
	}
	const group = "reminder:ambiguous-bundle"
	if err := s.CacheReminderPhrase(ctx, originals, group, "У тебя два напоминания.", "Два напоминания", "neutral"); err != nil {
		t.Fatal(err)
	}
	for i := range originals {
		originals[i].DeliveryGroup = group
	}

	// Simulate a process dying after Begin and before it can record whether the
	// external sink accepted the bundle. Synthetic reminder zero is never put
	// in the outbox: a real original is the human-readable representative and
	// the group is the occurrence identity shared by both originals.
	attemptID, err := s.BeginDeliveryAttempt(ctx, "reminder", "", originals[0].ID, group, "telegram", "hash", now)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.ReconcileStaleDeliveryAttempts(ctx, now.Add(time.Minute)); err != nil {
		t.Fatal(err)
	}
	if due, err := s.DueReminders(ctx, now.Add(24*time.Hour)); err != nil || len(due) != 0 {
		t.Fatalf("ambiguous delivered bundle was eligible for automatic repeat: due=%+v err=%v", due, err)
	}
	attempts, err := s.ListDeliveryAttempts(ctx, "", 10)
	if err != nil || len(attempts) != 1 {
		t.Fatalf("attempts=%+v err=%v", attempts, err)
	}
	if attempts[0].ID != attemptID || attempts[0].Status != DeliveryUnknown || attempts[0].DeliveryGroup != group {
		t.Fatalf("ambiguous occurrence lost its durable identity: %+v", attempts[0])
	}
	if err := s.ScheduleReminderRetry(ctx, originals, now.Add(time.Minute)); err != nil {
		t.Fatal(err)
	}
	rows, err := s.ListReminders(ctx, 10)
	if err != nil || len(rows) != 2 {
		t.Fatalf("rows=%+v err=%v", rows, err)
	}
	for _, r := range rows {
		if r.DeliveryAttempts != 0 || !r.NextAttemptTs.IsZero() {
			t.Fatalf("ambiguous occurrence was scheduled for automatic retry: %+v", r)
		}
	}
}

func TestDefiniteFailedDeliveryRemainsRetryEligible(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
	if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), "позвонить врачу", ""); err != nil {
		t.Fatal(err)
	}
	originals, err := s.DueReminders(ctx, now)
	if err != nil || len(originals) != 1 {
		t.Fatalf("due originals = %d, err=%v", len(originals), err)
	}
	const group = "reminder:definite-failure"
	if err := s.CacheReminderPhrase(ctx, originals, group, "Позвони врачу.", "Позвони врачу", "neutral"); err != nil {
		t.Fatal(err)
	}
	id, err := s.BeginDeliveryAttempt(ctx, "reminder", "", originals[0].ID, group, "telegram", "hash", now)
	if err != nil {
		t.Fatal(err)
	}
	if err := s.CompleteDeliveryAttempt(ctx, id, DeliveryFailed, now); err != nil {
		t.Fatal(err)
	}
	if due, err := s.DueReminders(ctx, now); err != nil || len(due) != 1 {
		t.Fatalf("definite failure was not retry eligible: due=%+v err=%v", due, err)
	}
}

func TestCollapsedCompletionRollsBackAsOneTransaction(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
	for _, text := range []string{"first", "fail-second"} {
		if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
			t.Fatal(err)
		}
	}
	originals, err := s.DueReminders(ctx, now)
	if err != nil || len(originals) != 2 {
		t.Fatalf("due originals = %d, err=%v", len(originals), err)
	}
	if _, err := s.db.ExecContext(ctx, `
		CREATE TRIGGER fail_second_reminder_completion
		BEFORE UPDATE OF status ON reminders
		WHEN OLD.payload = 'fail-second'
		BEGIN
			SELECT RAISE(ABORT, 'injected second completion failure');
		END`); err != nil {
		t.Fatal(err)
	}
	if err := s.CompleteReminderDelivery(ctx, originals, now); err == nil {
		t.Fatal("injected second-row failure did not fail the bundle completion")
	}
	rows, err := s.ListReminders(ctx, 10)
	if err != nil || len(rows) != 2 {
		t.Fatalf("rows=%+v err=%v", rows, err)
	}
	for _, r := range rows {
		if r.Status != ReminderPending {
			t.Fatalf("bundle completion partially committed reminder %d as %q", r.ID, r.Status)
		}
	}
}

func TestSuccessfulAttemptAndCollapsedCompletionCommitAtomically(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
	for _, text := range []string{"first", "fail-second"} {
		if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
			t.Fatal(err)
		}
	}
	originals, err := s.DueReminders(ctx, now)
	if err != nil || len(originals) != 2 {
		t.Fatalf("due originals = %d, err=%v", len(originals), err)
	}
	const group = "reminder:atomic-success"
	if err := s.CacheReminderPhrase(ctx, originals, group, "Two reminders.", "Two reminders", "neutral"); err != nil {
		t.Fatal(err)
	}
	for i := range originals {
		originals[i].DeliveryGroup = group
	}
	attemptID, err := s.BeginDeliveryAttempt(ctx, "reminder", "", originals[0].ID, group, "telegram", "hash", now)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.db.ExecContext(ctx, `
		CREATE TRIGGER fail_atomic_second_completion
		BEFORE UPDATE OF status ON reminders
		WHEN OLD.payload = 'fail-second'
		BEGIN
			SELECT RAISE(ABORT, 'injected second completion failure');
		END`); err != nil {
		t.Fatal(err)
	}
	if err := s.CompleteSuccessfulReminderAttempt(ctx, attemptID, originals, now); err == nil {
		t.Fatal("injected reminder completion failure did not fail the local commit")
	}
	attempts, err := s.ListDeliveryAttempts(ctx, "", 10)
	if err != nil || len(attempts) != 1 || attempts[0].Status != DeliveryPending {
		t.Fatalf("successful outbox marker committed without reminder state: attempts=%+v err=%v", attempts, err)
	}
	rows, err := s.ListReminders(ctx, 10)
	if err != nil || len(rows) != 2 {
		t.Fatalf("rows=%+v err=%v", rows, err)
	}
	for _, r := range rows {
		if r.Status != ReminderPending {
			t.Fatalf("atomic rollback partially completed reminder %d as %q", r.ID, r.Status)
		}
	}
	if _, err := s.ReconcileStaleDeliveryAttempts(ctx, now.Add(time.Minute)); err != nil {
		t.Fatal(err)
	}
	if due, err := s.DueReminders(ctx, now.Add(24*time.Hour)); err != nil || len(due) != 0 {
		t.Fatalf("restart auto-replayed ambiguous accepted send: due=%+v err=%v", due, err)
	}
}

func TestPermanentReminderFailureBlocksUntilDeliberatelyUnblocked(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
	id, err := s.CreateReminder(ctx, now.Add(-time.Minute), "позвонить врачу", "")
	if err != nil {
		t.Fatal(err)
	}
	originals, err := s.DueReminders(ctx, now)
	if err != nil || len(originals) != 1 {
		t.Fatalf("due originals=%+v err=%v", originals, err)
	}
	const group = "reminder:revoked-token"
	if err := s.CacheReminderPhrase(ctx, originals, group, "Позвони врачу.", "Позвони врачу", "neutral"); err != nil {
		t.Fatal(err)
	}
	originals[0].DeliveryGroup = group
	if err := s.BlockReminderDelivery(ctx, originals, now, "ntfy credentials rejected"); err != nil {
		t.Fatal(err)
	}
	if err := s.ScheduleReminderRetry(ctx, originals, now); err != nil {
		t.Fatal(err)
	}
	if due, err := s.DueReminders(ctx, now.Add(24*time.Hour)); err != nil || len(due) != 0 {
		t.Fatalf("blocked reminder retried automatically: due=%+v err=%v", due, err)
	}
	rows, err := s.ListReminders(ctx, 1)
	if err != nil || len(rows) != 1 {
		t.Fatalf("rows=%+v err=%v", rows, err)
	}
	if rows[0].DeliveryBlockedTs.IsZero() || rows[0].DeliveryBlockedError != "ntfy credentials rejected" ||
		rows[0].DeliveryAttempts != 0 || !rows[0].NextAttemptTs.IsZero() {
		t.Fatalf("blocked state is not visible/durable: %+v", rows[0])
	}
	if err := s.UnblockReminderDelivery(ctx, id); err != nil {
		t.Fatal(err)
	}
	due, err := s.DueReminders(ctx, now)
	if err != nil || len(due) != 1 || due[0].ID != id || !due[0].DeliveryBlockedTs.IsZero() || due[0].DeliveryBlockedError != "" {
		t.Fatalf("deliberate unblock did not restore eligibility: due=%+v err=%v", due, err)
	}
}
