package app

import (
	"context"
	"errors"
	"log/slog"
	"net"
	"sync"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	gamev1 "hyapp.local/api/proto/game/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/gamemq"
	"hyapp/pkg/grpchealth"
	"hyapp/pkg/healthhttp"
	"hyapp/pkg/logx"
	"hyapp/pkg/outboxpartition"
	"hyapp/pkg/rocketmqx"
	servicegrpc "hyapp/pkg/servicekit/grpcserver"
	servicehealth "hyapp/pkg/servicekit/health"
	servicemq "hyapp/pkg/servicekit/mq"
	"hyapp/services/game-service/internal/client"
	"hyapp/services/game-service/internal/config"
	diceservice "hyapp/services/game-service/internal/service/dice"
	gameservice "hyapp/services/game-service/internal/service/game"
	roomrpsservice "hyapp/services/game-service/internal/service/roomrps"
	mysqlstorage "hyapp/services/game-service/internal/storage/mysql"
	grpcserver "hyapp/services/game-service/internal/transport/grpc"
)

// App 装配 game-service gRPC 入口和持久化依赖。
type App struct {
	server         *grpc.Server
	listener       net.Listener
	health         *grpchealth.ServingChecker
	healthHTTP     *healthhttp.Server
	repo           *mysqlstorage.Repository
	walletConn     *grpc.ClientConn
	userConn       *grpc.ClientConn
	activityConn   *grpc.ClientConn
	robotConn      *grpc.ClientConn
	roomConn       *grpc.ClientConn
	outboxProducer *rocketmqx.Producer
	// outboxPartitions 缓存真实 App 列表并让双实例从不同轮转起点公平 claim。
	outboxPartitions *outboxpartition.Partitions
	outboxCtx        context.Context
	outboxCancel     context.CancelFunc
	outboxWG         sync.WaitGroup
	cfg              config.Config
	closeOnce        sync.Once
}

func New(cfg config.Config) (*App, error) {
	startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	repo, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN)
	if err != nil {
		return nil, err
	}
	walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		_ = repo.Close()
		return nil, err
	}
	userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		_ = walletConn.Close()
		_ = repo.Close()
		return nil, err
	}
	activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		_ = userConn.Close()
		_ = walletConn.Close()
		_ = repo.Close()
		return nil, err
	}
	robotConn, err := grpc.Dial(cfg.RobotServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		_ = activityConn.Close()
		_ = userConn.Close()
		_ = walletConn.Close()
		_ = repo.Close()
		return nil, err
	}
	roomConn, err := grpc.Dial(cfg.RoomServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		_ = robotConn.Close()
		_ = activityConn.Close()
		_ = userConn.Close()
		_ = walletConn.Close()
		_ = repo.Close()
		return nil, err
	}
	listener, err := net.Listen("tcp", cfg.GRPCAddr)
	if err != nil {
		_ = roomConn.Close()
		_ = robotConn.Close()
		_ = activityConn.Close()
		_ = userConn.Close()
		_ = walletConn.Close()
		_ = repo.Close()
		return nil, err
	}
	var outboxProducer *rocketmqx.Producer
	if cfg.RocketMQ.GameOutbox.Enabled {
		outboxProducer, err = rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ))
		if err != nil {
			_ = listener.Close()
			_ = roomConn.Close()
			_ = robotConn.Close()
			_ = activityConn.Close()
			_ = userConn.Close()
			_ = walletConn.Close()
			_ = repo.Close()
			return nil, err
		}
	}

	server := servicegrpc.New("game-service")
	walletClient := client.NewWalletClient(walletConn)
	userClient := client.NewUserClient(userConn)
	svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, walletClient, userClient, client.NewActivityGrowthClient(activityConn))
	// 骰子服务仍持有局事实仓储和钱包依赖，但机器人池已经拆到 robot-service；旧后台机器人 RPC 会通过这个客户端转发，避免后台接口一次性改约。
	diceSvc := diceservice.New(diceservice.Config{}, repo, walletClient, diceservice.WithRobotRegistry(client.NewGameRobotClient(robotConn)))
	var roomRPSPublisher roomrpsservice.Publisher
	if cfg.TencentIM.Enabled {
		// 房内 RPS 的“发起 PK”需要同步投递腾讯 IM 房间群消息；启动阶段先创建 REST client，避免运行中才发现配置缺失。
		roomRPSPublisher, err = roomrpsservice.NewTencentIMPublisher(cfg.TencentIM.RESTConfig())
		if err != nil {
			_ = listener.Close()
			_ = roomConn.Close()
			_ = robotConn.Close()
			_ = activityConn.Close()
			_ = userConn.Close()
			_ = walletConn.Close()
			_ = repo.Close()
			return nil, err
		}
	} else {
		// 显式关闭时保留 HTTP/RPC 功能，便于纯本地测试；测试服和线上必须在 YAML 中打开，否则用户看不到房内 PK。
		logx.Warn(context.Background(), "room_rps_im_disabled", slog.String("reason", "tencent_im.enabled=false"))
	}
	// 房内猜拳独立于骰子和独立猜拳：配置、挑战状态机和 IM 事件全部由 room-rps service 承接，transport 只做 RPC 适配。
	roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher, walletClient, repo, roomv1.NewRoomCommandServiceClient(roomConn))
	transport := grpcserver.NewServer(svc, diceSvc, roomRPSSvc)
	gamev1.RegisterGameAppServiceServer(server, transport)
	gamev1.RegisterGameCallbackServiceServer(server, transport)
	gamev1.RegisterGameAdminServiceServer(server, transport)
	gamev1.RegisterGameCronServiceServer(server, transport)
	health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
		ServiceName: "game-service",
		HTTPAddr:    cfg.HealthHTTPAddr,
		NodeID:      cfg.NodeID,
		Dependencies: []grpchealth.Dependency{{
			Name:  "mysql",
			Check: repo.Ping,
		}},
	})
	if err != nil {
		_ = listener.Close()
		_ = roomConn.Close()
		_ = robotConn.Close()
		_ = activityConn.Close()
		_ = userConn.Close()
		_ = walletConn.Close()
		_ = repo.Close()
		return nil, err
	}

	outboxCtx, outboxCancel := context.WithCancel(context.Background())
	return &App{server: server, listener: listener, health: health, healthHTTP: healthHTTP, repo: repo, walletConn: walletConn, userConn: userConn, activityConn: activityConn, robotConn: robotConn, roomConn: roomConn, outboxProducer: outboxProducer, outboxPartitions: outboxpartition.New(cfg.OutboxWorker.AppCodes, outboxpartition.DefaultDiscoveryRefreshInterval), outboxCtx: outboxCtx, outboxCancel: outboxCancel, cfg: cfg}, nil
}

