package main

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"flag"
	"fmt"
	"log"
	"os"
	"strings"
	"time"

	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"

	"hyapp/pkg/appcode"
	"hyapp/pkg/gamemq"
	"hyapp/pkg/roommq"
	"hyapp/pkg/usermq"
	"hyapp/pkg/walletmq"
	statapp "hyapp/services/statistics-service/internal/app"
	mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql"

	_ "github.com/go-sql-driver/mysql"
)

const replayEventIDMaxLen = 160

type replayWindow struct {
	startDay string
	endDay   string
	startMS  int64
	endMS    int64
}

type replaySummary struct {
	userRows   int64
	walletRows int64
	roomRows   int64
	gameRows   int64
}

func main() {
	var (
		appCode       = flag.String("app", envDefault("APP_CODE", "lalu"), "app_code to replay")
		statTZ        = flag.String("timezone", envDefault("STAT_TZ", mysqlstorage.StatTZAsiaShanghai), "stat_tz to rebuild: UTC or Asia/Shanghai")
		startDay      = flag.String("start-day", "", "inclusive stat day in selected timezone, yyyy-mm-dd")
		endDay        = flag.String("end-day", "", "inclusive stat day in selected timezone, yyyy-mm-dd")
		apply         = flag.Bool("apply", false, "apply mutations; dry-run only counts source rows by default")
		statisticsDSN = flag.String("statistics-dsn", firstNonEmptyEnv("STATISTICS_MYSQL_DSN", "MYSQL_DSN"), "statistics mysql dsn")
		userDSN       = flag.String("user-dsn", firstNonEmptyEnv("USER_MYSQL_DSN", "MYSQL_USER_DSN"), "user-service mysql dsn")
		walletDSN     = flag.String("wallet-dsn", firstNonEmptyEnv("WALLET_MYSQL_DSN", "MYSQL_WALLET_DSN"), "wallet-service mysql dsn")
		roomDSN       = flag.String("room-dsn", firstNonEmptyEnv("ROOM_MYSQL_DSN", "MYSQL_ROOM_DSN"), "room-service mysql dsn")
		gameDSN       = flag.String("game-dsn", firstNonEmptyEnv("GAME_MYSQL_DSN", "MYSQL_GAME_DSN"), "game-service mysql dsn")
	)
	flag.Parse()

	statTZValue, loc, err := normalizeReplayStatTZ(*statTZ)
	if err != nil {
		log.Fatal(err)
	}
	window, err := resolveReplayWindow(*startDay, *endDay, loc)
	if err != nil {
		log.Fatal(err)
	}
	if *apply && strings.TrimSpace(*statisticsDSN) == "" {
		log.Fatal("statistics-dsn is required when apply is true")
	}
	if *apply && (strings.TrimSpace(*userDSN) == "" || strings.TrimSpace(*walletDSN) == "" || strings.TrimSpace(*roomDSN) == "" || strings.TrimSpace(*gameDSN) == "") {
		log.Fatal("apply requires user-dsn, wallet-dsn, room-dsn and game-dsn so the rebuilt stat_tz window is complete for owner outbox facts")
	}

	ctx := context.Background()
	dbs := map[string]*sql.DB{}
	for name, dsn := range map[string]string{"user": *userDSN, "wallet": *walletDSN, "room": *roomDSN, "game": *gameDSN} {
		if strings.TrimSpace(dsn) == "" {
			continue
		}
		db, err := openDB(ctx, dsn)
		if err != nil {
			log.Fatalf("open %s db: %v", name, err)
		}
		defer db.Close()
		dbs[name] = db
	}

	summary, err := countSources(ctx, dbs, *appCode, window)
	if err != nil {
		log.Fatalf("count sources: %v", err)
	}
	if !*apply {
		fmt.Printf("dry-run app=%s stat_tz=%s days=%s..%s source_rows user=%d wallet=%d room=%d game=%d\n",
			*appCode, statTZValue, window.startDay, window.endDay, summary.userRows, summary.walletRows, summary.roomRows, summary.gameRows)
		return
	}
	statsDB, err := openDB(ctx, *statisticsDSN)
	if err != nil {
		log.Fatalf("open statistics db: %v", err)
	}
	defer statsDB.Close()
	repo, err := mysqlstorage.Open(ctx, *statisticsDSN)
	if err != nil {
		log.Fatalf("open statistics repository: %v", err)
	}
	defer repo.Close()
	if err := resetReplayTarget(ctx, statsDB, *appCode, statTZValue, window); err != nil {
		log.Fatalf("reset target: %v", err)
	}
	dimensionRows, err := primeUserDimensions(ctx, statsDB, dbs["user"], *appCode)
	if err != nil {
		log.Fatalf("prime user dimensions: %v", err)
	}
	scopeCtx := mysqlstorage.WithStatTZScope(ctx, statTZValue)
	if err := replayUser(ctx, scopeCtx, repo, dbs["user"], *appCode, statTZValue, window); err != nil {
		log.Fatalf("replay user outbox: %v", err)
	}
	if err := replayWallet(ctx, scopeCtx, repo, dbs["wallet"], *appCode, statTZValue, window); err != nil {
		log.Fatalf("replay wallet outbox: %v", err)
	}
	if err := replayRoom(ctx, scopeCtx, repo, dbs["room"], *appCode, statTZValue, window); err != nil {
		log.Fatalf("replay room outbox: %v", err)
	}
	if err := replayGame(ctx, scopeCtx, repo, dbs["game"], *appCode, statTZValue, window); err != nil {
		log.Fatalf("replay game outbox: %v", err)
	}
	fmt.Printf("applied app=%s stat_tz=%s days=%s..%s source_rows user=%d wallet=%d room=%d game=%d user_dimension_rows=%d\n",
		*appCode, statTZValue, window.startDay, window.endDay, summary.userRows, summary.walletRows, summary.roomRows, summary.gameRows, dimensionRows)
}

