package deliberi

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"path/filepath"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	deldb "tangled.org/core/deliberi/db"
	models "tangled.org/core/deliberi/models"
	"tangled.org/core/orm"
)

const (
	invitee   = "did:plc:invitee"
	knotDid   = "did:web:knot.example"
	repoDid   = "did:plc:repo"
	collabUri = "at://" + repoDid + "/sh.tangled.repo.collaboratorInvite/" + invitee
	otherUri  = "at://" + repoDid + "/sh.tangled.repo.collaboratorInvite/did:plc:olaren"
)

var offerMade = time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)

type fakeLister struct {
	collabs   []InviteOffer
	pending   []string
	truncated bool
	err       error
	calls     *atomic.Int64
}

func (f *fakeLister) ListCollaboratorInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) {
	if f.calls != nil {
		f.calls.Add(1)
	}
	if f.err != nil {
		return InviteAnswer{}, f.err
	}
	return InviteAnswer{Offers: f.collabs, Pending: f.pending, Truncated: f.truncated}, nil
}

func offerAt(uri, admin string) InviteOffer {
	return InviteOffer{
		Uri:       uri,
		KnotDid:   knotDid,
		RepoDid:   repoDid,
		AddedBy:   admin,
		CreatedAt: offerMade,
	}
}

func newTestInviteSync(t *testing.T, l inviteLister) *InviteSync {
	t.Helper()
	database, err := deldb.Make(context.Background(), filepath.Join(t.TempDir(), "x.db"))
	if err != nil {
		t.Fatalf("make db: %v", err)
	}
	t.Cleanup(func() { database.Close() })
	return NewInviteSync(database, l, slog.New(slog.NewTextHandler(io.Discard, nil)))
}

func inviteRows(t *testing.T, s *InviteSync, did string) []*models.Notification {
	t.Helper()
	rows, err := deldb.GetNotifications(s.db, did, 0, orm.FilterIn("type", inviteNotificationTypes))
	if err != nil {
		t.Fatalf("read invite rows: %v", err)
	}
	return rows
}

func expire(s *InviteSync, did string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	delete(s.lastRun, did)
}

func TestReconcileCreatesRowFromOffer(t *testing.T) {
	s := newTestInviteSync(t, &fakeLister{
		collabs: []InviteOffer{offerAt(collabUri, "did:plc:boltless")},
	})

	s.Reconcile(context.Background(), invitee)

	rows := inviteRows(t, s, invitee)
	if len(rows) != 1 {
		t.Fatalf("row count = %d, want 1 (one per offer)", len(rows))
	}

	row := rows[0]
	if row.AtUri != collabUri {
		t.Errorf("atUri = %q, want the offer's record %q", row.AtUri, collabUri)
	}
	if row.Type != models.NotificationTypeCollaboratorInvited {
		t.Errorf("type = %q, want %q", row.Type, models.NotificationTypeCollaboratorInvited)
	}
	if row.ActorDid != "did:plc:boltless" {
		t.Errorf("actorDid = %q, want inviting admin", row.ActorDid)
	}
	if row.RepoDid != repoDid {
		t.Errorf("repoDid = %q, want %q", row.RepoDid, repoDid)
	}
	if row.KnotDid != knotDid {
		t.Errorf("knotDid = %q, want knot serving this offer", row.KnotDid)
	}
	if !row.Created.Equal(offerMade) {
		t.Errorf("created = %s, want offer's own %s", row.Created, offerMade)
	}
}

func TestReconcileDeletesOnlyWhatBobbinVouchesFor(t *testing.T) {
	cases := []struct {
		name string
		then func(l *fakeLister)
		want int
	}{
		{"offer withdrawn", func(l *fakeLister) { l.collabs = nil }, 0},
		{
			"offering knot pending",
			func(l *fakeLister) { l.collabs, l.pending = nil, []string{knotDid} },
			1,
		},
		{
			"answer truncated",
			func(l *fakeLister) {
				l.collabs, l.truncated = []InviteOffer{offerAt(otherUri, "did:plc:akshay")}, true
			},
			2,
		},
		{"bobbin can't answer", func(l *fakeLister) { l.err = errors.New("bobbin unreachable") }, 1},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			lister := &fakeLister{collabs: []InviteOffer{offerAt(collabUri, "did:plc:akshay")}}
			s := newTestInviteSync(t, lister)
			s.Reconcile(context.Background(), invitee)
			if got := len(inviteRows(t, s, invitee)); got != 1 {
				t.Fatalf("row count after offer = %d, want 1", got)
			}

			tc.then(lister)
			expire(s, invitee)
			s.Reconcile(context.Background(), invitee)

			if got := len(inviteRows(t, s, invitee)); got != tc.want {
				t.Fatalf("row count once %s = %d, want %d", tc.name, got, tc.want)
			}
		})
	}
}

