// Package roomtime stores user-scoped in-room duration read models in MySQL.
package roomtime

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

	mysqldriver "github.com/go-sql-driver/mysql"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	roomtimedomain "hyapp/services/user-service/internal/domain/roomtime"
)

// Repository owns in-room session state and user duration aggregates inside user-service MySQL.
type Repository struct {
	db *sql.DB
}

const (
	defaultCompensationBatchSize     = 200
	defaultOpenSessionCompensationMs = int64(24 * 60 * 60 * 1000)
	userOutboxStatusPending          = "pending"
)

// New creates an in-room duration repository backed by the shared user-service connection pool.
func New(db *sql.DB) *Repository {
	return &Repository{db: db}
}

// ProcessRoomPresenceEvent idempotently applies one RoomUserJoined/RoomUserLeft/RoomUserKicked fact.
func (r *Repository) ProcessRoomPresenceEvent(ctx context.Context, event roomtimedomain.RoomPresenceEvent) (roomtimedomain.ApplyResult, error) {
	if r == nil || r.db == nil {
		return roomtimedomain.ApplyResult{}, xerr.New(xerr.Unavailable, "room time repository is not configured")
	}
	event = normalizeRoomPresenceEvent(event)
	if event.EventID == "" || event.EventType == "" {
		return roomtimedomain.ApplyResult{}, xerr.New(xerr.InvalidArgument, "room presence event identity is required")
	}
	if event.OccurredAtMs <= 0 {
		event.OccurredAtMs = time.Now().UnixMilli()
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return roomtimedomain.ApplyResult{}, err
	}
	defer tx.Rollback()

	if err := insertEventConsumption(ctx, tx, event); err != nil {
		if isDuplicate(err) {
			return roomtimedomain.ApplyResult{Skipped: true, Reason: "already_consumed"}, nil
		}
		return roomtimedomain.ApplyResult{}, err
	}

	var result roomtimedomain.ApplyResult
	switch {
	case event.RoomID == "" || event.UserID <= 0:
		result = roomtimedomain.ApplyResult{Skipped: true, Reason: "incomplete_presence_event"}
	case event.EventType == roomtimedomain.RoomUserJoinedEventType:
		result, err = applyJoined(ctx, tx, event)
	case event.EventType == roomtimedomain.RoomUserLeftEventType:
		result, err = applyLeft(ctx, tx, event, roomtimedomain.EndReasonLeft)
	case event.EventType == roomtimedomain.RoomUserKickedEventType:
		result, err = applyLeft(ctx, tx, event, roomtimedomain.EndReasonKicked)
	default:
		result = roomtimedomain.ApplyResult{Skipped: true, Reason: "unsupported_presence_event"}
	}
	if err != nil {
		return roomtimedomain.ApplyResult{}, err
	}
	if result.Reason == "" && result.Skipped {
		result.Reason = "skipped"
	}
	status := "consumed"
	if result.Skipped {
		status = "skipped"
	}
	if err := finishEventConsumption(ctx, tx, event, status, result.Reason); err != nil {
		return roomtimedomain.ApplyResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return roomtimedomain.ApplyResult{}, err
	}
	if !result.Skipped {
		result.Consumed = true
	}
	return result, nil
}

