package roomtime_test

import (
	"context"
	"database/sql"
	"testing"
	"time"

	roomtimedomain "hyapp/services/user-service/internal/domain/roomtime"
	"hyapp/services/user-service/internal/testutil/mysqltest"
)

func TestProcessJoinedAndLeftAggregatesDailyAndDeduplicates(t *testing.T) {
	wrapped := mysqltest.NewRepository(t)
	repo := wrapped.Repository.RoomTimeRepository()
	ctx := context.Background()
	base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC).UnixMilli()

	mustApply(t, repo, presenceEvent("evt-join", roomtimedomain.RoomUserJoinedEventType, "room-a", 20001, base))
	mustApply(t, repo, presenceEvent("evt-left", roomtimedomain.RoomUserLeftEventType, "room-a", 20001, base+11*60_000))

	assertDailyStats(t, wrapped.RawDB(), 20001, "2026-07-01", 11*60_000, 1)
	assertSessionEnded(t, wrapped.RawDB(), "evt-join", roomtimedomain.EndReasonLeft)
	assertUserOutboxEvent(t, wrapped.RawDB(), "UserRoomDailyStatsUpdated:evt-join:2026-07-01", 1)

	duplicate, err := repo.ProcessRoomPresenceEvent(ctx, presenceEvent("evt-left", roomtimedomain.RoomUserLeftEventType, "room-a", 20001, base+11*60_000))
	if err != nil || !duplicate.Skipped || duplicate.Reason != "already_consumed" {
		t.Fatalf("duplicate left should skip: result=%+v err=%v", duplicate, err)
	}
	assertDailyStats(t, wrapped.RawDB(), 20001, "2026-07-01", 11*60_000, 1)
}

func TestProcessKickedClosesOpenSession(t *testing.T) {
	wrapped := mysqltest.NewRepository(t)
	repo := wrapped.Repository.RoomTimeRepository()
	base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC).UnixMilli()

	mustApply(t, repo, presenceEvent("evt-kick-join", roomtimedomain.RoomUserJoinedEventType, "room-a", 20002, base))
	mustApply(t, repo, presenceEvent("evt-kick", roomtimedomain.RoomUserKickedEventType, "room-a", 20002, base+5*60_000))

	assertSessionEnded(t, wrapped.RawDB(), "evt-kick-join", roomtimedomain.EndReasonKicked)
	assertDailyStats(t, wrapped.RawDB(), 20002, "2026-07-01", 5*60_000, 1)
}

func TestLeftBeforeJoinedBuffersOrphanAndSettlesOnJoin(t *testing.T) {
	wrapped := mysqltest.NewRepository(t)
	repo := wrapped.Repository.RoomTimeRepository()
	ctx := context.Background()
	base := time.Date(2026, 7, 1, 14, 0, 0, 0, time.UTC).UnixMilli()

	// Left 先到：没有 open 会话，暂存为孤儿离房。
	result, err := repo.ProcessRoomPresenceEvent(ctx, presenceEvent("evt-ooo-left", roomtimedomain.RoomUserLeftEventType, "room-a", 20003, base+7*60_000))
	if err != nil || !result.Skipped || result.Reason != "left_before_join_buffered" {
		t.Fatalf("orphan left should buffer: result=%+v err=%v", result, err)
	}

	// Joined 后到：直接生成闭合会话，时长取两个事实的间隔。
	joined, err := repo.ProcessRoomPresenceEvent(ctx, presenceEvent("evt-ooo-join", roomtimedomain.RoomUserJoinedEventType, "room-a", 20003, base))
	if err != nil || !joined.Closed || joined.Reason != "matched_orphan_left" {
		t.Fatalf("joined should settle orphan left: result=%+v err=%v", joined, err)
	}
	assertSessionEnded(t, wrapped.RawDB(), "evt-ooo-join", roomtimedomain.EndReasonOrphanLeft)
	assertDailyStats(t, wrapped.RawDB(), 20003, "2026-07-01", 7*60_000, 1)

	var orphans int
	if err := wrapped.RawDB().QueryRowContext(ctx, `
		SELECT COUNT(*) FROM user_room_orphan_leaves WHERE app_code = 'lalu' AND user_id = 20003
	`).Scan(&orphans); err != nil {
		t.Fatalf("query orphan leaves failed: %v", err)
	}
	if orphans != 0 {
		t.Fatalf("matched orphan leave should be deleted, got %d rows", orphans)
	}
}