func (a *App) Run() error {
	a.runHealthHTTP()
	if err := a.startMQ(); err != nil {
		a.health.MarkStopped()
		return err
	}
	a.runOutboxWorker()
	a.health.MarkServing()
	err := a.server.Serve(a.listener)
	a.health.MarkStopped()
	a.stopOutboxWorker()
	a.shutdownMQ()
	if errors.Is(err, grpc.ErrServerStopped) {
		return nil
	}
	return err
}

func (a *App) Close() {
	a.closeOnce.Do(func() {
		a.health.MarkDraining()
		a.stopOutboxWorker()
		servicegrpc.GracefulStop(a.server, 15*time.Second)
		a.closeHealthHTTP()
		if a.walletConn != nil {
			_ = a.walletConn.Close()
		}
		if a.userConn != nil {
			_ = a.userConn.Close()
		}
		if a.activityConn != nil {
			_ = a.activityConn.Close()
		}
		if a.robotConn != nil {
			_ = a.robotConn.Close()
		}
		if a.roomConn != nil {
			_ = a.roomConn.Close()
		}
		if a.repo != nil {
			_ = a.repo.Close()
		}
		a.shutdownMQ()
	})
}

func (a *App) runOutboxWorker() {
	if !a.cfg.OutboxWorker.Enabled || a.outboxProducer == nil || a.repo == nil {
		return
	}
	workerID := "game-outbox-" + a.cfg.NodeID
	a.outboxWG.Add(1)
	go func() {
		defer a.outboxWG.Done()
		ticker := time.NewTicker(a.cfg.OutboxWorker.PollInterval)
		defer ticker.Stop()
		for {
			processed, err := a.processOutboxBatch()
			if err != nil && !errors.Is(err, context.Canceled) {
				logx.Error(a.outboxCtx, "game_outbox_publish_failed", err, slog.String("worker_id", workerID))
			}
			if processed >= a.cfg.OutboxWorker.BatchSize {
				continue
			}
			select {
			case <-a.outboxCtx.Done():
				return
			case <-ticker.C:
			}
		}
	}()
}

