// Package mictime stores user-scoped microphone online duration read models in MySQL.
package mictime

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

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

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

const (
	defaultCompensationBatchSize              = 100
	defaultPendingPublishCompensationMaxAgeMs = int64(2 * 60 * 1000)
	defaultPublishingCompensationMaxAgeMs     = int64(12 * 60 * 60 * 1000)
	userOutboxStatusPending                   = "pending"
)

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

// ProcessRoomMicEvent idempotently applies one RoomMicChanged fact.
func (r *Repository) ProcessRoomMicEvent(ctx context.Context, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
	if r == nil || r.db == nil {
		return mictimedomain.ApplyResult{}, xerr.New(xerr.Unavailable, "mic time repository is not configured")
	}
	event = normalizeRoomMicEvent(event)
	if event.EventID == "" || event.EventType == "" {
		return mictimedomain.ApplyResult{}, xerr.New(xerr.InvalidArgument, "room mic event identity is required")
	}
	if event.OccurredAtMs <= 0 {
		event.OccurredAtMs = time.Now().UnixMilli()
	}

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

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

	if event.EventType != mictimedomain.RoomMicChangedEventType {
		if err := finishEventConsumption(ctx, tx, event, "skipped", "not_room_mic_changed"); err != nil {
			return mictimedomain.ApplyResult{}, err
		}
		return mictimedomain.ApplyResult{Skipped: true, Reason: "not_room_mic_changed"}, tx.Commit()
	}
	if event.MicSessionID == "" || event.TargetUserID <= 0 {
		if err := finishEventConsumption(ctx, tx, event, "skipped", "incomplete_mic_event"); err != nil {
			return mictimedomain.ApplyResult{}, err
		}
		return mictimedomain.ApplyResult{Skipped: true, Reason: "incomplete_mic_event"}, tx.Commit()
	}

	var result mictimedomain.ApplyResult
	switch event.Action {
	case mictimedomain.ActionUp:
		result, err = applyUp(ctx, tx, event)
	case mictimedomain.ActionPublishConfirmed:
		result, err = applyPublishConfirmed(ctx, tx, event)
	case mictimedomain.ActionChange:
		result, err = applyChange(ctx, tx, event)
	case mictimedomain.ActionDown:
		result, err = applyDown(ctx, tx, event)
	default:
		result = mictimedomain.ApplyResult{Skipped: true, Reason: "unsupported_mic_action"}
	}
	if err != nil {
		return mictimedomain.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 mictimedomain.ApplyResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return mictimedomain.ApplyResult{}, err
	}
	if !result.Skipped {
		result.Consumed = true
	}
	return result, nil
}