// CompensateOpenSessions closes abnormal sessions whose authoritative leave event never reached user-service.
func (r *Repository) CompensateOpenSessions(ctx context.Context, options roomtimedomain.CompensationOptions) (roomtimedomain.CompensationResult, error) {
	if r == nil || r.db == nil {
		return roomtimedomain.CompensationResult{}, xerr.New(xerr.Unavailable, "room time repository is not configured")
	}
	options = normalizeCompensationOptions(options)

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return roomtimedomain.CompensationResult{}, err
	}
	defer tx.Rollback()

	cutoff := options.NowMs - options.OpenSessionMaxAgeMs
	app := appcode.FromContext(ctx)
	rows, err := tx.QueryContext(ctx, `
		SELECT app_code, room_session_id, user_id, room_id, status, is_robot, joined_at_ms, last_room_version
		FROM user_room_sessions
		WHERE app_code = ? AND status = ? AND joined_at_ms <= ?
		ORDER BY joined_at_ms ASC, room_session_id ASC
		LIMIT ?
		FOR UPDATE SKIP LOCKED
	`, app, roomtimedomain.SessionStatusOpen, cutoff, options.BatchSize)
	if err != nil {
		return roomtimedomain.CompensationResult{}, err
	}

	candidates := make([]roomSessionRow, 0, options.BatchSize)
	for rows.Next() {
		var session roomSessionRow
		if err := rows.Scan(&session.AppCode, &session.RoomSessionID, &session.UserID, &session.RoomID, &session.Status, &session.IsRobot, &session.JoinedAtMs, &session.LastRoomVersion); err != nil {
			_ = rows.Close()
			return roomtimedomain.CompensationResult{}, err
		}
		candidates = append(candidates, session)
	}
	if err := rows.Close(); err != nil {
		return roomtimedomain.CompensationResult{}, err
	}
	if err := rows.Err(); err != nil {
		return roomtimedomain.CompensationResult{}, err
	}

	var result roomtimedomain.CompensationResult
	for _, session := range candidates {
		if err := closeRoomSession(ctx, tx, session, closeRoomSessionInput{
			ClosedEventID: fmt.Sprintf("compensated:%s", session.RoomSessionID),
			EndAtMs:       minInt64(options.NowMs, session.JoinedAtMs+options.OpenSessionMaxAgeMs),
			EndReason:     roomtimedomain.CompensationReasonOpenSessionMaxWindow,
			UpdatedAtMs:   options.NowMs,
			RoomVersion:   session.LastRoomVersion,
		}); err != nil {
			return roomtimedomain.CompensationResult{}, err
		}
		result.ClosedCount++
	}

	// 孤儿 Left 只用于修正乱序到达；超过补偿窗口后已不可能再匹配任何进房事实，按批清理。
	deleted, err := tx.ExecContext(ctx, `
		DELETE FROM user_room_orphan_leaves
		WHERE app_code = ? AND left_at_ms <= ?
		LIMIT ?
	`, app, cutoff, options.BatchSize)
	if err != nil {
		return roomtimedomain.CompensationResult{}, err
	}
	if affected, err := deleted.RowsAffected(); err == nil {
		result.OrphanLeavesDeleted = int(affected)
	}

	if err := tx.Commit(); err != nil {
		return roomtimedomain.CompensationResult{}, err
	}
	return result, nil
}

func applyJoined(ctx context.Context, tx *sql.Tx, event roomtimedomain.RoomPresenceEvent) (roomtimedomain.ApplyResult, error) {
	openSessions, err := openSessionsForUpdate(ctx, tx, event.AppCode, event.UserID)
	if err != nil {
		return roomtimedomain.ApplyResult{}, err
	}
	for _, session := range openSessions {
		// 一个用户同一时刻只保留一个在房会话：房间侧一个在线周期只发一条 Joined，
		// 这里还有 open 会话说明它的 Left/Kicked 丢失或尚未到达，用新进房时刻闭合避免双计。
		reason := roomtimedomain.EndReasonSupersededByJoin
		if session.RoomID == event.RoomID {
			reason = roomtimedomain.EndReasonSupersededByRejoin
		}
		if err := closeRoomSession(ctx, tx, session, closeRoomSessionInput{
			ClosedEventID: event.EventID,
			EndAtMs:       event.OccurredAtMs,
			EndReason:     reason,
			UpdatedAtMs:   event.OccurredAtMs,
			RoomVersion:   event.RoomVersion,
		}); err != nil {
			return roomtimedomain.ApplyResult{}, err
		}
	}

	orphan, matched, err := matchOrphanLeave(ctx, tx, event)
	if err != nil {
		return roomtimedomain.ApplyResult{}, err
	}
	joinedSession := roomSessionRow{
		AppCode:         event.AppCode,
		RoomSessionID:   event.EventID,
		UserID:          event.UserID,
		RoomID:          event.RoomID,
		Status:          roomtimedomain.SessionStatusOpen,
		IsRobot:         event.IsRobot,
		JoinedAtMs:      event.OccurredAtMs,
		LastRoomVersion: event.RoomVersion,
	}
	if matched {
		// Left 先到、Joined 后到：直接落一条已闭合会话，时长取两个事实的间隔。
		if err := insertRoomSession(ctx, tx, joinedSession, event, roomtimedomain.SessionStatusOpen); err != nil {
			return roomtimedomain.ApplyResult{}, err
		}
		if err := closeRoomSession(ctx, tx, joinedSession, closeRoomSessionInput{
			ClosedEventID: orphan.EventID,
			EndAtMs:       orphan.LeftAtMs,
			EndReason:     roomtimedomain.EndReasonOrphanLeft,
			UpdatedAtMs:   event.OccurredAtMs,
			RoomVersion:   event.RoomVersion,
		}); err != nil {
			return roomtimedomain.ApplyResult{}, err
		}
		if _, err := tx.ExecContext(ctx, `
			DELETE FROM user_room_orphan_leaves WHERE app_code = ? AND event_id = ?
		`, event.AppCode, orphan.EventID); err != nil {
			return roomtimedomain.ApplyResult{}, err
		}
		return roomtimedomain.ApplyResult{Consumed: true, Opened: true, Closed: true, Reason: "matched_orphan_left"}, nil
	}

	if err := insertRoomSession(ctx, tx, joinedSession, event, roomtimedomain.SessionStatusOpen); err != nil {
		return roomtimedomain.ApplyResult{}, err
	}
	return roomtimedomain.ApplyResult{Consumed: true, Opened: true}, nil
}

