package main

import (
	"context"
	"crypto/sha1"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"strings"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/protobuf/proto"
	roomv1 "hyapp.local/api/proto/room/v1"
	"hyapp/pkg/appcode"
)

const (
	defaultLimit           = 2000
	ownerCountryRoomExtKey = "owner_country_code"
)

type roomCandidate struct {
	AppCode                string `json:"app_code"`
	RoomID                 string `json:"room_id"`
	RoomShortID            string `json:"room_short_id"`
	OwnerUserID            int64  `json:"owner_user_id"`
	VisibleRegionID        int64  `json:"visible_region_id"`
	OwnerRegionID          int64  `json:"owner_region_id"`
	ListOwnerCountryCode   string `json:"list_owner_country_code"`
	SnapshotCountryCode    string `json:"snapshot_country_code"`
	TargetOwnerCountryCode string `json:"target_owner_country_code"`
	NeedsListBackfill      bool   `json:"needs_list_backfill"`
	NeedsSnapshotBackfill  bool   `json:"needs_snapshot_backfill"`
}

type userCountryFact struct {
	CountryCode string
	RegionID    int64
}

func main() {
	var roomDSN string
	var userDSN string
	var roomGRPCAddr string
	var app string
	var roomID string
	var roomShortID string
	var limit int
	var timeout time.Duration
	var apply bool
	var continueOnError bool
	flag.StringVar(&roomDSN, "room_mysql_dsn", "", "room-service MySQL DSN")
	flag.StringVar(&userDSN, "user_mysql_dsn", "", "user-service MySQL DSN")
	flag.StringVar(&roomGRPCAddr, "room_grpc_addr", "", "room-service gRPC address; required with -apply")
	flag.StringVar(&app, "app_code", appcode.Default, "app_code scope")
	flag.StringVar(&roomID, "room_id", "", "optional room_id filter")
	flag.StringVar(&roomShortID, "room_short_id", "", "optional room_short_id filter")
	flag.IntVar(&limit, "limit", defaultLimit, "maximum room_list_entries rows to scan")
	flag.DurationVar(&timeout, "timeout", 10*time.Minute, "overall execution timeout")
	flag.BoolVar(&apply, "apply", false, "submit room-service mutations; omitted means dry-run")
	flag.BoolVar(&continueOnError, "continue_on_error", false, "log per-room failures and continue applying remaining candidates")
	flag.Parse()

	if strings.TrimSpace(roomDSN) == "" || strings.TrimSpace(userDSN) == "" {
		log.Fatal("room_mysql_dsn and user_mysql_dsn are required")
	}
	if apply && strings.TrimSpace(roomGRPCAddr) == "" {
		log.Fatal("room_grpc_addr is required with -apply")
	}

	ctx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), app), timeout)
	defer cancel()

	roomDB, err := sql.Open("mysql", roomDSN)
	if err != nil {
		log.Fatalf("open room mysql failed: %v", err)
	}
	defer roomDB.Close()
	roomDB.SetMaxOpenConns(4)
	roomDB.SetMaxIdleConns(4)
	roomDB.SetConnMaxLifetime(30 * time.Minute)
	if err := roomDB.PingContext(ctx); err != nil {
		log.Fatalf("ping room mysql failed: %v", err)
	}

	userDB, err := sql.Open("mysql", userDSN)
	if err != nil {
		log.Fatalf("open user mysql failed: %v", err)
	}
	defer userDB.Close()
	userDB.SetMaxOpenConns(4)
	userDB.SetMaxIdleConns(4)
	userDB.SetConnMaxLifetime(30 * time.Minute)
	if err := userDB.PingContext(ctx); err != nil {
		log.Fatalf("ping user mysql failed: %v", err)
	}

	candidates, err := findCandidates(ctx, roomDB, userDB, appcode.Normalize(app), roomID, roomShortID, limit)
	if err != nil {
		log.Fatalf("find candidates failed: %v", err)
	}
	encoder := json.NewEncoder(log.Writer())
	for _, candidate := range candidates {
		if err := encoder.Encode(candidate); err != nil {
			log.Fatalf("write candidate failed: %v", err)
		}
	}
	log.Printf("candidate_count=%d apply=%t", len(candidates), apply)
	if !apply || len(candidates) == 0 {
		return
	}

	conn, err := grpc.DialContext(ctx, roomGRPCAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("dial room-service failed: %v", err)
	}
	defer conn.Close()
	client := roomv1.NewRoomCommandServiceClient(conn)

	applied := 0
	failed := 0
	for _, candidate := range candidates {
		if candidate.NeedsListBackfill && !candidate.NeedsSnapshotBackfill && candidate.SnapshotCountryCode == candidate.TargetOwnerCountryCode {
			// 少数历史行可能只是列表投影丢失，而 snapshot 已经持有正确 RoomExt。
			// 这种情况没有状态变更可提交，直接按已验证的 snapshot 国家补列表字段，避免制造空命令。
			if err := repairListCountryFromSnapshot(ctx, roomDB, candidate); err != nil {
				failed++
				log.Printf("repair list projection room_id=%s owner_user_id=%d failed: %v", candidate.RoomID, candidate.OwnerUserID, err)
				if !continueOnError {
					log.Fatalf("stop after repair failure")
				}
				continue
			}
			if err := verifyCandidate(ctx, roomDB, candidate); err != nil {
				failed++
				log.Printf("verify room_id=%s owner_user_id=%d failed: %v", candidate.RoomID, candidate.OwnerUserID, err)
				if !continueOnError {
					log.Fatalf("stop after verify failure")
				}
				continue
			}
			applied++
			log.Printf("projected room_id=%s owner_user_id=%d country=%s", candidate.RoomID, candidate.OwnerUserID, candidate.TargetOwnerCountryCode)
			continue
		}
		// apply 必须走线上 room-service gRPC，让 owner forwarder 把命令转到真实 Room Cell owner；
		// 这样不会绕开 Redis lease，也不会被活跃房间旧内存态覆盖回去。
		if err := submitOwnerCountryBackfill(ctx, client, candidate); err != nil {
			failed++
			log.Printf("backfill room_id=%s owner_user_id=%d failed: %v", candidate.RoomID, candidate.OwnerUserID, err)
			if !continueOnError {
				log.Fatalf("stop after backfill failure")
			}
			continue
		}
		if err := verifyCandidate(ctx, roomDB, candidate); err != nil {
			failed++
			log.Printf("verify room_id=%s owner_user_id=%d failed: %v", candidate.RoomID, candidate.OwnerUserID, err)
			if !continueOnError {
				log.Fatalf("stop after verify failure")
			}
			continue
		}
		applied++
		log.Printf("applied room_id=%s owner_user_id=%d country=%s", candidate.RoomID, candidate.OwnerUserID, candidate.TargetOwnerCountryCode)
	}

	log.Printf("applied_count=%d failed_count=%d candidate_count=%d", applied, failed, len(candidates))
}