// GetLifetimeStats returns the current user-wide mic duration aggregate.
func (r *Repository) GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error) {
	if r == nil || r.db == nil {
		return mictimedomain.LifetimeStats{}, xerr.New(xerr.Unavailable, "mic time repository is not configured")
	}
	if userID <= 0 {
		return mictimedomain.LifetimeStats{}, xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	stats := mictimedomain.LifetimeStats{AppCode: appcode.Normalize(appCode), UserID: userID}
	err := r.db.QueryRowContext(ctx, `
		SELECT seat_occupied_ms, mic_online_ms, session_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms
		FROM user_mic_lifetime_stats
		WHERE app_code = ? AND user_id = ?
	`, stats.AppCode, userID).Scan(&stats.SeatOccupiedMs, &stats.MicOnlineMs, &stats.SessionCount, &stats.FirstMicAtMs, &stats.LastMicAtMs, &stats.UpdatedAtMs)
	if err == sql.ErrNoRows {
		return stats, nil
	}
	if err != nil {
		return mictimedomain.LifetimeStats{}, err
	}
	return stats, nil
}

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

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

	pendingCutoff := options.NowMs - options.PendingPublishMaxAgeMs
	publishingCutoff := options.NowMs - options.PublishingSessionMaxAgeMs
	app := appcode.FromContext(ctx)
	rows, err := tx.QueryContext(ctx, `
		SELECT app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms,
		       publishing_started_at_ms, ended_at_ms, last_room_version
		FROM user_mic_sessions
		WHERE app_code = ? AND ((
			status = ? AND seat_started_at_ms <= ?
		) OR (
			status = ? AND seat_started_at_ms <= ?
		))
		ORDER BY seat_started_at_ms ASC, mic_session_id ASC
		LIMIT ?
		FOR UPDATE SKIP LOCKED
	`, app, mictimedomain.SessionStatusPendingPublish, pendingCutoff, mictimedomain.SessionStatusPublishing, publishingCutoff, options.BatchSize)
	if err != nil {
		return mictimedomain.CompensationResult{}, err
	}

	candidates := make([]micSessionRow, 0, options.BatchSize)
	for rows.Next() {
		var session micSessionRow
		if err := rows.Scan(&session.AppCode, &session.MicSessionID, &session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status, &session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion); err != nil {
			_ = rows.Close()
			return mictimedomain.CompensationResult{}, err
		}
		candidates = append(candidates, session)
	}
	if err := rows.Close(); err != nil {
		return mictimedomain.CompensationResult{}, err
	}
	if err := rows.Err(); err != nil {
		return mictimedomain.CompensationResult{}, err
	}

	var result mictimedomain.CompensationResult
	for _, session := range candidates {
		input := closeMicSessionInput{
			ClosedEventID: fmt.Sprintf("compensated:%s", session.MicSessionID),
			EndAtMs:       compensationEndAt(session, options),
			UpdatedAtMs:   options.NowMs,
			RoomVersion:   session.LastRoomVersion,
		}
		switch session.Status {
		case mictimedomain.SessionStatusPendingPublish:
			input.EndReason = mictimedomain.CompensationReasonPendingPublishTimeout
			result.PendingPublishClosed++
		case mictimedomain.SessionStatusPublishing:
			input.EndReason = mictimedomain.CompensationReasonPublishingMaxWindow
			result.PublishingSessionClosed++
		default:
			continue
		}
		if err := closeMicSession(ctx, tx, session, input); err != nil {
			return mictimedomain.CompensationResult{}, err
		}
		result.ClosedCount++
	}
	if err := tx.Commit(); err != nil {
		return mictimedomain.CompensationResult{}, err
	}
	return result, nil
}

func applyUp(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
	seatNo := event.ToSeat
	if seatNo <= 0 {
		seatNo = event.FromSeat
	}
	if seatNo <= 0 {
		return mictimedomain.ApplyResult{Skipped: true, Reason: "missing_seat_no"}, nil
	}
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_mic_sessions (
			app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status,
			seat_started_at_ms, publishing_started_at_ms, ended_at_ms, end_reason,
			seat_occupied_ms, mic_online_ms, opened_event_id, publish_event_id, closed_event_id,
			last_event_id, last_room_version, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, '', 0, 0, ?, '', '', ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			current_seat_no = CASE WHEN status = ? THEN current_seat_no ELSE VALUES(current_seat_no) END,
			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)
	`, event.AppCode, event.MicSessionID, event.TargetUserID, event.RoomID, seatNo, seatNo, mictimedomain.SessionStatusPendingPublish,
		event.OccurredAtMs, event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.OccurredAtMs, mictimedomain.SessionStatusEnded)
	if err != nil {
		return mictimedomain.ApplyResult{}, err
	}
	return mictimedomain.ApplyResult{Consumed: true}, nil
}

func applyPublishConfirmed(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
	session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID)
	if err != nil || !exists {
		if err != nil {
			return mictimedomain.ApplyResult{}, err
		}
		return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil
	}
	if session.UserID != event.TargetUserID {
		return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil
	}
	if session.Status == mictimedomain.SessionStatusEnded {
		return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil
	}
	publishAt := event.PublishEventTimeMs
	if publishAt <= 0 {
		publishAt = event.OccurredAtMs
	}
	if publishAt < session.SeatStartedAtMs {
		// RTC/client clocks can arrive slightly earlier than server MicUp time; clamp to avoid negative online windows.
		publishAt = session.SeatStartedAtMs
	}
	if session.PublishingStartedAtMs.Valid && publishAt <= session.PublishingStartedAtMs.V {
		if err := updateSessionLastEvent(ctx, tx, event); err != nil {
			return mictimedomain.ApplyResult{}, err
		}
		return mictimedomain.ApplyResult{Skipped: true, Reason: "publish_already_confirmed"}, nil
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE user_mic_sessions
		SET status = ?, publishing_started_at_ms = ?, publish_event_id = ?, last_event_id = ?,
		    last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
		WHERE app_code = ? AND mic_session_id = ? AND status <> ?
	`, mictimedomain.SessionStatusPublishing, publishAt, event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs,
		event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded)
	if err != nil {
		return mictimedomain.ApplyResult{}, err
	}
	return mictimedomain.ApplyResult{Consumed: true}, nil
}

