package botlink

import (
	"context"
	"time"

	"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
	"go.uber.org/zap"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/keepalive"

	botlinkv1 "scrabble/pkg/proto/botlink/v1"
	"scrabble/platform/telegram/internal/health"
)

const (
	// clientKeepaliveTime is how often the bot pings the gateway to hold the WAN
	// connection open (kept above the gateway's MinTime to avoid an enforcement ban).
	clientKeepaliveTime = 30 * time.Second
	// clientKeepaliveTimeout bounds the wait for a keepalive ping reply.
	clientKeepaliveTimeout = 10 * time.Second
)

// ClientConfig configures the bot's dial side of the reverse bot-link.
type ClientConfig struct {
	// GatewayAddr is the gateway bot-link endpoint to dial.
	GatewayAddr string
	// InstanceID identifies this bot to the gateway.
	InstanceID string
	// OwnsUpdates reports whether this bot runs the exclusive getUpdates long-poll.
	OwnsUpdates bool
	// Creds is the transport credentials for the dial (mutual TLS in production,
	// built from pkg/mtls).
	Creds credentials.TransportCredentials
	// ReconnectDelay is the pause before re-dialing after the stream ends.
	ReconnectDelay time.Duration
	// Health, when set with a positive HealthInterval, is flushed to the gateway as a botlink Health
	// message every HealthInterval while the stream is open. Nil disables health reporting.
	Health *health.Reporter
	// HealthInterval is the cadence of the Health flush; 0 disables it.
	HealthInterval time.Duration
}

// Client maintains the long-lived bot-link to the gateway, executing the commands
// it receives and re-dialing after any break. The same mTLS connection also serves
// the unary chat-eligibility query the bot makes on a chat join.
type Client struct {
	cfg    ClientConfig
	exec   *Executor
	log    *zap.Logger
	conn   *grpc.ClientConn
	client botlinkv1.BotLinkClient
}

// NewClient builds the bot-link client over the executor, dialing the gateway. The
// gRPC connection is lazy, so the dial does not block on the gateway being up; the
// caller must Close it. The bot-link command stream is opened by Run.
func NewClient(cfg ClientConfig, exec *Executor, log *zap.Logger) (*Client, error) {
	if log == nil {
		log = zap.NewNop()
	}
	conn, err := grpc.NewClient(cfg.GatewayAddr,
		grpc.WithTransportCredentials(cfg.Creds),
		grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
		grpc.WithKeepaliveParams(keepalive.ClientParameters{
			Time:                clientKeepaliveTime,
			Timeout:             clientKeepaliveTimeout,
			PermitWithoutStream: true,
		}),
	)
	if err != nil {
		return nil, err
	}
	return &Client{cfg: cfg, exec: exec, log: log, conn: conn, client: botlinkv1.NewBotLinkClient(conn)}, nil
}

// Close releases the bot-link connection.
func (c *Client) Close() error { return c.conn.Close() }

// ResolveChatEligibility asks the gateway whether the Telegram user identified by
// externalID may write in the moderated chat. The bot calls it on a chat join, over
// the same mTLS connection as the command stream.
func (c *Client) ResolveChatEligibility(ctx context.Context, externalID string) (bool, error) {
	resp, err := c.client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: externalID})
	if err != nil {
		return false, err
	}
	return resp.GetEligible(), nil
}

// ValidatePreCheckout asks the gateway whether a Telegram Stars pre_checkout_query for orderID
// paying amount in currency may be approved, before the charge. The bot calls it on every
// pre_checkout_query over the same mTLS connection and approves only on ok; the reason is a short
// decline message (already localised by the backend) to show the payer.
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error) {
	resp, err := c.client.ValidatePreCheckout(ctx, &botlinkv1.PreCheckoutRequest{OrderId: orderID, Amount: amount, Currency: currency})
	if err != nil {
		return false, "", err
	}
	return resp.GetOk(), resp.GetReason(), nil
}

// ForwardPayment delivers a completed Stars payment to the gateway for crediting. The bot calls it
// from the outbox drain; it reports whether the order was credited (or already had been). A non-nil
// error is a transient failure the bot retries.
func (c *Client) ForwardPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error) {
	resp, err := c.client.ForwardPayment(ctx, &botlinkv1.ForwardPaymentRequest{
		OrderId:                 orderID,
		TelegramPaymentChargeId: chargeID,
		Amount:                  amount,
		TelegramUserId:          telegramUserID,
	})
	if err != nil {
		return false, err
	}
	return resp.GetCredited(), nil
}

// Run keeps the bot-link command stream open, re-opening it after each break, until
// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath.
func (c *Client) Run(ctx context.Context) error {
	for ctx.Err() == nil {
		if err := c.serve(ctx, c.client); err != nil && ctx.Err() == nil {
			c.log.Warn("bot-link stream ended", zap.Error(err))
		}
		if !sleep(ctx, c.cfg.ReconnectDelay) {
			break
		}
	}
	return ctx.Err()
}

// serve opens one Link stream, registers with a Hello, then executes commands and
// replies with an Ack each until the stream ends.
func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) error {
	stream, err := client.Link(ctx)
	if err != nil {
		return err
	}
	if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Hello{Hello: &botlinkv1.Hello{
		InstanceId:  c.cfg.InstanceID,
		OwnsUpdates: c.cfg.OwnsUpdates,
	}}}); err != nil {
		return err
	}
	c.log.Info("bot-link connected", zap.String("gateway", c.cfg.GatewayAddr), zap.Bool("owns_updates", c.cfg.OwnsUpdates))

	// One goroutine owns stream.Recv, feeding commands to the loop below; the loop owns every
	// stream.Send (the Acks and the periodic Health) — a gRPC stream forbids concurrent Send.
	rctx, cancel := context.WithCancel(ctx)
	defer cancel()
	cmds := make(chan *botlinkv1.Command)
	recvErr := make(chan error, 1)
	go func() {
		for {
			msg, err := stream.Recv()
			if err != nil {
				recvErr <- err
				return
			}
			if cmd := msg.GetCommand(); cmd != nil {
				select {
				case cmds <- cmd:
				case <-rctx.Done():
					return
				}
			}
		}
	}()

	var healthTick <-chan time.Time
	if c.cfg.Health != nil && c.cfg.HealthInterval > 0 {
		t := time.NewTicker(c.cfg.HealthInterval)
		defer t.Stop()
		healthTick = t.C
	}

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case err := <-recvErr:
			return err
		case cmd := <-cmds:
			delivered, result, herr := c.exec.Handle(ctx, cmd)
			ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
			if herr != nil {
				ack.Error = herr.Error()
			}
			if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil {
				return err
			}
		case <-healthTick:
			// Snapshot without resetting; only commit (clear the deltas) after a successful send, so a
			// failed flush loses nothing and the counts ride the next connection.
			hb := c.cfg.Health.Snapshot()
			if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Health{Health: hb}}); err != nil {
				return err
			}
			c.cfg.Health.Commit(hb)
		}
	}
}

// sleep waits for d or until ctx is cancelled, reporting whether it waited the full
// duration.
func sleep(ctx context.Context, d time.Duration) bool {
	t := time.NewTimer(d)
	defer t.Stop()
	select {
	case <-ctx.Done():
		return false
	case <-t.C:
		return true
	}
}