func normalizeReplayStatTZ(value string) (string, *time.Location, error) {
	switch strings.TrimSpace(value) {
	case mysqlstorage.StatTZUTC:
		return mysqlstorage.StatTZUTC, time.UTC, nil
	case mysqlstorage.StatTZAsiaShanghai:
		return mysqlstorage.StatTZAsiaShanghai, time.FixedZone(mysqlstorage.StatTZAsiaShanghai, 8*60*60), nil
	default:
		return "", nil, fmt.Errorf("unsupported stat_tz %q; only UTC and Asia/Shanghai are supported", value)
	}
}

func resolveReplayWindow(startDay string, endDay string, loc *time.Location) (replayWindow, error) {
	now := time.Now().In(loc)
	if strings.TrimSpace(startDay) == "" || strings.TrimSpace(endDay) == "" {
		// 默认重跑“最近 30 个已完成自然日”，避开当天实时消费者仍在写入造成 replay 后缀和原 event_id 同时入账。
		yesterday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -1)
		start := yesterday.AddDate(0, 0, -29)
		startDay = start.Format("2006-01-02")
		endDay = yesterday.Format("2006-01-02")
	}
	start, err := time.ParseInLocation("2006-01-02", startDay, loc)
	if err != nil {
		return replayWindow{}, fmt.Errorf("parse start-day: %w", err)
	}
	end, err := time.ParseInLocation("2006-01-02", endDay, loc)
	if err != nil {
		return replayWindow{}, fmt.Errorf("parse end-day: %w", err)
	}
	if end.Before(start) {
		return replayWindow{}, fmt.Errorf("end-day %s is before start-day %s", endDay, startDay)
	}
	return replayWindow{
		startDay: start.Format("2006-01-02"),
		endDay:   end.Format("2006-01-02"),
		startMS:  start.UnixMilli(),
		endMS:    end.AddDate(0, 0, 1).UnixMilli(),
	}, nil
}

func openDB(ctx context.Context, dsn string) (*sql.DB, error) {
	db, err := sql.Open("mysql", strings.TrimSpace(dsn))
	if err != nil {
		return nil, err
	}
	if err := db.PingContext(ctx); err != nil {
		_ = db.Close()
		return nil, err
	}
	return db, nil
}