func applyChange(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
	session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID)
	if err != nil || !exists {
		if err != nil {
			return mictimedomain.ApplyResult{}, err
		}
		return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil
	}
	if session.UserID != event.TargetUserID {
		return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil
	}
	if session.Status == mictimedomain.SessionStatusEnded {
		return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil
	}
	if event.ToSeat <= 0 {
		return mictimedomain.ApplyResult{Skipped: true, Reason: "missing_target_seat"}, nil
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE user_mic_sessions
		SET current_seat_no = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
		WHERE app_code = ? AND mic_session_id = ? AND status <> ?
	`, event.ToSeat, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded)
	if err != nil {
		return mictimedomain.ApplyResult{}, err
	}
	return mictimedomain.ApplyResult{Consumed: true}, nil
}

func applyDown(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
	session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID)
	if err != nil || !exists {
		if err != nil {
			return mictimedomain.ApplyResult{}, err
		}
		return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil
	}
	if session.UserID != event.TargetUserID {
		return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil
	}
	if session.Status == mictimedomain.SessionStatusEnded {
		return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil
	}

	endReason := strings.TrimSpace(event.Reason)
	if endReason == "" {
		endReason = "down"
	}
	if err := closeMicSession(ctx, tx, session, closeMicSessionInput{
		ClosedEventID: event.EventID,
		EndAtMs:       event.OccurredAtMs,
		EndReason:     endReason,
		UpdatedAtMs:   event.OccurredAtMs,
		RoomVersion:   event.RoomVersion,
	}); err != nil {
		return mictimedomain.ApplyResult{}, err
	}
	return mictimedomain.ApplyResult{Consumed: true, Closed: true}, nil
}

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

func closeMicSession(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput) error {
	input.ClosedEventID = strings.TrimSpace(input.ClosedEventID)
	if input.ClosedEventID == "" {
		input.ClosedEventID = "closed:" + session.MicSessionID
	}
	input.EndReason = strings.TrimSpace(input.EndReason)
	if input.EndReason == "" {
		input.EndReason = "down"
	}
	if input.EndAtMs < session.SeatStartedAtMs {
		input.EndAtMs = session.SeatStartedAtMs
	}
	if input.UpdatedAtMs <= 0 {
		input.UpdatedAtMs = input.EndAtMs
	}

	seatOccupiedMs := maxInt64(0, input.EndAtMs-session.SeatStartedAtMs)
	micOnlineMs := int64(0)
	if session.PublishingStartedAtMs.Valid {
		publishStart := max(session.PublishingStartedAtMs.V, session.SeatStartedAtMs)
		micOnlineMs = maxInt64(0, input.EndAtMs-publishStart)
	}

	_, err := tx.ExecContext(ctx, `
		UPDATE user_mic_sessions
		SET status = ?, ended_at_ms = ?, end_reason = ?, seat_occupied_ms = ?, mic_online_ms = ?,
		    closed_event_id = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
		WHERE app_code = ? AND mic_session_id = ? AND status <> ?
	`, mictimedomain.SessionStatusEnded, input.EndAtMs, input.EndReason, seatOccupiedMs, micOnlineMs,
		input.ClosedEventID, input.ClosedEventID, input.RoomVersion, input.UpdatedAtMs, session.AppCode, session.MicSessionID, mictimedomain.SessionStatusEnded)
	if err != nil {
		return err
	}
	if err := incrementLifetime(ctx, tx, session, input, seatOccupiedMs, micOnlineMs); err != nil {
		return err
	}
	if err := incrementDaily(ctx, tx, session, input); err != nil {
		return err
	}
	return insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicSessionClosed, session.MicSessionID, ""), mictimedomain.EventTypeUserMicSessionClosed, "user_mic_session", session.UserID, input.UpdatedAtMs, map[string]any{
		"app_code":           session.AppCode,
		"user_id":            session.UserID,
		"room_id":            session.RoomID,
		"mic_session_id":     session.MicSessionID,
		"seat_occupied_ms":   seatOccupiedMs,
		"mic_online_ms":      micOnlineMs,
		"seat_started_at_ms": session.SeatStartedAtMs,
		"ended_at_ms":        input.EndAtMs,
		"end_reason":         input.EndReason,
		"closed_event_id":    input.ClosedEventID,
	})
}

func incrementLifetime(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput, seatMs int64, micMs int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_mic_lifetime_stats (
			app_code, user_id, seat_occupied_ms, mic_online_ms, session_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 1, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			seat_occupied_ms = seat_occupied_ms + VALUES(seat_occupied_ms),
			mic_online_ms = mic_online_ms + VALUES(mic_online_ms),
			session_count = session_count + 1,
			first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END,
			last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)),
			updated_at_ms = VALUES(updated_at_ms)
	`, session.AppCode, session.UserID, seatMs, micMs, session.SeatStartedAtMs, input.EndAtMs, input.UpdatedAtMs)
	if err != nil {
		return err
	}
	return insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicLifetimeStatsUpdated, session.MicSessionID, ""), mictimedomain.EventTypeUserMicLifetimeStatsUpdated, "user_mic_lifetime_stats", session.UserID, input.UpdatedAtMs, map[string]any{
		"app_code":               session.AppCode,
		"user_id":                session.UserID,
		"mic_session_id":         session.MicSessionID,
		"seat_occupied_delta_ms": seatMs,
		"mic_online_delta_ms":    micMs,
		"session_count_delta":    1,
		"updated_at_ms":          input.UpdatedAtMs,
	})
}

