package botlink

import (
	"context"
	"errors"
	"net"
	"testing"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/status"
	"google.golang.org/grpc/test/bufconn"

	botlinkv1 "scrabble/pkg/proto/botlink/v1"
	telegramv1 "scrabble/pkg/proto/telegram/v1"
)

// fakeBot is a test bot that dials the hub, registers, and acks commands with a
// fixed delivered flag (or never, when ack is false).
type fakeBot struct {
	delivered bool
	ack       bool
	received  chan *botlinkv1.Command
}

// startHub registers a Hub (no chat-eligibility resolver) on an in-memory gRPC
// server and returns the hub plus a dialer for fake bots.
func startHub(t *testing.T) (*Hub, func(t *testing.T) botlinkv1.BotLinkClient) {
	return startHubWith(t, nil)
}

// startHubWith is startHub with an explicit chat-eligibility resolver, for the
// ResolveChatEligibility tests.
func startHubWith(t *testing.T, resolve EligibilityResolver) (*Hub, func(t *testing.T) botlinkv1.BotLinkClient) {
	t.Helper()
	lis := bufconn.Listen(1 << 20)
	hub := NewHub(nil, nil, resolve)
	srv := grpc.NewServer()
	botlinkv1.RegisterBotLinkServer(srv, hub)
	go func() { _ = srv.Serve(lis) }()
	t.Cleanup(srv.Stop)

	dial := func(t *testing.T) botlinkv1.BotLinkClient {
		t.Helper()
		conn, err := grpc.NewClient("passthrough:///bufnet",
			grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return lis.Dial() }),
			grpc.WithTransportCredentials(insecure.NewCredentials()),
		)
		if err != nil {
			t.Fatalf("dial: %v", err)
		}
		t.Cleanup(func() { _ = conn.Close() })
		return botlinkv1.NewBotLinkClient(conn)
	}
	return hub, dial
}

// connect runs a fake bot against client until ctx is cancelled, returning once the
// hub has registered it.
func (f *fakeBot) connect(t *testing.T, ctx context.Context, hub *Hub, client botlinkv1.BotLinkClient) {
	t.Helper()
	f.received = make(chan *botlinkv1.Command, 8)
	stream, err := client.Link(ctx)
	if err != nil {
		t.Fatalf("link: %v", err)
	}
	if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Hello{Hello: &botlinkv1.Hello{InstanceId: "test"}}}); err != nil {
		t.Fatalf("hello: %v", err)
	}
	go func() {
		for {
			msg, err := stream.Recv()
			if err != nil {
				return
			}
			cmd := msg.GetCommand()
			f.received <- cmd
			if !f.ack {
				continue
			}
			_ = stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: &botlinkv1.Ack{
				CommandId: cmd.GetCommandId(),
				Delivered: f.delivered,
			}}})
		}
	}()
	waitConnected(t, hub, 1)
}

// waitConnected blocks until the hub reports n connected bots.
func waitConnected(t *testing.T, hub *Hub, n int) {
	t.Helper()
	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		hub.mu.Lock()
		got := len(hub.links)
		hub.mu.Unlock()
		if got == n {
			return
		}
		time.Sleep(5 * time.Millisecond)
	}
	t.Fatalf("hub did not reach %d connected bots", n)
}

func TestHubSendAwaitDelivered(t *testing.T) {
	hub, dial := startHub(t)
	ctx := t.Context()
	bot := &fakeBot{delivered: true, ack: true}
	bot.connect(t, ctx, hub, dial(t))

	delivered, err := hub.SendAwait(ctx, SendToUserCommand("42", "hi"))
	if err != nil {
		t.Fatalf("SendAwait: %v", err)
	}
	if !delivered {
		t.Fatal("delivered = false, want true")
	}
	select {
	case cmd := <-bot.received:
		if cmd.GetSendToUser().GetExternalId() != "42" {
			t.Errorf("received external_id = %q, want 42", cmd.GetSendToUser().GetExternalId())
		}
	case <-time.After(time.Second):
		t.Fatal("bot received no command")
	}
}

func TestHubSendAwaitNoBot(t *testing.T) {
	hub, _ := startHub(t)
	_, err := hub.SendAwait(context.Background(), SendToUserCommand("42", "hi"))
	if !errors.Is(err, ErrNoBot) {
		t.Errorf("err = %v, want ErrNoBot", err)
	}
}

