package service

import (
	"context"
	"log/slog"
	"strings"
	"time"

	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/idgen"
	"hyapp/pkg/logx"
	"hyapp/pkg/tencentim"
	"hyapp/pkg/xerr"
	"hyapp/services/room-service/internal/room/command"
	"hyapp/services/room-service/internal/room/outbox"
	"hyapp/services/room-service/internal/room/rank"
	"hyapp/services/room-service/internal/room/state"
)

// JoinRoom 把用户业务态接入房间。
func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
	ctx = contextFromMeta(ctx, req.GetMeta())
	password := strings.TrimSpace(req.GetPassword())
	// JoinRoom 只维护 room-service presence，不创建任何本地长连接订阅。
	cmd := command.JoinRoom{
		Base:           baseFromMeta(req.GetMeta()),
		Role:           req.GetRole(),
		ActorCountryID: req.GetActorCountryId(),
		ActorRegionID:  req.GetActorRegionId(),
		// entry_vehicle 来自 gateway 入房瞬间的 wallet 快照，写入 command log 后可稳定重放。
		EntryVehicle: entryVehicleSnapshotFromProto(req.GetEntryVehicle()),
	}

	if cmd.Role == "" {
		// 客户端不传角色时按普通观众进入房间。
		cmd.Role = "audience"
	}
	if err := s.requireRoomOpenForEntry(ctx, cmd.RoomID()); err != nil {
		return nil, err
	}

	result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
		if ban, exists := current.BanUsers[cmd.ActorUserID()]; exists {
			if ban.ActiveAt(now.UnixMilli()) {
				// 被踢或封禁用户在 ban 未过期前不能重新进入业务 presence。
				return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is banned")
			}
			// 限时 ban 过期后由下一次进房命令清理，避免定时任务介入 Room Cell owner 状态。
			delete(current.BanUsers, cmd.ActorUserID())
		}

		if current.RoomPasswordHash != "" && current.OwnerUserID != cmd.ActorUserID() && !roomPasswordMatches(current.RoomPasswordHash, password) {
			// 锁房校验必须覆盖新进房和已有 presence 的重连；否则房主改密后，悬浮保活或 stale 未清理用户会绕过最新密码。
			return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "room password is invalid")
		}

		if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
			// 重复 JoinRoom 表示重连或刷新业务 presence，只更新 last_seen，不重复发进房系统消息。
			existing.LastSeenAtMS = now.UnixMilli()
			current.Version++

			snapshot := current.ToProto()
			return mutationResult{
				snapshot: snapshot,
				user:     findProtoUser(snapshot, cmd.ActorUserID()),
			}, nil, nil
		}
		current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
			UserID:       cmd.ActorUserID(),
			Role:         cmd.Role,
			JoinedAtMS:   now.UnixMilli(),
			LastSeenAtMS: now.UnixMilli(),
		}
		current.Version++

		// JoinRoom 成功需要 outbox 事件，腾讯云 IM 房间系统消息由 worker 异步投递。
		joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, &roomeventsv1.RoomUserJoined{
			UserId:          cmd.ActorUserID(),
			Role:            cmd.Role,
			VisibleRegionId: current.VisibleRegionID,
			CountryId:       cmd.ActorCountryID,
			RegionId:        firstNonZeroInt64(cmd.ActorRegionID, current.VisibleRegionID),
			// outbox 事件必须带座驾快照；否则异步补偿投递 IM 时会丢失入场座驾。
			EntryVehicle: eventEntryVehicleFromCommand(cmd.EntryVehicle),
		})
		if err != nil {
			return mutationResult{}, nil, err
		}

		snapshot := current.ToProto()

		return mutationResult{
			snapshot: snapshot,
			user:     findProtoUser(snapshot, cmd.ActorUserID()),
			syncEvent: &tencentim.RoomEvent{
				EventID:      joinedEvent.EventID,
				RoomID:       current.RoomID,
				EventType:    "room_user_joined",
				ActorUserID:  cmd.ActorUserID(),
				TargetUserID: cmd.ActorUserID(),
				RoomVersion:  current.Version,
				// syncEvent 与 outbox 事件保持同一份快照，避免低延迟路径和补偿路径 payload 不一致。
				EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
			},
		}, []outbox.Record{joinedEvent}, nil
	})
	if err != nil {
		return nil, err
	}
	s.projectRoomVisitBestEffort(ctx, cmd.ActorUserID(), cmd.RoomID())

	return &roomv1.JoinRoomResponse{
		Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
		User:   result.user,
		Room:   result.snapshot,
	}, nil
}