func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput) error {
	seatParts := splitByUTCDay(session.SeatStartedAtMs, input.EndAtMs)
	micParts := map[string]dayDuration{}
	if session.PublishingStartedAtMs.Valid {
		publishStart := max(session.PublishingStartedAtMs.V, session.SeatStartedAtMs)
		micParts = splitByUTCDay(publishStart, input.EndAtMs)
	}
	dates := map[string]struct{}{}
	for date := range seatParts {
		dates[date] = struct{}{}
	}
	for date := range micParts {
		dates[date] = struct{}{}
	}
	for date := range dates {
		seatPart := seatParts[date]
		micPart := micParts[date]
		firstAt := minPositive(seatPart.FirstAtMs, micPart.FirstAtMs)
		lastAt := maxInt64(seatPart.LastAtMs, micPart.LastAtMs)
		if firstAt == 0 || lastAt == 0 {
			continue
		}
		sessionCount := int64(0)
		roomCount := int64(0)
		if seatPart.DurationMs > 0 {
			// A cross-day session is visible on every day it occupied a seat.
			sessionCount = 1
			roomCount = 1
		}
		_, err := tx.ExecContext(ctx, `
			INSERT INTO user_mic_daily_stats (
				app_code, user_id, stat_date, seat_occupied_ms, mic_online_ms, session_count, room_count,
				first_mic_at_ms, last_mic_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
			ON DUPLICATE KEY UPDATE
				seat_occupied_ms = seat_occupied_ms + VALUES(seat_occupied_ms),
				mic_online_ms = mic_online_ms + VALUES(mic_online_ms),
				session_count = session_count + VALUES(session_count),
				room_count = room_count + VALUES(room_count),
				first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END,
				last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)),
				updated_at_ms = VALUES(updated_at_ms)
		`, session.AppCode, session.UserID, date, seatPart.DurationMs, micPart.DurationMs, sessionCount, roomCount, firstAt, lastAt, input.UpdatedAtMs)
		if err != nil {
			return err
		}
		if err := insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicDailyStatsUpdated, session.MicSessionID, date), mictimedomain.EventTypeUserMicDailyStatsUpdated, "user_mic_daily_stats", session.UserID, input.UpdatedAtMs, map[string]any{
			"app_code":               session.AppCode,
			"user_id":                session.UserID,
			"stat_date":              date,
			"mic_session_id":         session.MicSessionID,
			"seat_occupied_delta_ms": seatPart.DurationMs,
			"mic_online_delta_ms":    micPart.DurationMs,
			"session_count_delta":    sessionCount,
			"room_count_delta":       roomCount,
			"updated_at_ms":          input.UpdatedAtMs,
		}); err != nil {
			return err
		}
	}
	return nil
}