func countSources(ctx context.Context, dbs map[string]*sql.DB, app string, window replayWindow) (replaySummary, error) {
	var out replaySummary
	var err error
	if db := dbs["user"]; db != nil {
		out.userRows, err = countOutboxRows(ctx, db, "user_outbox", app, userReplayEventTypes(), window)
		if err != nil {
			return replaySummary{}, err
		}
	}
	if db := dbs["wallet"]; db != nil {
		out.walletRows, err = countOutboxRows(ctx, db, "wallet_outbox", app, walletReplayEventTypes(), window)
		if err != nil {
			return replaySummary{}, err
		}
	}
	if db := dbs["room"]; db != nil {
		out.roomRows, err = countOutboxRows(ctx, db, "room_outbox", app, []string{"RoomGiftSent", "RoomUserJoined"}, window)
		if err != nil {
			return replaySummary{}, err
		}
	}
	if db := dbs["game"]; db != nil {
		out.gameRows, err = countOutboxRows(ctx, db, "game_outbox", app, gameReplayEventTypes(), window)
		if err != nil {
			return replaySummary{}, err
		}
	}
	return out, nil
}

func countOutboxRows(ctx context.Context, db *sql.DB, table string, app string, eventTypes []string, window replayWindow) (int64, error) {
	query, args := outboxWindowQuery("COUNT(*)", table, app, eventTypes, window, "")
	row := db.QueryRowContext(ctx, query, args...)
	var count int64
	return count, row.Scan(&count)
}

func resetReplayTarget(ctx context.Context, db *sql.DB, app string, statTZ string, window replayWindow) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()
	// 目标表只删除当前 stat_tz 的可重建 owner-outbox 聚合；H5 上报没有 owner outbox，不在这里删除，避免抹掉部署后实时进入的 H5 事件。
	for _, spec := range []struct {
		table string
		day   string
	}{
		{"stat_app_day_country", "stat_day"},
		{"stat_user_day_activity", "stat_day"},
		{"stat_user_registration", "registered_day"},
		{"stat_user_day_mic", "stat_day"},
		{"stat_recharge_day_payers", "stat_day"},
		{"stat_lucky_gift_day_payers", "stat_day"},
		{"stat_lucky_gift_pool_day_country", "stat_day"},
		{"stat_platform_grant_coin_events", "stat_day"},
		{"stat_real_room_robot_gift_day_rooms", "stat_day"},
		{"stat_game_day_country", "stat_day"},
		{"stat_game_day_players", "stat_day"},
		{"stat_self_game_match_day", "stat_day"},
		{"stat_self_game_pool_day", "stat_day"},
		{"stat_self_game_participant_day", "stat_day"},
		{"stat_self_game_user_day", "stat_day"},
	} {
		if _, err := tx.ExecContext(ctx, "DELETE FROM "+spec.table+" WHERE app_code = ? AND stat_tz = ? AND "+spec.day+" BETWEEN ? AND ?", app, statTZ, window.startDay, window.endDay); err != nil {
			return err
		}
	}
	token := replayToken(statTZ)
	for _, source := range []string{mysqlstorage.SourceUser, mysqlstorage.SourceWallet, mysqlstorage.SourceRoom, mysqlstorage.SourceGame} {
		if _, err := tx.ExecContext(ctx, `
			DELETE FROM statistics_event_consumption
			WHERE app_code = ? AND source = ? AND (event_id LIKE ? OR event_id LIKE ?)`,
			app, source, "%:stat_tz:"+token, "replay:"+token+":%"); err != nil {
			return err
		}
	}
	return tx.Commit()
}

