// Command bot is the remote-side Telegram bot. It runs the Bot API long-poll
// (Mini App launch + /start deep-links) and dials the gateway over the reverse mTLS
// bot-link to receive and execute send commands (out-of-app push and admin sends).
// It holds the bot token and is the only component that reaches the Telegram Bot
// API, so it runs on a host with native Telegram access (no VPN) and needs no
// inbound port. See platform/telegram/README.md.
package main

import (
	"context"
	"log"
	"os/signal"
	"path/filepath"
	"sync"
	"syscall"
	"time"

	"go.uber.org/zap"
	"google.golang.org/grpc/credentials"

	"scrabble/pkg/mtls"
	pkgtel "scrabble/pkg/telemetry"
	"scrabble/platform/telegram/internal/bot"
	"scrabble/platform/telegram/internal/botlink"
	"scrabble/platform/telegram/internal/config"
	"scrabble/platform/telegram/internal/health"
	"scrabble/platform/telegram/internal/outbox"
	"scrabble/platform/telegram/internal/promobot"
	"scrabble/platform/telegram/internal/support"
)

// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second

// healthReportInterval is how often the bot flushes its accumulated Bot API health to the gateway
// over the bot-link. It bounds how stale a metric can be, not how often the bot talks to Telegram.
const healthReportInterval = 30 * time.Second

func main() {
	cfg, err := config.LoadBot()
	if err != nil {
		log.Fatalf("bot: load config: %v", err)
	}
	logger, err := newLogger(cfg.LogLevel)
	if err != nil {
		log.Fatalf("bot: build logger: %v", err)
	}
	defer func() { _ = logger.Sync() }()

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	if err := run(ctx, cfg, logger); err != nil {
		logger.Fatal("bot: terminated", zap.Error(err))
	}
}