func applyLeft(ctx context.Context, tx *sql.Tx, event roomtimedomain.RoomPresenceEvent, endReason string) (roomtimedomain.ApplyResult, error) {
	openSessions, err := openRoomSessionsForUpdate(ctx, tx, event.AppCode, event.RoomID, event.UserID)
	if err != nil {
		return roomtimedomain.ApplyResult{}, err
	}
	closed := 0
	sawNewerSession := false
	for _, session := range openSessions {
		if session.JoinedAtMs > event.OccurredAtMs {
			// Left 早于当前 open 会话的进房时刻，说明它属于更早的在线周期；那个周期已被 supersede 闭合，直接丢弃。
			sawNewerSession = true
			continue
		}
		if err := closeRoomSession(ctx, tx, session, closeRoomSessionInput{
			ClosedEventID: event.EventID,
			EndAtMs:       event.OccurredAtMs,
			EndReason:     endReason,
			UpdatedAtMs:   event.OccurredAtMs,
			RoomVersion:   event.RoomVersion,
		}); err != nil {
			return roomtimedomain.ApplyResult{}, err
		}
		closed++
	}
	if closed > 0 {
		return roomtimedomain.ApplyResult{Consumed: true, Closed: true}, nil
	}
	if sawNewerSession {
		return roomtimedomain.ApplyResult{Skipped: true, Reason: "stale_left_event"}, nil
	}
	// 没有可闭合的会话：Left 可能先于 Joined 到达，暂存等 Joined 到达时直接生成闭合会话。
	// 踢人对已离房用户也会发 RoomUserKicked，这类孤儿记录不会匹配任何更晚的进房事实，由补偿任务清理。
	if _, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO user_room_orphan_leaves (app_code, event_id, room_id, user_id, left_at_ms, reason, created_at_ms)
		VALUES (?, ?, ?, ?, ?, ?, ?)
	`, event.AppCode, event.EventID, event.RoomID, event.UserID, event.OccurredAtMs, endReason, event.OccurredAtMs); err != nil {
		return roomtimedomain.ApplyResult{}, err
	}
	return roomtimedomain.ApplyResult{Skipped: true, Reason: "left_before_join_buffered"}, nil
}

type closeRoomSessionInput struct {
	ClosedEventID string
	EndAtMs       int64
	EndReason     string
	UpdatedAtMs   int64
	RoomVersion   int64
}

func closeRoomSession(ctx context.Context, tx *sql.Tx, session roomSessionRow, input closeRoomSessionInput) error {
	input.ClosedEventID = strings.TrimSpace(input.ClosedEventID)
	if input.ClosedEventID == "" {
		input.ClosedEventID = "closed:" + session.RoomSessionID
	}
	input.EndReason = strings.TrimSpace(input.EndReason)
	if input.EndReason == "" {
		input.EndReason = roomtimedomain.EndReasonLeft
	}
	if input.EndAtMs < session.JoinedAtMs {
		input.EndAtMs = session.JoinedAtMs
	}
	if input.UpdatedAtMs <= 0 {
		input.UpdatedAtMs = input.EndAtMs
	}
	roomOnlineMs := maxInt64(0, input.EndAtMs-session.JoinedAtMs)

	_, err := tx.ExecContext(ctx, `
		UPDATE user_room_sessions
		SET status = ?, ended_at_ms = ?, end_reason = ?, room_online_ms = ?,
		    closed_event_id = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
		WHERE app_code = ? AND room_session_id = ? AND status <> ?
	`, roomtimedomain.SessionStatusEnded, input.EndAtMs, input.EndReason, roomOnlineMs,
		input.ClosedEventID, input.ClosedEventID, input.RoomVersion, input.UpdatedAtMs,
		session.AppCode, session.RoomSessionID, roomtimedomain.SessionStatusEnded)
	if err != nil {
		return err
	}
	if session.IsRobot {
		// 机器人在房不产生活跃/时长统计事实；会话仍闭合留档，方便排查调度行为。
		return nil
	}
	return incrementDaily(ctx, tx, session, input)
}

func incrementDaily(ctx context.Context, tx *sql.Tx, session roomSessionRow, input closeRoomSessionInput) error {
	for date, part := range splitByUTCDay(session.JoinedAtMs, input.EndAtMs) {
		_, err := tx.ExecContext(ctx, `
			INSERT INTO user_room_daily_stats (
				app_code, user_id, stat_date, room_online_ms, session_count, first_room_at_ms, last_room_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, 1, ?, ?, ?)
			ON DUPLICATE KEY UPDATE
				room_online_ms = room_online_ms + VALUES(room_online_ms),
				session_count = session_count + 1,
				first_room_at_ms = CASE WHEN first_room_at_ms = 0 OR first_room_at_ms > VALUES(first_room_at_ms) THEN VALUES(first_room_at_ms) ELSE first_room_at_ms END,
				last_room_at_ms = GREATEST(last_room_at_ms, VALUES(last_room_at_ms)),
				updated_at_ms = VALUES(updated_at_ms)
		`, session.AppCode, session.UserID, date, part.DurationMs, part.FirstAtMs, part.LastAtMs, input.UpdatedAtMs)
		if err != nil {
			return err
		}
		if err := insertUserOutbox(ctx, tx, session.AppCode,
			roomtimedomain.EventTypeUserRoomDailyStatsUpdated+":"+session.RoomSessionID+":"+date,
			roomtimedomain.EventTypeUserRoomDailyStatsUpdated, "user_room_daily_stats", session.UserID, input.UpdatedAtMs, map[string]any{
				"app_code":             session.AppCode,
				"user_id":              session.UserID,
				"room_id":              session.RoomID,
				"stat_date":            date,
				"room_session_id":      session.RoomSessionID,
				"room_online_delta_ms": part.DurationMs,
				"session_count_delta":  1,
				"end_reason":           input.EndReason,
				"updated_at_ms":        input.UpdatedAtMs,
			}); err != nil {
			return err
		}
	}
	return nil
}

type roomSessionRow struct {
	AppCode         string
	RoomSessionID   string
	UserID          int64
	RoomID          string
	Status          string
	IsRobot         bool
	JoinedAtMs      int64
	LastRoomVersion int64
}

type orphanLeaveRow struct {
	EventID  string
	LeftAtMs int64
}

func openSessionsForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64) ([]roomSessionRow, error) {
	return scanSessions(tx.QueryContext(ctx, `
		SELECT app_code, room_session_id, user_id, room_id, status, is_robot, joined_at_ms, last_room_version
		FROM user_room_sessions
		WHERE app_code = ? AND user_id = ? AND status = ?
		ORDER BY joined_at_ms ASC, room_session_id ASC
		FOR UPDATE
	`, appCode, userID, roomtimedomain.SessionStatusOpen))
}

func openRoomSessionsForUpdate(ctx context.Context, tx *sql.Tx, appCode string, roomID string, userID int64) ([]roomSessionRow, error) {
	return scanSessions(tx.QueryContext(ctx, `
		SELECT app_code, room_session_id, user_id, room_id, status, is_robot, joined_at_ms, last_room_version
		FROM user_room_sessions
		WHERE app_code = ? AND user_id = ? AND room_id = ? AND status = ?
		ORDER BY joined_at_ms ASC, room_session_id ASC
		FOR UPDATE
	`, appCode, userID, roomID, roomtimedomain.SessionStatusOpen))
}

func scanSessions(rows *sql.Rows, err error) ([]roomSessionRow, error) {
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	sessions := []roomSessionRow{}
	for rows.Next() {
		var session roomSessionRow
		if err := rows.Scan(&session.AppCode, &session.RoomSessionID, &session.UserID, &session.RoomID, &session.Status, &session.IsRobot, &session.JoinedAtMs, &session.LastRoomVersion); err != nil {
			return nil, err
		}
		sessions = append(sessions, session)
	}
	return sessions, rows.Err()
}

func matchOrphanLeave(ctx context.Context, tx *sql.Tx, event roomtimedomain.RoomPresenceEvent) (orphanLeaveRow, bool, error) {
	var orphan orphanLeaveRow
	err := tx.QueryRowContext(ctx, `
		SELECT event_id, left_at_ms
		FROM user_room_orphan_leaves
		WHERE app_code = ? AND room_id = ? AND user_id = ? AND left_at_ms >= ?
		ORDER BY left_at_ms ASC
		LIMIT 1
		FOR UPDATE
	`, event.AppCode, event.RoomID, event.UserID, event.OccurredAtMs).Scan(&orphan.EventID, &orphan.LeftAtMs)
	if err == sql.ErrNoRows {
		return orphanLeaveRow{}, false, nil
	}
	if err != nil {
		return orphanLeaveRow{}, false, err
	}
	return orphan, true, nil
}

func insertRoomSession(ctx context.Context, tx *sql.Tx, session roomSessionRow, event roomtimedomain.RoomPresenceEvent, status string) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_room_sessions (
			app_code, room_session_id, user_id, room_id, status, is_robot,
			joined_at_ms, ended_at_ms, end_reason, room_online_ms,
			opened_event_id, closed_event_id, last_event_id, last_room_version, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, '', 0, ?, '', ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			last_event_id = VALUES(last_event_id),
			last_room_version = GREATEST(last_room_version, VALUES(last_room_version)),
			updated_at_ms = VALUES(updated_at_ms)
	`, session.AppCode, session.RoomSessionID, session.UserID, session.RoomID, status, session.IsRobot,
		session.JoinedAtMs, event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.OccurredAtMs)
	return err
}