func replayUser(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error {
	query, args := outboxWindowQuery(`
		app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms`, "user_outbox", app,
		userReplayEventTypes(), window, "ORDER BY created_at_ms ASC, event_id ASC")
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var msg usermq.UserOutboxMessage
		if err := rows.Scan(&msg.AppCode, &msg.EventID, &msg.EventType, &msg.AggregateType, &msg.AggregateID, &msg.PayloadJSON, &msg.OccurredAtMS); err != nil {
			return err
		}
		msg.EventID = replayEventID(msg.EventID, statTZ)
		body, err := usermq.EncodeUserOutboxMessage(msg)
		if err != nil {
			return err
		}
		if err := statapp.ConsumeUserOutboxBody(scopeCtx, repo, body); err != nil {
			return err
		}
	}
	return rows.Err()
}

func primeUserDimensions(ctx context.Context, statsDB *sql.DB, userDB *sql.DB, app string) (int64, error) {
	rows, err := userDB.QueryContext(ctx, `
		SELECT u.user_id, COALESCE(c.country_id, 0), COALESCE(u.region_id, 0)
		FROM users u
		LEFT JOIN countries c ON c.app_code = u.app_code AND c.country_code = u.country
		WHERE u.app_code = ?
		  AND u.user_id > 0
		  AND (COALESCE(c.country_id, 0) > 0 OR COALESCE(u.region_id, 0) > 0)`,
		appcode.Normalize(app))
	if err != nil {
		return 0, err
	}
	defer rows.Close()

	tx, err := statsDB.BeginTx(ctx, nil)
	if err != nil {
		return 0, err
	}
	defer tx.Rollback()

	stmt, err := tx.PrepareContext(ctx, `
		INSERT INTO stat_user_dimension (app_code, user_id, country_id, region_id, updated_at_ms)
		VALUES (?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
		  country_id = CASE WHEN VALUES(country_id) > 0 THEN VALUES(country_id) ELSE country_id END,
		  region_id = CASE WHEN VALUES(region_id) > 0 THEN VALUES(region_id) ELSE region_id END,
		  updated_at_ms = VALUES(updated_at_ms)`)
	if err != nil {
		return 0, err
	}
	defer stmt.Close()

	nowMS := time.Now().UnixMilli()
	var count int64
	for rows.Next() {
		var userID int64
		var countryID int64
		var regionID int64
		if err := rows.Scan(&userID, &countryID, &regionID); err != nil {
			return 0, err
		}
		if _, err := stmt.ExecContext(ctx, appcode.Normalize(app), userID, countryID, regionID, nowMS); err != nil {
			return 0, err
		}
		count++
	}
	if err := rows.Err(); err != nil {
		return 0, err
	}
	if err := tx.Commit(); err != nil {
		return 0, err
	}
	return count, nil
}

func replayWallet(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error {
	query, args := outboxWindowQuery(`
		app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
		available_delta, frozen_delta, CAST(payload AS CHAR), created_at_ms`, "wallet_outbox", app,
		walletReplayEventTypes(), window, "ORDER BY created_at_ms ASC, event_id ASC")
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var msg walletmq.WalletOutboxMessage
		if err := rows.Scan(&msg.AppCode, &msg.EventID, &msg.EventType, &msg.TransactionID, &msg.CommandID, &msg.UserID, &msg.AssetType, &msg.AvailableDelta, &msg.FrozenDelta, &msg.PayloadJSON, &msg.OccurredAtMS); err != nil {
			return err
		}
		msg.EventID = replayEventID(msg.EventID, statTZ)
		body, err := walletmq.EncodeWalletOutboxMessage(msg)
		if err != nil {
			return err
		}
		if err := statapp.ConsumeWalletOutboxBody(scopeCtx, repo, body); err != nil {
			return err
		}
	}
	return rows.Err()
}

func replayRoom(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error {
	query, args := outboxWindowQuery("envelope", "room_outbox", app, []string{"RoomGiftSent", "RoomUserJoined"}, window, "ORDER BY created_at_ms ASC, event_id ASC")
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var raw []byte
		if err := rows.Scan(&raw); err != nil {
			return err
		}
		var envelope roomeventsv1.EventEnvelope
		if err := proto.Unmarshal(raw, &envelope); err != nil {
			return err
		}
		envelope.EventId = replayEventID(envelope.GetEventId(), statTZ)
		body, err := roommq.EncodeRoomOutboxMessage(&envelope)
		if err != nil {
			return err
		}
		if err := statapp.ConsumeRoomOutboxBody(scopeCtx, repo, body); err != nil {
			return err
		}
	}
	return rows.Err()
}