func TestJoinSupersedesLingeringOpenSessionInOtherRoom(t *testing.T) {
	wrapped := mysqltest.NewRepository(t)
	repo := wrapped.Repository.RoomTimeRepository()
	base := time.Date(2026, 7, 1, 16, 0, 0, 0, time.UTC).UnixMilli()

	mustApply(t, repo, presenceEvent("evt-super-join-a", roomtimedomain.RoomUserJoinedEventType, "room-a", 20004, base))
	// room-a 的 Left 丢失，用户直接出现在 room-b：旧会话按新进房时刻闭合，避免双计。
	mustApply(t, repo, presenceEvent("evt-super-join-b", roomtimedomain.RoomUserJoinedEventType, "room-b", 20004, base+3*60_000))

	assertSessionEnded(t, wrapped.RawDB(), "evt-super-join-a", roomtimedomain.EndReasonSupersededByJoin)
	assertDailyStats(t, wrapped.RawDB(), 20004, "2026-07-01", 3*60_000, 1)

	mustApply(t, repo, presenceEvent("evt-super-left-b", roomtimedomain.RoomUserLeftEventType, "room-b", 20004, base+10*60_000))
	assertDailyStats(t, wrapped.RawDB(), 20004, "2026-07-01", 10*60_000, 2)
}

func TestRobotSessionsCloseWithoutStatsFacts(t *testing.T) {
	wrapped := mysqltest.NewRepository(t)
	repo := wrapped.Repository.RoomTimeRepository()
	ctx := context.Background()
	base := time.Date(2026, 7, 1, 18, 0, 0, 0, time.UTC).UnixMilli()

	robotJoin := presenceEvent("evt-robot-join", roomtimedomain.RoomUserJoinedEventType, "room-a", 20005, base)
	robotJoin.IsRobot = true
	mustApply(t, repo, robotJoin)
	mustApply(t, repo, presenceEvent("evt-robot-left", roomtimedomain.RoomUserLeftEventType, "room-a", 20005, base+9*60_000))

	assertSessionEnded(t, wrapped.RawDB(), "evt-robot-join", roomtimedomain.EndReasonLeft)
	var rows int
	if err := wrapped.RawDB().QueryRowContext(ctx, `
		SELECT COUNT(*) FROM user_room_daily_stats WHERE app_code = 'lalu' AND user_id = 20005
	`).Scan(&rows); err != nil {
		t.Fatalf("query daily stats failed: %v", err)
	}
	if rows != 0 {
		t.Fatalf("robot session must not produce daily stats, got %d rows", rows)
	}
	assertUserOutboxEvent(t, wrapped.RawDB(), "UserRoomDailyStatsUpdated:evt-robot-join:2026-07-01", 0)
}

func TestCrossDaySessionSplitsByUTCDay(t *testing.T) {
	wrapped := mysqltest.NewRepository(t)
	repo := wrapped.Repository.RoomTimeRepository()
	base := time.Date(2026, 7, 1, 23, 50, 0, 0, time.UTC).UnixMilli()

	mustApply(t, repo, presenceEvent("evt-cross-join", roomtimedomain.RoomUserJoinedEventType, "room-a", 20006, base))
	mustApply(t, repo, presenceEvent("evt-cross-left", roomtimedomain.RoomUserLeftEventType, "room-a", 20006, base+30*60_000))

	assertDailyStats(t, wrapped.RawDB(), 20006, "2026-07-01", 10*60_000, 1)
	assertDailyStats(t, wrapped.RawDB(), 20006, "2026-07-02", 20*60_000, 1)
	assertUserOutboxEvent(t, wrapped.RawDB(), "UserRoomDailyStatsUpdated:evt-cross-join:2026-07-01", 1)
	assertUserOutboxEvent(t, wrapped.RawDB(), "UserRoomDailyStatsUpdated:evt-cross-join:2026-07-02", 1)
}