type micSessionRow struct {
	AppCode               string
	MicSessionID          string
	UserID                int64
	RoomID                string
	FirstSeatNo           int32
	CurrentSeatNo         int32
	Status                string
	SeatStartedAtMs       int64
	PublishingStartedAtMs sql.Null[int64]
	EndedAtMs             sql.Null[int64]
	LastRoomVersion       int64
}

func sessionForUpdate(ctx context.Context, tx *sql.Tx, appCode string, micSessionID string) (micSessionRow, bool, error) {
	var session micSessionRow
	err := tx.QueryRowContext(ctx, `
		SELECT app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms,
		       publishing_started_at_ms, ended_at_ms, last_room_version
		FROM user_mic_sessions
		WHERE app_code = ? AND mic_session_id = ?
		FOR UPDATE
	`, appCode, micSessionID).Scan(&session.AppCode, &session.MicSessionID, &session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status,
		&session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion)
	if err == sql.ErrNoRows {
		return micSessionRow{}, false, nil
	}
	if err != nil {
		return micSessionRow{}, false, err
	}
	return session, true, nil
}

func updateSessionLastEvent(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE user_mic_sessions
		SET last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
		WHERE app_code = ? AND mic_session_id = ?
	`, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID)
	return err
}

func insertEventConsumption(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_mic_event_consumption (
			app_code, event_id, event_type, mic_session_id, user_id, action, status, skip_reason,
			created_at_ms, consumed_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, 'processing', '', ?, NULL, ?)
	`, event.AppCode, event.EventID, event.EventType, event.MicSessionID, event.TargetUserID, event.Action, event.OccurredAtMs, event.OccurredAtMs)
	return err
}

func finishEventConsumption(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent, status string, reason string) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE user_mic_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 normalizeRoomMicEvent(event mictimedomain.RoomMicEvent) mictimedomain.RoomMicEvent {
	event.AppCode = appcode.Normalize(event.AppCode)
	event.EventID = strings.TrimSpace(event.EventID)
	event.EventType = strings.TrimSpace(event.EventType)
	event.RoomID = strings.TrimSpace(event.RoomID)
	event.Action = strings.TrimSpace(event.Action)
	event.MicSessionID = strings.TrimSpace(event.MicSessionID)
	event.PublishState = strings.TrimSpace(event.PublishState)
	event.Reason = strings.TrimSpace(event.Reason)
	return event
}

func normalizeCompensationOptions(options mictimedomain.CompensationOptions) mictimedomain.CompensationOptions {
	if options.NowMs <= 0 {
		options.NowMs = time.Now().UnixMilli()
	}
	if options.BatchSize <= 0 {
		options.BatchSize = defaultCompensationBatchSize
	}
	if options.PendingPublishMaxAgeMs <= 0 {
		options.PendingPublishMaxAgeMs = defaultPendingPublishCompensationMaxAgeMs
	}
	if options.PublishingSessionMaxAgeMs <= 0 {
		options.PublishingSessionMaxAgeMs = defaultPublishingCompensationMaxAgeMs
	}
	return options
}

func compensationEndAt(session micSessionRow, options mictimedomain.CompensationOptions) int64 {
	switch session.Status {
	case mictimedomain.SessionStatusPendingPublish:
		return minInt64(options.NowMs, session.SeatStartedAtMs+options.PendingPublishMaxAgeMs)
	case mictimedomain.SessionStatusPublishing:
		return minInt64(options.NowMs, session.SeatStartedAtMs+options.PublishingSessionMaxAgeMs)
	default:
		return options.NowMs
	}
}

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 userMicOutboxEventID(eventType string, micSessionID string, suffix string) string {
	if strings.TrimSpace(suffix) == "" {
		return eventType + ":" + micSessionID
	}
	return eventType + ":" + micSessionID + ":" + suffix
}

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 minPositive(a int64, b int64) int64 {
	if a <= 0 {
		return b
	}
	if b <= 0 || a < b {
		return a
	}
	return b
}

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
}