func replayGame(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error {
	query, args := outboxWindowQuery(`
		app_code, event_id, event_type, order_id, user_id, platform_code, game_id,
		op_type, coin_amount, CAST(payload_json AS CHAR), created_at_ms`, "game_outbox", app, gameReplayEventTypes(), window, "ORDER BY created_at_ms ASC, event_id ASC")
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var msg gamemq.GameOutboxMessage
		if err := rows.Scan(&msg.AppCode, &msg.EventID, &msg.EventType, &msg.OrderID, &msg.UserID, &msg.PlatformCode, &msg.GameID, &msg.OpType, &msg.CoinAmount, &msg.PayloadJSON, &msg.OccurredAtMS); err != nil {
			return err
		}
		msg.EventID = replayEventID(msg.EventID, statTZ)
		body, err := gamemq.EncodeGameOutboxMessage(msg)
		if err != nil {
			return err
		}
		if err := statapp.ConsumeGameOutboxBody(scopeCtx, repo, body); err != nil {
			return err
		}
	}
	return rows.Err()
}

func outboxWindowQuery(selectExpr string, table string, app string, eventTypes []string, window replayWindow, suffix string) (string, []any) {
	placeholders := strings.TrimRight(strings.Repeat("?,", len(eventTypes)), ",")
	args := []any{app}
	for _, eventType := range eventTypes {
		args = append(args, eventType)
	}
	args = append(args, window.startMS, window.endMS)
	return fmt.Sprintf("SELECT %s FROM %s WHERE app_code = ? AND event_type IN (%s) AND created_at_ms >= ? AND created_at_ms < ? %s", selectExpr, table, placeholders, suffix), args
}

func userReplayEventTypes() []string {
	return []string{
		"UserRegistered",
		"UserRegionChanged",
		"UserMicDailyStatsUpdated",
	}
}

func walletReplayEventTypes() []string {
	return []string{
		"WalletRechargeRecorded",
		"WalletCoinSellerStockPurchased",
		"WalletBalanceChanged",
		"WalletTaskRewardCredited",
		"WalletLuckyGiftRewardCredited",
		"WalletWheelRewardCredited",
		"WalletRoomTurnoverRewardCredited",
		"WalletInviteActivityRewardCredited",
		"WalletAgencyOpeningRewardCredited",
	}
}

func gameReplayEventTypes() []string {
	return []string{
		gamemq.EventTypeGameOrderSettled,
		gamemq.EventTypeSelfGameMatchCreated,
		gamemq.EventTypeSelfGameMatchReady,
		gamemq.EventTypeSelfGameMatchSettled,
		gamemq.EventTypeSelfGameMatchCanceled,
		gamemq.EventTypeSelfGameMatchFailed,
		gamemq.EventTypeSelfGamePoolAdjusted,
	}
}

func replayEventID(eventID string, statTZ string) string {
	token := replayToken(statTZ)
	suffixed := eventID + ":stat_tz:" + token
	if len(suffixed) <= replayEventIDMaxLen {
		return suffixed
	}
	sum := sha256.Sum256([]byte(eventID + "|" + statTZ))
	return "replay:" + token + ":" + hex.EncodeToString(sum[:])[:40]
}

func replayToken(statTZ string) string {
	return strings.NewReplacer("/", "_", ":", "_").Replace(strings.ToLower(strings.TrimSpace(statTZ)))
}

func envDefault(key string, fallback string) string {
	if value := strings.TrimSpace(os.Getenv(key)); value != "" {
		return value
	}
	return fallback
}

func firstNonEmptyEnv(keys ...string) string {
	for _, key := range keys {
		if value := strings.TrimSpace(os.Getenv(key)); value != "" {
			return value
		}
	}
	return ""
}