func TestCompensateOpenSessionsClosesStaleSessionsAndPrunesOrphans(t *testing.T) {
	wrapped := mysqltest.NewRepository(t)
	repo := wrapped.Repository.RoomTimeRepository()
	ctx := context.Background()
	base := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC).UnixMilli()

	mustApply(t, repo, presenceEvent("evt-comp-join", roomtimedomain.RoomUserJoinedEventType, "room-a", 20007, base))
	if _, err := repo.ProcessRoomPresenceEvent(ctx, presenceEvent("evt-comp-orphan", roomtimedomain.RoomUserLeftEventType, "room-b", 20008, base)); err != nil {
		t.Fatalf("buffer orphan left failed: %v", err)
	}

	result, err := repo.CompensateOpenSessions(ctx, roomtimedomain.CompensationOptions{
		NowMs:               base + 30*60*60*1000,
		BatchSize:           10,
		OpenSessionMaxAgeMs: 24 * 60 * 60 * 1000,
	})
	if err != nil {
		t.Fatalf("CompensateOpenSessions failed: %v", err)
	}
	if result.ClosedCount != 1 || result.OrphanLeavesDeleted != 1 {
		t.Fatalf("compensation result mismatch: %+v", result)
	}
	assertSessionEnded(t, wrapped.RawDB(), "evt-comp-join", roomtimedomain.CompensationReasonOpenSessionMaxWindow)
	// 补偿闭合按 joined_at + max_age 封顶记时长，跨 7/1 与 7/2 两个 UTC 日。
	assertDailyStats(t, wrapped.RawDB(), 20007, "2026-07-01", 16*60*60*1000, 1)
	assertDailyStats(t, wrapped.RawDB(), 20007, "2026-07-02", 8*60*60*1000, 1)
}

func mustApply(t *testing.T, repo interface {
	ProcessRoomPresenceEvent(context.Context, roomtimedomain.RoomPresenceEvent) (roomtimedomain.ApplyResult, error)
}, event roomtimedomain.RoomPresenceEvent) {
	t.Helper()
	result, err := repo.ProcessRoomPresenceEvent(context.Background(), event)
	if err != nil || result.Skipped {
		t.Fatalf("ProcessRoomPresenceEvent failed: event=%+v result=%+v err=%v", event, result, err)
	}
}

func presenceEvent(eventID string, eventType string, roomID string, userID int64, occurredAtMs int64) roomtimedomain.RoomPresenceEvent {
	return roomtimedomain.RoomPresenceEvent{
		AppCode:      "lalu",
		EventID:      eventID,
		EventType:    eventType,
		RoomID:       roomID,
		RoomVersion:  occurredAtMs,
		OccurredAtMs: occurredAtMs,
		UserID:       userID,
	}
}

func assertDailyStats(t *testing.T, db *sql.DB, userID int64, statDate string, wantOnlineMs int64, wantSessions int64) {
	t.Helper()
	var onlineMs, sessions int64
	if err := db.QueryRowContext(context.Background(), `
		SELECT room_online_ms, session_count
		FROM user_room_daily_stats
		WHERE app_code = 'lalu' AND user_id = ? AND stat_date = ?
	`, userID, statDate).Scan(&onlineMs, &sessions); err != nil {
		t.Fatalf("query user_room_daily_stats user=%d date=%s failed: %v", userID, statDate, err)
	}
	if onlineMs != wantOnlineMs || sessions != wantSessions {
		t.Fatalf("daily stats mismatch user=%d date=%s: online=%d sessions=%d, want online=%d sessions=%d", userID, statDate, onlineMs, sessions, wantOnlineMs, wantSessions)
	}
}

func assertSessionEnded(t *testing.T, db *sql.DB, roomSessionID string, reason string) {
	t.Helper()
	var status string
	var endReason string
	if err := db.QueryRowContext(context.Background(), `
		SELECT status, end_reason
		FROM user_room_sessions
		WHERE app_code = 'lalu' AND room_session_id = ?
	`, roomSessionID).Scan(&status, &endReason); err != nil {
		t.Fatalf("query user_room_sessions failed: %v", err)
	}
	if status != roomtimedomain.SessionStatusEnded || endReason != reason {
		t.Fatalf("session %s terminal state mismatch: status=%s reason=%s want=%s", roomSessionID, status, endReason, reason)
	}
}

func assertUserOutboxEvent(t *testing.T, db *sql.DB, eventID string, want int) {
	t.Helper()
	var count int
	if err := db.QueryRowContext(context.Background(), `
		SELECT COUNT(*)
		FROM user_outbox
		WHERE app_code = 'lalu' AND event_id = ?
	`, eventID).Scan(&count); err != nil {
		t.Fatalf("query user_outbox failed: %v", err)
	}
	if count != want {
		t.Fatalf("user_outbox event %s count=%d, want %d", eventID, count, want)
	}
}