func entryVehicleSnapshotFromProto(item *roomv1.RoomEntryVehicleSnapshot) command.EntryVehicleSnapshot {
	if item == nil || item.GetResourceId() <= 0 {
		return command.EntryVehicleSnapshot{}
	}
	// 只裁剪字符串，不做有效期二次判断；wallet batch RPC 已经负责过期佩戴过滤。
	return command.EntryVehicleSnapshot{
		ResourceID:    item.GetResourceId(),
		ResourceCode:  strings.TrimSpace(item.GetResourceCode()),
		Name:          strings.TrimSpace(item.GetName()),
		AssetURL:      strings.TrimSpace(item.GetAssetUrl()),
		PreviewURL:    strings.TrimSpace(item.GetPreviewUrl()),
		AnimationURL:  strings.TrimSpace(item.GetAnimationUrl()),
		MetadataJSON:  strings.TrimSpace(item.GetMetadataJson()),
		EntitlementID: strings.TrimSpace(item.GetEntitlementId()),
		ExpiresAtMS:   item.GetExpiresAtMs(),
	}
}

func eventEntryVehicleFromCommand(item command.EntryVehicleSnapshot) *roomeventsv1.RoomEntryVehicleSnapshot {
	if item.ResourceID <= 0 {
		return nil
	}
	// 事件 proto 是补偿投递、统计消费的稳定事实，字段不依赖后续资源表变更。
	return &roomeventsv1.RoomEntryVehicleSnapshot{
		ResourceId:    item.ResourceID,
		ResourceCode:  item.ResourceCode,
		Name:          item.Name,
		AssetUrl:      item.AssetURL,
		PreviewUrl:    item.PreviewURL,
		AnimationUrl:  item.AnimationURL,
		MetadataJson:  item.MetadataJSON,
		EntitlementId: item.EntitlementID,
		ExpiresAtMs:   item.ExpiresAtMS,
	}
}

func imEntryVehicleFromCommand(item command.EntryVehicleSnapshot) *tencentim.RoomEntryVehicleSnapshot {
	if item.ResourceID <= 0 {
		return nil
	}
	// Tencent IM payload 使用客户端展示字段，保持和 /appearance 的资源字段命名一致。
	return &tencentim.RoomEntryVehicleSnapshot{
		ResourceID:    item.ResourceID,
		ResourceCode:  item.ResourceCode,
		Name:          item.Name,
		AssetURL:      item.AssetURL,
		PreviewURL:    item.PreviewURL,
		AnimationURL:  item.AnimationURL,
		MetadataJSON:  item.MetadataJSON,
		EntitlementID: item.EntitlementID,
		ExpiresAtMS:   item.ExpiresAtMS,
	}
}

// projectRoomVisitBestEffort 记录 Mine/Visited tab 的房间访问索引。
// 这是列表读模型，不参与 Room Cell 提交；失败只记录日志，不能影响已经成功的 JoinRoom。
func (s *Service) projectRoomVisitBestEffort(ctx context.Context, userID int64, roomID string) {
	if userID <= 0 || roomID == "" {
		return
	}
	nowMS := s.clock.Now().UnixMilli()
	if err := s.repository.UpsertRoomUserFeedEntry(ctx, RoomUserFeedEntry{
		AppCode:     appcode.FromContext(ctx),
		UserID:      userID,
		FeedType:    roomListTabVisited,
		RoomID:      roomID,
		CreatedAtMS: nowMS,
		UpdatedAtMS: nowMS,
	}); err != nil {
		logx.Error(ctx, "room_visit_feed_upsert_failed", err,
			slog.String("component", "room_user_feed_projector"),
			slog.String("room_id", roomID),
			slog.Int64("user_id", userID),
		)
	}
}

// RoomHeartbeat 显式刷新已在房间内用户的业务 presence。
func (s *Service) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) {
	ctx = contextFromMeta(ctx, req.GetMeta())
	cmd := command.RoomHeartbeat{
		Base: baseFromMeta(req.GetMeta()),
	}

	result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
		if err := requireActiveRoom(current); err != nil {
			return mutationResult{}, nil, err
		}
		if ban, exists := current.BanUsers[cmd.ActorUserID()]; exists {
			if ban.ActiveAt(now.UnixMilli()) {
				// ban 用户不能通过心跳续住业务 presence；KickUser 已经清理 presence，这里是防御脏状态。
				return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is banned")
			}
			// 过期 ban 不再阻断心跳；保守清理后继续按 presence 是否存在判断。
			delete(current.BanUsers, cmd.ActorUserID())
		}

		existing, exists := current.OnlineUsers[cmd.ActorUserID()]
		if !exists {
			// 心跳只刷新已存在 presence，不能把已离房用户重新加入房间。
			return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room")
		}

		existing.LastSeenAtMS = now.UnixMilli()
		current.Version++

		snapshot := current.ToProto()
		return mutationResult{
			snapshot: snapshot,
			user:     findProtoUser(snapshot, cmd.ActorUserID()),
		}, nil, nil
	})
	if err != nil {
		return nil, err
	}

	return &roomv1.RoomHeartbeatResponse{
		Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
		User:   result.user,
		Room:   result.snapshot,
	}, nil
}

// LeaveRoom 把用户从房间业务态移出。
func (s *Service) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
	ctx = contextFromMeta(ctx, req.GetMeta())
	// LeaveRoom 只移除 room-service 业务 presence，真实 IM 连接态由腾讯云 IM SDK 处理。
	cmd := command.LeaveRoom{
		Base: baseFromMeta(req.GetMeta()),
	}

	result, err := s.leaveRoom(ctx, cmd, "")
	if err != nil {
		return nil, err
	}

	return &roomv1.LeaveRoomResponse{
		Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
		Room:   result.snapshot,
	}, nil
}