func insertEventConsumption(ctx context.Context, tx *sql.Tx, event roomtimedomain.RoomPresenceEvent) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_room_event_consumption (
			app_code, event_id, event_type, room_id, user_id, status, skip_reason,
			created_at_ms, consumed_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 'processing', '', ?, NULL, ?)
	`, event.AppCode, event.EventID, event.EventType, event.RoomID, event.UserID, event.OccurredAtMs, event.OccurredAtMs)
	return err
}

func finishEventConsumption(ctx context.Context, tx *sql.Tx, event roomtimedomain.RoomPresenceEvent, status string, reason string) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE user_room_event_consumption
		SET status = ?, skip_reason = ?, consumed_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND event_id = ?
	`, status, strings.TrimSpace(reason), event.OccurredAtMs, event.OccurredAtMs, event.AppCode, event.EventID)
	return err
}

func insertUserOutbox(ctx context.Context, tx *sql.Tx, appCode string, eventID string, eventType string, aggregateType string, aggregateID int64, createdAtMs int64, payload map[string]any) error {
	payloadBytes, err := json.Marshal(payload)
	if err != nil {
		return err
	}
	_, err = tx.ExecContext(ctx, `
		INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
	`, appcode.Normalize(appCode), eventID, eventType, aggregateType, aggregateID, userOutboxStatusPending, string(payloadBytes), createdAtMs, createdAtMs)
	return err
}