func (a *App) processOutboxBatch() (int, error) {
	workerID := "game-outbox-" + a.cfg.NodeID
	appCodes, discoveryErr := a.outboxPartitions.Resolve(a.outboxCtx, a.repo.ListGameOutboxAppCodes)
	if discoveryErr != nil {
		if len(appCodes) == 0 {
			return 0, discoveryErr
		}
		// 已缓存 App 继续发布；低频刷新失败不应把整个 game outbox worker 停掉。
		logx.Warn(a.outboxCtx, "game_outbox_app_discovery_stale", slog.String("worker_id", workerID), slog.String("error", discoveryErr.Error()))
	}
	records, err := outboxpartition.ClaimFair(a.outboxCtx, a.outboxPartitions.Order(appCodes), a.cfg.OutboxWorker.BatchSize, func(claimCtx context.Context, appCode string, limit int) ([]mysqlstorage.GameOutboxRecord, error) {
		return a.repo.ClaimPendingGameOutbox(appcode.WithContext(claimCtx, appCode), workerID, limit)
	})
	if err != nil {
		return 0, err
	}
	for _, record := range records {
		publishCtx, cancel := context.WithTimeout(context.Background(), a.cfg.OutboxWorker.PublishTimeout)
		err := a.publishGameOutboxRecord(publishCtx, record)
		cancel()
		if err != nil {
			nextRetryAtMS := time.Now().UTC().Add(a.cfg.OutboxWorker.PollInterval).UnixMilli()
			if markErr := a.repo.MarkGameOutboxRetryable(context.Background(), record.AppCode, record.EventID, err.Error(), nextRetryAtMS); markErr != nil {
				return 0, markErr
			}
			return len(records), err
		}
		if err := a.repo.MarkGameOutboxDelivered(context.Background(), record.AppCode, record.EventID); err != nil {
			return 0, err
		}
	}
	return len(records), nil
}

func (a *App) publishGameOutboxRecord(ctx context.Context, record mysqlstorage.GameOutboxRecord) error {
	body, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{
		AppCode:      record.AppCode,
		EventID:      record.EventID,
		EventType:    record.EventType,
		OrderID:      record.OrderID,
		UserID:       record.UserID,
		PlatformCode: record.PlatformCode,
		GameID:       record.GameID,
		OpType:       record.OpType,
		CoinAmount:   record.CoinAmount,
		PayloadJSON:  record.PayloadJSON,
		OccurredAtMS: record.CreatedAtMS,
	})
	if err != nil {
		return err
	}
	return a.outboxProducer.SendSync(ctx, rocketmqx.Message{
		Topic: a.cfg.RocketMQ.GameOutbox.Topic,
		Tag:   gamemq.TagGameOutboxEvent,
		Keys:  []string{record.EventID, record.OrderID, record.GameID},
		Body:  body,
	})
}

func (a *App) startMQ() error {
	if a.outboxProducer == nil {
		return nil
	}
	return servicemq.StartProducers([]*rocketmqx.Producer{a.outboxProducer})
}

func (a *App) shutdownMQ() {
	servicemq.ShutdownProducers([]*rocketmqx.Producer{a.outboxProducer})
}

func (a *App) stopOutboxWorker() {
	if a.outboxCancel != nil {
		a.outboxCancel()
	}
	a.outboxWG.Wait()
}

func rocketMQProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig {
	return rocketmqx.ProducerConfig{
		EndpointConfig: rocketmqx.EndpointConfig{
			NameServers:      cfg.NameServers,
			NameServerDomain: cfg.NameServerDomain,
			AccessKey:        cfg.AccessKey,
			SecretKey:        cfg.SecretKey,
			SecurityToken:    cfg.SecurityToken,
			Namespace:        cfg.Namespace,
			VIPChannel:       cfg.VIPChannel,
		},
		GroupName:   cfg.GameOutbox.ProducerGroup,
		SendTimeout: cfg.SendTimeout,
		Retry:       cfg.Retry,
	}
}

func (a *App) runHealthHTTP() {
	if a.healthHTTP == nil {
		return
	}
	go func() {
		if err := a.healthHTTP.Run(); err != nil {
			logx.Error(context.Background(), "health_http_run_failed", err)
		}
	}()
}

func (a *App) closeHealthHTTP() {
	if a.healthHTTP == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	_ = a.healthHTTP.Close(ctx)
}