func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason string) (mutationResult, error) {
	return s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
		_, inRoom := current.OnlineUsers[cmd.ActorUserID()]
		seat, onSeat := current.SeatByUser(cmd.ActorUserID())
		if !inRoom && !onSeat {
			// 离房幂等：没有业务 presence 且不占麦时不写命令日志和 outbox。
			return mutationResult{snapshot: current.ToProto()}, nil, nil
		}

		delete(current.OnlineUsers, cmd.ActorUserID())
		var micSessionID string
		if onSeat {
			// 离房必须同步释放麦位；同时写 RoomMicChanged/down，给时长聚合一个稳定闭合事件。
			index := current.SeatIndex(seat.SeatNo)
			if index >= 0 {
				micSessionID = current.MicSeats[index].MicSessionID
				current.ClearMicSession(index)
			}
		}
		current.Version++

		records := make([]outbox.Record, 0, 2)
		if onSeat && micSessionID != "" {
			micReason := reason
			if micReason == "" {
				micReason = "leave"
			}
			micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), cmd.ActorUserID(), seat.SeatNo, micSessionID, micReason)
			if err != nil {
				return mutationResult{}, nil, err
			}
			records = append(records, micEvent)
		}

		// 离房事件进入 outbox；stale 清理会通过 attributes 标记原因，腾讯云 IM 由 worker 异步投递。
		leftEvent, err := outbox.Build(current.RoomID, "RoomUserLeft", current.Version, now, &roomeventsv1.RoomUserLeft{
			UserId: cmd.ActorUserID(),
		})
		if err != nil {
			return mutationResult{}, nil, err
		}
		records = append(records, leftEvent)

		attributes := map[string]string{}
		if reason != "" {
			attributes["reason"] = reason
		}

		return mutationResult{
			snapshot: current.ToProto(),
			syncEvent: &tencentim.RoomEvent{
				EventID:      leftEvent.EventID,
				RoomID:       current.RoomID,
				EventType:    "room_user_left",
				ActorUserID:  cmd.ActorUserID(),
				TargetUserID: cmd.ActorUserID(),
				RoomVersion:  current.Version,
				Attributes:   attributes,
			},
		}, records, nil
	})
}

// RunPresenceStaleWorker 周期性清理本节点已装载房间中的过期业务 presence。
// worker 只调用 room-service 命令链路，不触碰腾讯云 IM 连接态，客户端重连后仍需重新 JoinRoom。
func (s *Service) RunPresenceStaleWorker(ctx context.Context, interval time.Duration) {
	if interval <= 0 || s.presenceStaleAfter <= 0 {
		// 非正数表示显式关闭 worker，测试或特殊环境可用。
		return
	}

	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			_ = s.SweepStalePresence(ctx)
		}
	}
}

// SweepStalePresence 扫描并清理已经超过 last_seen 阈值的业务 presence。
// 该方法暴露给测试和后台 worker 复用，所有清理都复用 LeaveRoom 的持久化、快照和 outbox 语义。
func (s *Service) SweepStalePresence(ctx context.Context) error {
	if s.presenceStaleAfter <= 0 {
		return nil
	}

	now := s.clock.Now()
	cutoffMs := now.Add(-s.presenceStaleAfter).UnixMilli()
	for _, roomRef := range s.loadedRoomRefs() {
		roomCtx := appcode.WithContext(ctx, roomRef.AppCode)
		lease, owned, err := s.loadedRoomLease(roomCtx, roomRef, now)
		if err != nil {
			return err
		}
		if !owned {
			continue
		}

		roomCell := s.loadCell(roomCtx, roomRef.RoomID)
		if roomCell == nil {
			continue
		}

		currentState, _, err := roomCell.Snapshot(roomCtx)
		if err != nil {
			return err
		}
		owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
		if err != nil {
			return err
		}
		if !owned {
			continue
		}

		for _, userID := range stalePresenceUserIDs(currentState, cutoffMs) {
			owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
			if err != nil {
				return err
			}
			if !owned {
				break
			}

			cmd := command.LeaveRoom{
				Base: command.Base{
					RequestID:     idgen.New("req_presence_stale"),
					CommandID:     idgen.New("cmd_presence_stale"),
					ActorID:       userID,
					Room:          roomRef.RoomID,
					GatewayNodeID: s.nodeID,
					SessionID:     "presence-stale",
					SentAtMS:      now.UnixMilli(),
				},
			}
			if _, err := s.leaveRoom(roomCtx, cmd, "stale_timeout"); err != nil {
				return err
			}
		}
	}

	return nil
}

func stalePresenceUserIDs(current *state.RoomState, cutoffMs int64) []int64 {
	if current == nil {
		return nil
	}

	userIDs := make([]int64, 0)
	for userID, userState := range current.OnlineUsers {
		if userState != nil && userState.LastSeenAtMS <= cutoffMs {
			userIDs = append(userIDs, userID)
		}
	}

	return userIDs
}