func TestReconcileDeletesOncePendingKnotIsRead(t *testing.T) {
	lister := &fakeLister{
		collabs: []InviteOffer{offerAt(collabUri, "did:plc:akshay")},
		pending: []string{knotDid},
	}
	s := newTestInviteSync(t, lister)
	s.Reconcile(context.Background(), invitee)

	lister.collabs, lister.pending = nil, nil
	expire(s, invitee)
	s.Reconcile(context.Background(), invitee)

	if got := len(inviteRows(t, s, invitee)); got != 0 {
		t.Fatalf("row count once bobbin has read knot = %d, want 0", got)
	}
}

func TestReconcileReofferAfterWithdrawal(t *testing.T) {
	lister := &fakeLister{collabs: []InviteOffer{offerAt(collabUri, "did:plc:akshay")}}
	s := newTestInviteSync(t, lister)
	s.Reconcile(context.Background(), invitee)

	lister.collabs = nil
	expire(s, invitee)
	s.Reconcile(context.Background(), invitee)

	reoffer := offerAt(collabUri, "did:plc:akshay")
	reoffer.CreatedAt = time.Date(2026, 8, 31, 9, 15, 0, 0, time.UTC)
	lister.collabs = []InviteOffer{reoffer}
	expire(s, invitee)
	s.Reconcile(context.Background(), invitee)

	rows := inviteRows(t, s, invitee)
	if len(rows) != 1 {
		t.Fatalf("row count after re-offer = %d, want 1", len(rows))
	}
	if !rows[0].Created.Equal(reoffer.CreatedAt) {
		t.Fatalf("created = %s, want re-offer's %s", rows[0].Created, reoffer.CreatedAt)
	}
}

func TestReconcileSkipsSelfInvite(t *testing.T) {
	s := newTestInviteSync(t, &fakeLister{collabs: []InviteOffer{offerAt(collabUri, invitee)}})

	s.Reconcile(context.Background(), invitee)

	if got := len(inviteRows(t, s, invitee)); got != 0 {
		t.Fatalf("row count for self-invite = %d, want 0", got)
	}
}

func TestReconcileAsksBobbinOncePerWindow(t *testing.T) {
	var calls atomic.Int64
	s := newTestInviteSync(t, &fakeLister{calls: &calls})

	var racers sync.WaitGroup
	racers.Add(16)
	for range 16 {
		go func() {
			defer racers.Done()
			s.Reconcile(context.Background(), invitee)
		}()
	}
	racers.Wait()
	if got := calls.Load(); got != 1 {
		t.Fatalf("listings across 16 concurrent bells = %d, want 1 (one pass)", got)
	}

	expire(s, invitee)
	s.Reconcile(context.Background(), invitee)
	if got := calls.Load(); got != 2 {
		t.Fatalf("listings once window passes = %d, want 2", got)
	}
}

func TestReconcileForgetsRecipientsPastWindow(t *testing.T) {
	s := newTestInviteSync(t, &fakeLister{})

	s.Reconcile(context.Background(), "did:plc:one")
	s.Reconcile(context.Background(), "did:plc:two")

	s.mu.Lock()
	stale := time.Now().Add(-2 * inviteReconcileInterval)
	s.lastRun["did:plc:one"] = stale
	s.swept = stale
	s.mu.Unlock()

	s.Reconcile(context.Background(), "did:plc:three")

	s.mu.Lock()
	defer s.mu.Unlock()
	if _, held := s.lastRun["did:plc:one"]; held {
		t.Fatal("recipient who didn't read this window is still held in debounce")
	}
	if _, held := s.lastRun["did:plc:two"]; !held {
		t.Fatal("sweep took recipient still inside window")
	}
}