// run wires the bot long-poll and the bot-link client and runs both until the
// context is cancelled.
func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
	tel, err := pkgtel.New(ctx, cfg.Telemetry)
	if err != nil {
		return err
	}
	defer func() {
		shutdownCtx, cancel := context.WithTimeout(context.Background(), telemetryShutdownTimeout)
		defer cancel()
		if err := tel.Shutdown(shutdownCtx); err != nil {
			logger.Warn("telemetry shutdown", zap.Error(err))
		}
	}()
	if err := tel.StartRuntimeMetrics(); err != nil {
		logger.Warn("telemetry: start runtime metrics", zap.Error(err))
	}

	// The support relay keeps its per-user state in a JSON file on a writable volume
	// (the bot host has no database). A corrupt file is logged and the relay starts
	// empty rather than crash-looping the bot.
	var supportStore *support.Store
	if cfg.SupportChatID != 0 {
		statePath := filepath.Join(cfg.SupportStateDir, "support.json")
		supportStore, err = support.Open(statePath)
		if err != nil {
			logger.Warn("support: state load failed, starting empty", zap.String("path", statePath), zap.Error(err))
			supportStore = support.New(statePath)
		}
	}

	// The bot exports no telemetry of its own (otelcol is on the main host, unreachable from here),
	// so its Bot API health is observed centrally and reported to the gateway over the bot-link,
	// which turns the reports into metrics. Shared between the bot (which feeds it) and the bot-link
	// client (which flushes it).
	healthReporter := health.NewReporter()

	b, err := bot.New(bot.Config{
		Token:             cfg.Token,
		APIBaseURL:        cfg.APIBaseURL,
		TestEnv:           cfg.TestEnv,
		MiniAppURL:        cfg.MiniAppURL,
		SendRatePerSecond: cfg.SendRatePerSecond,
		ChatID:            cfg.ChatID,
		GameChannelID:     cfg.GameChannelID,
		SupportChatID:     cfg.SupportChatID,
		SupportStore:      supportStore,
		AcceptPayments:    cfg.StarsOutboxDir != "",
		Health:            healthReporter,
	}, logger)
	if err != nil {
		return err
	}

	// The Telegram Stars payment outbox: a durable SQLite store on the bot host's writable volume.
	// Opened when the rail is enabled; a failure to open is fatal, since accepting Stars without a
	// durable outbox would risk losing a paid-for order.
	var paymentOutbox *outbox.Store
	if cfg.StarsOutboxDir != "" {
		paymentOutbox, err = outbox.Open(filepath.Join(cfg.StarsOutboxDir, "stars.db"))
		if err != nil {
			return err
		}
		defer func() { _ = paymentOutbox.Close() }()
	}

	tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName)
	if err != nil {
		return err
	}
	exec := botlink.NewExecutor(b, cfg.GameChannelID, logger)
	client, err := botlink.NewClient(botlink.ClientConfig{
		GatewayAddr:    cfg.BotLink.GatewayAddr,
		InstanceID:     cfg.BotLink.InstanceID,
		OwnsUpdates:    cfg.OwnsUpdates,
		Creds:          credentials.NewTLS(tlsCfg),
		ReconnectDelay: cfg.BotLink.ReconnectDelay,
		Health:         healthReporter,
		HealthInterval: healthReportInterval,
	}, exec, logger)
	if err != nil {
		return err
	}
	defer func() { _ = client.Close() }()
	// The chat-join eligibility query rides the same bot-link connection; wire it into
	// the bot after the client is built — the late binding that breaks the bot <->
	// client construction cycle.
	b.SetEligibilityResolver(client.ResolveChatEligibility)
	// The Telegram Stars payment path rides the same bot-link: pre_checkout validation and
	// completed-payment forwarding, backed by the durable outbox — wired here for the same
	// late-binding reason.
	if paymentOutbox != nil {
		b.SetPaymentHandlers(client.ValidatePreCheckout, client.ForwardPayment, paymentOutbox)
	}

	// The optional standalone promo bot: a second bot (its own token) that only answers
	// /start with a button opening the main bot's Mini App. It is self-contained — no
	// bot-link, no gateway — so onboarding works even when the game is down.
	var promo *promobot.Bot
	if cfg.PromoBotToken != "" {
		// The promo bot is auxiliary and shares this process with the main bot, so its
		// construction failure (a bad or unreachable promo token — tgbot.New validates it
		// with getMe) is logged and the promo bot is skipped, never fatal: it must not
		// take the main game bot down with it.
		promo, err = promobot.New(promobot.Config{
			Token:             cfg.PromoBotToken,
			APIBaseURL:        cfg.APIBaseURL,
			TestEnv:           cfg.TestEnv,
			BotUsername:       cfg.BotUsername,
			BotLinkURL:        cfg.BotLinkURL,
			StartParam:        cfg.PromoStartParam,
			SendRatePerSecond: cfg.SendRatePerSecond,
		}, logger)
		if err != nil {
			logger.Error("promo bot disabled: construction failed (the main bot is unaffected)", zap.Error(err))
			promo = nil
		}
	}

	logger.Info("telegram bot starting",
		zap.String("gateway", cfg.BotLink.GatewayAddr),
		zap.String("miniapp_url", cfg.MiniAppURL),
		zap.Bool("owns_updates", cfg.OwnsUpdates),
		zap.Bool("test_env", cfg.TestEnv),
		zap.Bool("chat_gating", cfg.ChatID != 0),
		zap.Bool("support_relay", cfg.SupportChatID != 0),
		zap.Bool("promo_bot", promo != nil))

	var wg sync.WaitGroup
	// The long-poll holds the exclusive getUpdates lease (one bot per token); a bot
	// that does not own it only delivers sends over the bot-link.
	if cfg.OwnsUpdates {
		wg.Go(func() { b.Run(ctx) })
	}
	wg.Go(func() {
		if err := client.Run(ctx); err != nil && ctx.Err() == nil {
			logger.Error("bot-link client stopped", zap.Error(err))
		}
	})
	// Re-drive the Stars payment outbox: at startup (recovering payments left by a restart or a past
	// outage) and periodically thereafter.
	if paymentOutbox != nil {
		wg.Go(func() { b.RunPaymentDrainer(ctx) })
	}
	// The promo bot runs its own getUpdates long-poll on its own token (no 409 with
	// the main bot's lease).
	if promo != nil {
		wg.Go(func() { promo.Run(ctx) })
	}

	<-ctx.Done()
	wg.Wait()
	return nil
}

// newLogger builds a production JSON logger at the given level.
func newLogger(level string) (*zap.Logger, error) {
	var lvl zap.AtomicLevel
	if err := lvl.UnmarshalText([]byte(level)); err != nil {
		return nil, err
	}
	cfg := zap.NewProductionConfig()
	cfg.Level = lvl
	return cfg.Build()
}