func findCandidates(ctx context.Context, roomDB *sql.DB, userDB *sql.DB, app string, roomID string, roomShortID string, limit int) ([]roomCandidate, error) {
	if limit <= 0 {
		limit = defaultLimit
	}
	entries, err := listRoomEntries(ctx, roomDB, app, roomID, roomShortID, limit)
	if err != nil {
		return nil, err
	}

	result := make([]roomCandidate, 0)
	for _, entry := range entries {
		userFact, ok, err := lookupUserCountryFact(ctx, userDB, app, entry.OwnerUserID)
		if err != nil {
			return nil, err
		}
		if !ok || userFact.CountryCode == "" {
			// user-service 当前国家事实是本次 backfill 的唯一来源；没有明确国家时不猜测兜底。
			continue
		}

		snapshotCountry, err := latestSnapshotOwnerCountry(ctx, roomDB, app, entry.RoomID)
		if err != nil {
			return nil, err
		}
		entry.SnapshotCountryCode = snapshotCountry
		entry.TargetOwnerCountryCode = userFact.CountryCode
		// 本工具只修复房主国家；UserRegionChanged 入口需要 new_region_id，
		// 这里显式传回房间当前 visible_region_id，避免把国家 backfill 扩大成区域迁移。
		entry.OwnerRegionID = entry.VisibleRegionID
		entry.NeedsListBackfill = entry.ListOwnerCountryCode == ""
		entry.NeedsSnapshotBackfill = snapshotCountry == ""
		if !entry.NeedsListBackfill && !entry.NeedsSnapshotBackfill {
			// 这次只补空值，不主动重写已有国家码，避免把“纠偏”和“补齐”混在一个线上动作里。
			continue
		}
		result = append(result, entry)
	}

	return result, nil
}