func TestHubSendAwaitDeadline(t *testing.T) {
	hub, dial := startHub(t)
	ctx := t.Context()
	bot := &fakeBot{ack: false} // receives but never acks
	bot.connect(t, ctx, hub, dial(t))

	awaitCtx, awaitCancel := context.WithTimeout(ctx, 100*time.Millisecond)
	defer awaitCancel()
	delivered, err := hub.SendAwait(awaitCtx, SendToUserCommand("42", "hi"))
	if err != nil {
		t.Fatalf("SendAwait err = %v, want nil on deadline", err)
	}
	if delivered {
		t.Error("delivered = true, want false on deadline")
	}
}

func TestHubSendAsync(t *testing.T) {
	hub, dial := startHub(t)
	ctx := t.Context()
	bot := &fakeBot{ack: false}
	bot.connect(t, ctx, hub, dial(t))

	hub.Send(NotifyCommand("42", "your_turn", nil, "en"))
	select {
	case cmd := <-bot.received:
		if cmd.GetNotify().GetExternalId() != "42" {
			t.Errorf("received external_id = %q, want 42", cmd.GetNotify().GetExternalId())
		}
	case <-time.After(time.Second):
		t.Fatal("bot received no command")
	}
}

func TestHubSendChatGate(t *testing.T) {
	hub, dial := startHub(t)
	ctx := t.Context()
	bot := &fakeBot{ack: false}
	bot.connect(t, ctx, hub, dial(t))

	hub.Send(ChatGateCommand("42", true))
	select {
	case cmd := <-bot.received:
		cg := cmd.GetChatGate()
		if cg.GetExternalId() != "42" || !cg.GetAllow() {
			t.Errorf("chat_gate = %+v, want external_id=42 allow=true", cg)
		}
	case <-time.After(time.Second):
		t.Fatal("bot received no command")
	}
}

func TestHubResolveChatEligibility(t *testing.T) {
	var gotExt string
	_, dial := startHubWith(t, func(_ context.Context, ext string) (bool, bool, error) {
		gotExt = ext
		return true, ext == "good", nil
	})
	client := dial(t)
	ctx := t.Context()

	resp, err := client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: "good"})
	if err != nil {
		t.Fatalf("ResolveChatEligibility: %v", err)
	}
	if gotExt != "good" {
		t.Errorf("resolver external_id = %q, want good", gotExt)
	}
	if !resp.GetRegistered() || !resp.GetEligible() {
		t.Errorf("resp = %+v, want registered+eligible", resp)
	}

	resp, err = client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: "muted"})
	if err != nil {
		t.Fatalf("ResolveChatEligibility(muted): %v", err)
	}
	if !resp.GetRegistered() || resp.GetEligible() {
		t.Errorf("resp = %+v, want registered but not eligible", resp)
	}
}

func TestHubResolveChatEligibilityUnconfigured(t *testing.T) {
	_, dial := startHub(t) // nil resolver
	client := dial(t)
	_, err := client.ResolveChatEligibility(t.Context(), &botlinkv1.ChatEligibilityRequest{ExternalId: "x"})
	if status.Code(err) != codes.Unavailable {
		t.Fatalf("err = %v, want Unavailable", err)
	}
}

func TestRelayServerNoBot(t *testing.T) {
	hub, _ := startHub(t)
	relay := NewRelayServer(hub, 200*time.Millisecond)
	if _, err := relay.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "42", Text: "hi"}); err == nil {
		t.Fatal("expected an error with no bot connected")
	}
}

// TestRecordHealthLastOKMonotonic checks a bot Health report advances the last-ok liveness stamp but
// a stale or reordered report (an earlier second) never rewinds it.
func TestRecordHealthLastOKMonotonic(t *testing.T) {
	hub := NewHub(nil, nil, nil)
	hub.recordHealth(&botlinkv1.Health{LastOkUnix: 100})
	if got := hub.lastOK.Load(); got != 100 {
		t.Fatalf("lastOK = %d, want 100", got)
	}
	hub.recordHealth(&botlinkv1.Health{LastOkUnix: 90}) // reordered/stale — must not rewind
	if got := hub.lastOK.Load(); got != 100 {
		t.Errorf("lastOK rewound to %d, want 100", got)
	}
	hub.recordHealth(&botlinkv1.Health{LastOkUnix: 150})
	if got := hub.lastOK.Load(); got != 150 {
		t.Errorf("lastOK = %d, want 150", got)
	}
}