func normalizeRoomPresenceEvent(event roomtimedomain.RoomPresenceEvent) roomtimedomain.RoomPresenceEvent {
	event.AppCode = appcode.Normalize(event.AppCode)
	event.EventID = strings.TrimSpace(event.EventID)
	event.EventType = strings.TrimSpace(event.EventType)
	event.RoomID = strings.TrimSpace(event.RoomID)
	return event
}

func normalizeCompensationOptions(options roomtimedomain.CompensationOptions) roomtimedomain.CompensationOptions {
	if options.NowMs <= 0 {
		options.NowMs = time.Now().UnixMilli()
	}
	if options.BatchSize <= 0 {
		options.BatchSize = defaultCompensationBatchSize
	}
	if options.OpenSessionMaxAgeMs <= 0 {
		options.OpenSessionMaxAgeMs = defaultOpenSessionCompensationMs
	}
	return options
}

type dayDuration struct {
	DurationMs int64
	FirstAtMs  int64
	LastAtMs   int64
}

func splitByUTCDay(startMs int64, endMs int64) map[string]dayDuration {
	parts := map[string]dayDuration{}
	if endMs <= startMs {
		return parts
	}
	for cursor := startMs; cursor < endMs; {
		cursorTime := time.UnixMilli(cursor).UTC()
		dayStart := time.Date(cursorTime.Year(), cursorTime.Month(), cursorTime.Day(), 0, 0, 0, 0, time.UTC)
		nextDayMs := dayStart.AddDate(0, 0, 1).UnixMilli()
		segmentEnd := minInt64(endMs, nextDayMs)
		date := dayStart.Format("2006-01-02")
		part := parts[date]
		if part.FirstAtMs == 0 || cursor < part.FirstAtMs {
			part.FirstAtMs = cursor
		}
		if segmentEnd > part.LastAtMs {
			part.LastAtMs = segmentEnd
		}
		part.DurationMs += segmentEnd - cursor
		parts[date] = part
		cursor = segmentEnd
	}
	return parts
}

func isDuplicate(err error) bool {
	var mysqlErr *mysqldriver.MySQLError
	return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}

func minInt64(a int64, b int64) int64 {
	if a < b {
		return a
	}
	return b
}

func maxInt64(a int64, b int64) int64 {
	if a > b {
		return a
	}
	return b
}