func listRoomEntries(ctx context.Context, roomDB *sql.DB, app string, roomID string, roomShortID string, limit int) ([]roomCandidate, error) {
	where := "WHERE e.app_code = ? AND e.status <> 'deleted' AND r.status <> 'deleted'"
	args := []any{app}
	if strings.TrimSpace(roomID) != "" {
		where += " AND e.room_id = ?"
		args = append(args, strings.TrimSpace(roomID))
	}
	if strings.TrimSpace(roomShortID) != "" {
		where += " AND e.room_short_id = ?"
		args = append(args, strings.TrimSpace(roomShortID))
	}
	args = append(args, limit)
	rows, err := roomDB.QueryContext(ctx, `
		SELECT e.room_id, e.room_short_id, e.owner_user_id, COALESCE(e.visible_region_id, 0), COALESCE(e.owner_country_code, '')
		FROM room_list_entries e
		JOIN rooms r ON r.app_code = e.app_code AND r.room_id = e.room_id
		`+where+`
		ORDER BY e.room_id ASC
		LIMIT ?
	`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	entries := make([]roomCandidate, 0)
	for rows.Next() {
		var entry roomCandidate
		entry.AppCode = app
		if err := rows.Scan(&entry.RoomID, &entry.RoomShortID, &entry.OwnerUserID, &entry.VisibleRegionID, &entry.ListOwnerCountryCode); err != nil {
			return nil, err
		}
		entry.ListOwnerCountryCode = normalizeCountryCode(entry.ListOwnerCountryCode)
		entries = append(entries, entry)
	}
	return entries, rows.Err()
}

func lookupUserCountryFact(ctx context.Context, userDB *sql.DB, app string, userID int64) (userCountryFact, bool, error) {
	var country string
	var regionID int64
	err := userDB.QueryRowContext(ctx, `
		SELECT COALESCE(country, ''), COALESCE(region_id, 0)
		FROM users
		WHERE app_code = ? AND user_id = ?
	`, app, userID).Scan(&country, &regionID)
	if err == sql.ErrNoRows {
		return userCountryFact{}, false, nil
	}
	if err != nil {
		return userCountryFact{}, false, err
	}
	return userCountryFact{CountryCode: normalizeCountryCode(country), RegionID: normalizeRegionID(regionID)}, true, nil
}

func latestSnapshotOwnerCountry(ctx context.Context, roomDB *sql.DB, app string, roomID string) (string, error) {
	var payload []byte
	err := roomDB.QueryRowContext(ctx, `
		SELECT payload
		FROM room_snapshots
		WHERE app_code = ? AND room_id = ?
	`, app, roomID).Scan(&payload)
	if err == sql.ErrNoRows {
		return "", nil
	}
	if err != nil {
		return "", err
	}
	var snapshot roomv1.RoomSnapshot
	if err := proto.Unmarshal(payload, &snapshot); err != nil {
		return "", err
	}
	return normalizeCountryCode(snapshot.GetRoomExt()[ownerCountryRoomExtKey]), nil
}

func submitOwnerCountryBackfill(ctx context.Context, client roomv1.RoomCommandServiceClient, candidate roomCandidate) error {
	nowMS := time.Now().UTC().UnixMilli()
	eventID := backfillEventID(candidate)
	ownerCountryCode := candidate.TargetOwnerCountryCode
	_, err := client.AdminUpdateRoom(ctx, &roomv1.AdminUpdateRoomRequest{
		Meta: &roomv1.RequestMeta{
			AppCode:     candidate.AppCode,
			RequestId:   eventID,
			CommandId:   eventID,
			ActorUserId: candidate.OwnerUserID,
			RoomId:      candidate.RoomID,
			SentAtMs:    nowMS,
		},
		AdminName:        "room-owner-country-backfill",
		OwnerCountryCode: &ownerCountryCode,
	})
	return err
}

func verifyCandidate(ctx context.Context, roomDB *sql.DB, candidate roomCandidate) error {
	snapshotCountry, err := latestSnapshotOwnerCountry(ctx, roomDB, candidate.AppCode, candidate.RoomID)
	if err != nil {
		return err
	}
	if snapshotCountry != candidate.TargetOwnerCountryCode {
		return fmt.Errorf("snapshot owner country=%q want %q", snapshotCountry, candidate.TargetOwnerCountryCode)
	}
	var listCountry string
	err = roomDB.QueryRowContext(ctx, `
		SELECT COALESCE(owner_country_code, '')
		FROM room_list_entries
		WHERE app_code = ? AND room_id = ?
	`, candidate.AppCode, candidate.RoomID).Scan(&listCountry)
	if err == sql.ErrNoRows {
		return fmt.Errorf("room_list_entries row is missing")
	}
	if err != nil {
		return err
	}
	if normalizeCountryCode(listCountry) != candidate.TargetOwnerCountryCode {
		return fmt.Errorf("list owner country=%q want %q", listCountry, candidate.TargetOwnerCountryCode)
	}
	return nil
}

func repairListCountryFromSnapshot(ctx context.Context, roomDB *sql.DB, candidate roomCandidate) error {
	result, err := roomDB.ExecContext(ctx, `
		UPDATE room_list_entries
		SET owner_country_code = ?, updated_at_ms = ?
		WHERE app_code = ? AND room_id = ? AND COALESCE(owner_country_code, '') = ''
	`, candidate.TargetOwnerCountryCode, time.Now().UTC().UnixMilli(), candidate.AppCode, candidate.RoomID)
	if err != nil {
		return err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if affected != 1 {
		return fmt.Errorf("projection repair affected %d rows", affected)
	}
	return nil
}

func backfillEventID(candidate roomCandidate) string {
	sum := sha1.Sum([]byte(fmt.Sprintf("%s:%s:%d:%s", candidate.AppCode, candidate.RoomID, candidate.OwnerUserID, candidate.TargetOwnerCountryCode)))
	return "room-owner-country-backfill:v3:" + hex.EncodeToString(sum[:])
}

func normalizeCountryCode(countryCode string) string {
	countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
	if len(countryCode) < 2 || len(countryCode) > 3 {
		return ""
	}
	for _, ch := range countryCode {
		if ch < 'A' || ch > 'Z' {
			return ""
		}
	}
	return countryCode
}

func normalizeRegionID(regionID int64) int64 {
	if regionID < 0 {
		return 0
	}
	return regionID
}
