package mysql

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

	"hyapp/pkg/appcode"
	"hyapp/pkg/idgen"
	"hyapp/pkg/xerr"
	domain "hyapp/services/activity-service/internal/domain/agencyopening"
)

// ListAgencyOpeningCycles returns admin configured cycles with reward rows attached.
func (r *Repository) ListAgencyOpeningCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	where, args := agencyOpeningCycleWhere(ctx, query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agency_opening_cycles `+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	pageSize := normalizePageSize(query.PageSize)
	page := query.Page
	if page <= 0 {
		page = 1
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
		       created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
		FROM agency_opening_cycles `+where+`
		ORDER BY start_ms DESC, cycle_id DESC
		LIMIT ? OFFSET ?`, append(args, int(pageSize), int((page-1)*pageSize))...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	cycles := make([]domain.Cycle, 0)
	for rows.Next() {
		cycle, scanErr := scanAgencyOpeningCycle(rows)
		if scanErr != nil {
			return nil, 0, scanErr
		}
		cycles = append(cycles, cycle)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	if err := r.attachAgencyOpeningRewards(ctx, cycles); err != nil {
		return nil, 0, err
	}
	return cycles, total, nil
}

func (r *Repository) GetAgencyOpeningCycle(ctx context.Context, cycleID string) (domain.Cycle, error) {
	if r == nil || r.db == nil {
		return domain.Cycle{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	cycle, err := r.getAgencyOpeningCycle(ctx, r.db, cycleID)
	if err != nil {
		return domain.Cycle{}, err
	}
	cycles := []domain.Cycle{cycle}
	if err := r.attachAgencyOpeningRewards(ctx, cycles); err != nil {
		return domain.Cycle{}, err
	}
	return cycles[0], nil
}

func (r *Repository) UpsertAgencyOpeningCycle(ctx context.Context, command domain.CycleCommand, nowMS int64) (domain.Cycle, bool, error) {
	if r == nil || r.db == nil {
		return domain.Cycle{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Cycle{}, false, err
	}
	defer tx.Rollback()
	cycle := command.Cycle
	cycle.AppCode = appcode.FromContext(ctx)
	cycle.ActivityCode = domain.ActivityCode
	if cycle.Status == "" {
		cycle.Status = domain.StatusDraft
	}
	created := strings.TrimSpace(cycle.CycleID) == ""
	if created {
		cycle.CycleID = idgen.New("aopen")
		cycle.CreatedAtMS = nowMS
		cycle.CreatedByAdminID = command.OperatorAdminID
	} else {
		stored, getErr := r.getAgencyOpeningCycle(ctx, tx, cycle.CycleID)
		if getErr != nil {
			return domain.Cycle{}, false, getErr
		}
		if stored.RegionID != cycle.RegionID {
			// 申请和计分事件都持有周期区域快照；周期创建后改区域会让既有申请与周期失联，因此区域只能通过新建周期调整。
			return domain.Cycle{}, false, xerr.New(xerr.Conflict, "agency opening cycle region cannot be changed")
		}
	}
	if !created && command.RequireNewRecord {
		return domain.Cycle{}, false, xerr.New(xerr.InvalidArgument, "cycle_id already exists")
	}
	cycle.UpdatedAtMS = nowMS
	cycle.UpdatedByAdminID = command.OperatorAdminID
	if err := r.checkAgencyOpeningCycleOverlap(ctx, tx, cycle); err != nil {
		return domain.Cycle{}, false, err
	}
	if created {
		_, err = tx.ExecContext(ctx, `
			INSERT INTO agency_opening_cycles (
			  app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
			  created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
			cycle.AppCode, cycle.RegionID, cycle.CycleID, cycle.ActivityCode, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS, cycle.MinHostCount, cycle.MaxAgencyAgeDays,
			cycle.CreatedByAdminID, cycle.UpdatedByAdminID, cycle.CreatedAtMS, cycle.UpdatedAtMS)
	} else {
		_, err = tx.ExecContext(ctx, `
			UPDATE agency_opening_cycles
			SET region_id = ?, title = ?, status = ?, start_ms = ?, end_ms = ?, min_host_count = ?, max_agency_age_days = ?,
			    updated_by_admin_id = ?, updated_at_ms = ?
			WHERE app_code = ? AND cycle_id = ?`,
			cycle.RegionID, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS, cycle.MinHostCount, cycle.MaxAgencyAgeDays,
			cycle.UpdatedByAdminID, cycle.UpdatedAtMS, cycle.AppCode, cycle.CycleID)
	}
	if err != nil {
		return domain.Cycle{}, false, err
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM agency_opening_cycle_rewards WHERE app_code = ? AND cycle_id = ?`, cycle.AppCode, cycle.CycleID); err != nil {
		return domain.Cycle{}, false, err
	}
	for _, reward := range cycle.Rewards {
		if _, err := tx.ExecContext(ctx, `
				INSERT INTO agency_opening_cycle_rewards (app_code, cycle_id, rank_no, threshold_coin_spent, reward_coin_amount, created_at_ms, updated_at_ms)
				VALUES (?, ?, ?, ?, ?, ?, ?)`,
			cycle.AppCode, cycle.CycleID, reward.RankNo, reward.ThresholdCoinSpent, reward.RewardCoinAmount, nowMS, nowMS); err != nil {
			return domain.Cycle{}, false, err
		}
	}
	if err := tx.Commit(); err != nil {
		return domain.Cycle{}, false, err
	}
	stored, err := r.GetAgencyOpeningCycle(ctx, cycle.CycleID)
	return stored, created, err
}

func (r *Repository) SetAgencyOpeningCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64, nowMS int64) (domain.Cycle, error) {
	if r == nil || r.db == nil {
		return domain.Cycle{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Cycle{}, err
	}
	defer tx.Rollback()
	cycle, err := r.getAgencyOpeningCycle(ctx, tx, cycleID)
	if err != nil {
		return domain.Cycle{}, err
	}
	cycle.Status = status
	if err := r.checkAgencyOpeningCycleOverlap(ctx, tx, cycle); err != nil {
		return domain.Cycle{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE agency_opening_cycles
		SET status = ?, updated_by_admin_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND cycle_id = ?`,
		status, operatorAdminID, nowMS, appcode.FromContext(ctx), cycleID); err != nil {
		return domain.Cycle{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.Cycle{}, err
	}
	return r.GetAgencyOpeningCycle(ctx, cycleID)
}

func (r *Repository) FindCurrentAgencyOpeningCycle(ctx context.Context, regionID int64, nowMS int64) (domain.Cycle, bool, error) {
	if r == nil || r.db == nil {
		return domain.Cycle{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	cycle, found, err := r.findAgencyOpeningCycleForRegion(ctx, regionID, nowMS)
	if err != nil || found {
		return cycle, found, err
	}
	// 没有具体区域周期时回退全区域周期；拆成两次索引点查，避免 OR 条件破坏 current 索引前缀。
	return r.findAgencyOpeningCycleForRegion(ctx, 0, nowMS)
}

func (r *Repository) findAgencyOpeningCycleForRegion(ctx context.Context, regionID int64, nowMS int64) (domain.Cycle, bool, error) {
	cycle, err := scanAgencyOpeningCycle(r.db.QueryRowContext(ctx, `
		SELECT app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
		       created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
		FROM agency_opening_cycles
		WHERE app_code = ? AND region_id = ? AND activity_code = ? AND status = ? AND start_ms <= ? AND end_ms > ?
		ORDER BY start_ms DESC, updated_at_ms DESC
		LIMIT 1`, appcode.FromContext(ctx), regionID, domain.ActivityCode, domain.StatusActive, nowMS, nowMS))
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.Cycle{}, false, nil
		}
		return domain.Cycle{}, false, err
	}
	cycles := []domain.Cycle{cycle}
	if err := r.attachAgencyOpeningRewards(ctx, cycles); err != nil {
		return domain.Cycle{}, false, err
	}
	return cycles[0], true, nil
}

func (r *Repository) GetAgencyOpeningApplicationByOwner(ctx context.Context, ownerUserID int64) (domain.Application, bool, error) {
	item, err := scanAgencyOpeningApplication(r.db.QueryRowContext(ctx, agencyOpeningApplicationSelect()+`
		WHERE app_code = ? AND agency_owner_user_id = ?
		ORDER BY applied_at_ms DESC
		LIMIT 1`, appcode.FromContext(ctx), ownerUserID))
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.Application{}, false, nil
		}
		return domain.Application{}, false, err
	}
	return item, true, nil
}

func (r *Repository) CreateAgencyOpeningApplication(ctx context.Context, cycle domain.Cycle, agency domain.AgencySnapshot, nowMS int64) (domain.Application, bool, error) {
	if r == nil || r.db == nil {
		return domain.Application{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	applicationID := idgen.New("aopenapp")
	result, err := r.db.ExecContext(ctx, `
		INSERT IGNORE INTO agency_opening_applications (
		  app_code, region_id, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms,
			  status, score_coin_amount, reward_rank_no, reward_threshold_coin_spent, reward_coin_amount, wallet_command_id, wallet_transaction_id, failure_reason,
			  applied_at_ms, approved_at_ms, score_start_ms, score_end_ms, reviewed_by_admin_id, granted_at_ms, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, '', '', '', ?, 0, 0, 0, 0, 0, ?, ?)`,
		appcode.FromContext(ctx), agency.RegionID, applicationID, cycle.CycleID, agency.AgencyID, agency.OwnerUserID, agency.Name, agency.HostCount, agency.AgencyCreatedAtMS,
		domain.ApplicationStatusApplied, nowMS, nowMS, nowMS)
	if err != nil {
		return domain.Application{}, false, err
	}
	affected, _ := result.RowsAffected()
	item, ok, err := r.GetAgencyOpeningApplicationByOwner(ctx, agency.OwnerUserID)
	return item, affected > 0 && ok, err
}

func (r *Repository) ApproveAgencyOpeningApplication(ctx context.Context, applicationID string, operatorAdminID int64, nowMS int64) (domain.Application, error) {
	if r == nil || r.db == nil {
		return domain.Application{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Application{}, err
	}
	defer tx.Rollback()
	var app domain.Application
	var cycleEndMS int64
	var cycleStatus string
	err = tx.QueryRowContext(ctx, agencyOpeningApplicationSelect()+`
		WHERE app_code = ? AND application_id = ?
		FOR UPDATE`, appcode.FromContext(ctx), strings.TrimSpace(applicationID)).Scan(
		&app.AppCode, &app.RegionID, &app.ApplicationID, &app.CycleID, &app.AgencyID, &app.AgencyOwnerUserID, &app.AgencyName,
		&app.HostCount, &app.AgencyCreatedAtMS, &app.Status, &app.ScoreCoinAmount, &app.RewardRankNo, &app.RewardThresholdCoin, &app.RewardCoinAmount,
		&app.WalletCommandID, &app.WalletTransactionID, &app.FailureReason, &app.AppliedAtMS, &app.ApprovedAtMS, &app.ScoreStartMS, &app.ScoreEndMS,
		&app.ReviewedByAdminID, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.Application{}, xerr.New(xerr.NotFound, "agency opening application not found")
		}
		return domain.Application{}, err
	}
	if err := tx.QueryRowContext(ctx, `
		SELECT status, end_ms
		FROM agency_opening_cycles
		WHERE app_code = ? AND cycle_id = ? AND activity_code = ?`, appcode.FromContext(ctx), app.CycleID, domain.ActivityCode).Scan(&cycleStatus, &cycleEndMS); err != nil {
		return domain.Application{}, err
	}
	if app.Status == domain.ApplicationStatusApproved {
		if err := tx.Commit(); err != nil {
			return domain.Application{}, err
		}
		return r.getAgencyOpeningApplication(ctx, app.ApplicationID)
	}
	if app.Status != domain.ApplicationStatusApplied {
		return domain.Application{}, xerr.New(xerr.Conflict, "application is not waiting for approval")
	}
	if cycleStatus != domain.StatusActive {
		return domain.Application{}, xerr.New(xerr.Conflict, "activity is not active")
	}
	scoreEndMS := nowMS + int64((24 * time.Hour).Milliseconds())
	if cycleEndMS > 0 && cycleEndMS < scoreEndMS {
		scoreEndMS = cycleEndMS
	}
	if scoreEndMS <= nowMS {
		return domain.Application{}, xerr.New(xerr.Conflict, "activity period is already ended")
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE agency_opening_applications
		SET status = ?, approved_at_ms = ?, score_start_ms = ?, score_end_ms = ?, reviewed_by_admin_id = ?, score_coin_amount = 0,
		    failure_reason = '', updated_at_ms = ?
		WHERE app_code = ? AND application_id = ? AND status = ?`,
		domain.ApplicationStatusApproved, nowMS, nowMS, scoreEndMS, operatorAdminID, nowMS,
		appcode.FromContext(ctx), app.ApplicationID, domain.ApplicationStatusApplied); err != nil {
		return domain.Application{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.Application{}, err
	}
	return r.getAgencyOpeningApplication(ctx, app.ApplicationID)
}

func (r *Repository) ListAgencyOpeningApplications(ctx context.Context, query domain.ApplicationQuery) ([]domain.Application, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	where, args := agencyOpeningApplicationWhere(ctx, query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agency_opening_applications `+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	pageSize := normalizePageSize(query.PageSize)
	page := query.Page
	if page <= 0 {
		page = 1
	}
	rows, err := r.db.QueryContext(ctx, agencyOpeningApplicationSelect()+` `+where+`
		ORDER BY score_coin_amount DESC, applied_at_ms ASC, agency_id ASC
		LIMIT ? OFFSET ?`, append(args, int(pageSize), int((page-1)*pageSize))...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]domain.Application, 0)
	for rows.Next() {
		item, scanErr := scanAgencyOpeningApplication(rows)
		if scanErr != nil {
			return nil, 0, scanErr
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

func (r *Repository) ConsumeAgencyOpeningGiftEvent(ctx context.Context, event domain.GiftEvent, nowMS int64) (domain.EventResult, error) {
	if r == nil || r.db == nil {
		return domain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.EventResult{}, err
	}
	defer tx.Rollback()
	var app domain.Application
	err = tx.QueryRowContext(ctx, `
			SELECT app.app_code, app.region_id, app.application_id, app.cycle_id, app.agency_id, app.agency_owner_user_id, app.agency_name, app.host_count, app.agency_created_at_ms,
			       app.status, app.score_coin_amount, app.reward_rank_no, app.reward_threshold_coin_spent, app.reward_coin_amount, app.wallet_command_id, app.wallet_transaction_id, app.failure_reason,
			       app.applied_at_ms, app.approved_at_ms, app.score_start_ms, app.score_end_ms, app.reviewed_by_admin_id, app.granted_at_ms, app.created_at_ms, app.updated_at_ms
		FROM agency_opening_applications app
		INNER JOIN agency_opening_cycles cycle
		  ON cycle.app_code = app.app_code AND cycle.cycle_id = app.cycle_id AND cycle.region_id IN (0, app.region_id)
		WHERE app.app_code = ? AND (? = 0 OR app.region_id = ?) AND app.agency_owner_user_id = ? AND app.status = ? AND app.score_start_ms <= ? AND app.score_end_ms > ?
		  AND cycle.activity_code = ? AND cycle.status IN (?, ?) AND cycle.start_ms <= ? AND cycle.end_ms > ?
		ORDER BY app.applied_at_ms DESC
		LIMIT 1`, appcode.FromContext(ctx), event.RegionID, event.RegionID, event.RoomOwnerUserID, domain.ApplicationStatusApproved, event.OccurredAtMS, event.OccurredAtMS,
		domain.ActivityCode, domain.StatusActive, domain.StatusSettling, event.OccurredAtMS, event.OccurredAtMS).Scan(
		&app.AppCode, &app.RegionID, &app.ApplicationID, &app.CycleID, &app.AgencyID, &app.AgencyOwnerUserID, &app.AgencyName,
		&app.HostCount, &app.AgencyCreatedAtMS, &app.Status, &app.ScoreCoinAmount, &app.RewardRankNo, &app.RewardThresholdCoin, &app.RewardCoinAmount,
		&app.WalletCommandID, &app.WalletTransactionID, &app.FailureReason, &app.AppliedAtMS, &app.ApprovedAtMS, &app.ScoreStartMS, &app.ScoreEndMS,
		&app.ReviewedByAdminID, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, tx.Commit()
		}
		return domain.EventResult{}, err
	}
	result, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO agency_opening_score_events (
		  app_code, source_event_id, cycle_id, application_id, agency_id, target_user_id, score_delta, occurred_at_ms, created_at_ms, region_id
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), event.EventID, app.CycleID, app.ApplicationID, app.AgencyID, event.TargetUserID, event.CoinSpent, event.OccurredAtMS, nowMS, app.RegionID)
	if err != nil {
		return domain.EventResult{}, err
	}
	affected, _ := result.RowsAffected()
	if affected == 0 {
		return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusDuplicate}, tx.Commit()
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE agency_opening_applications
		SET score_coin_amount = score_coin_amount + ?, updated_at_ms = ?
		WHERE app_code = ? AND application_id = ?`,
		event.CoinSpent, nowMS, appcode.FromContext(ctx), app.ApplicationID); err != nil {
		return domain.EventResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.EventResult{}, err
	}
	return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusConsumed}, nil
}

func (r *Repository) ClaimDueAgencyOpeningCycles(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.Cycle, error) {
	if batchSize <= 0 {
		batchSize = 50
	}
	lockUntil := nowMS + lockTTL.Milliseconds()
	if lockTTL <= 0 {
		lockUntil = nowMS + int64((30 * time.Second).Milliseconds())
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE agency_opening_cycles
		SET status = ?, locked_by = ?, lock_until_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND activity_code = ? AND status IN (?, ?) AND end_ms <= ? AND (lock_until_ms = 0 OR lock_until_ms <= ?)
		ORDER BY end_ms ASC, cycle_id ASC
		LIMIT ?`,
		domain.StatusSettling, strings.TrimSpace(workerID), lockUntil, nowMS,
		appcode.FromContext(ctx), domain.ActivityCode, domain.StatusActive, domain.StatusSettling, nowMS, nowMS, int(batchSize))
	if err != nil {
		return nil, err
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
		       created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
		FROM agency_opening_cycles
		WHERE app_code = ? AND activity_code = ? AND status = ? AND locked_by = ? AND lock_until_ms > ?
		ORDER BY end_ms ASC, cycle_id ASC
		LIMIT ?`, appcode.FromContext(ctx), domain.ActivityCode, domain.StatusSettling, strings.TrimSpace(workerID), nowMS, int(batchSize))
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	cycles := make([]domain.Cycle, 0)
	for rows.Next() {
		cycle, scanErr := scanAgencyOpeningCycle(rows)
		if scanErr != nil {
			return nil, scanErr
		}
		cycles = append(cycles, cycle)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return cycles, r.attachAgencyOpeningRewards(ctx, cycles)
}

func (r *Repository) PrepareAgencyOpeningSettlements(ctx context.Context, cycle domain.Cycle, nowMS int64) ([]domain.Application, error) {
	rewards := activeAgencyOpeningRewardTiers(cycle.Rewards)
	if len(rewards) == 0 {
		return nil, nil
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()
	// 周期结算时仍未后台同意的申请没有有效 24 小时计分窗口，直接过期，不进入发奖队列。
	if _, err := tx.ExecContext(ctx, `
		UPDATE agency_opening_applications
		SET status = ?, failure_reason = 'not approved before cycle ended', updated_at_ms = ?
		WHERE app_code = ? AND cycle_id = ? AND status = ?`,
		domain.ApplicationStatusExpired, nowMS, appcode.FromContext(ctx), cycle.CycleID, domain.ApplicationStatusApplied); err != nil {
		return nil, err
	}
	rows, err := tx.QueryContext(ctx, agencyOpeningApplicationSelect()+`
		WHERE app_code = ? AND cycle_id = ? AND status IN (?, ?)
		ORDER BY score_coin_amount DESC, applied_at_ms ASC, agency_id ASC`,
		appcode.FromContext(ctx), cycle.CycleID, domain.ApplicationStatusApproved, domain.ApplicationStatusFailed)
	if err != nil {
		return nil, err
	}
	applications := make([]domain.Application, 0)
	for rows.Next() {
		item, scanErr := scanAgencyOpeningApplication(rows)
		if scanErr != nil {
			rows.Close()
			return nil, scanErr
		}
		applications = append(applications, item)
	}
	if err := rows.Close(); err != nil {
		return nil, err
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	for _, application := range applications {
		reward, matched := matchAgencyOpeningRewardTier(rewards, application.ScoreCoinAmount)
		if !matched {
			reward = domain.Reward{}
		}
		walletCommandID := fmt.Sprintf("agency_opening:%s:%s", cycle.CycleID, application.ApplicationID)
		if _, err := tx.ExecContext(ctx, `
			UPDATE agency_opening_applications
			SET status = CASE WHEN status = ? THEN status ELSE ? END,
			    reward_rank_no = ?, reward_threshold_coin_spent = ?, reward_coin_amount = ?, wallet_command_id = ?, updated_at_ms = ?
			WHERE app_code = ? AND application_id = ? AND status IN (?, ?)`,
			domain.ApplicationStatusGranted, domain.ApplicationStatusPending,
			reward.RankNo, reward.ThresholdCoinSpent, reward.RewardCoinAmount, walletCommandID, nowMS,
			appcode.FromContext(ctx), application.ApplicationID, domain.ApplicationStatusApproved, domain.ApplicationStatusFailed); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	pendingApplications, _, err := r.ListAgencyOpeningApplications(ctx, domain.ApplicationQuery{CycleID: cycle.CycleID, Status: domain.ApplicationStatusPending, Page: 1, PageSize: 100})
	return pendingApplications, err
}

func (r *Repository) MarkAgencyOpeningApplicationGranted(ctx context.Context, applicationID string, walletTransactionID string, grantedAtMS int64) (domain.Application, error) {
	if _, err := r.db.ExecContext(ctx, `
		UPDATE agency_opening_applications
		SET status = ?, wallet_transaction_id = ?, failure_reason = '', granted_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND application_id = ?`,
		domain.ApplicationStatusGranted, strings.TrimSpace(walletTransactionID), grantedAtMS, grantedAtMS, appcode.FromContext(ctx), strings.TrimSpace(applicationID)); err != nil {
		return domain.Application{}, err
	}
	return r.getAgencyOpeningApplication(ctx, applicationID)
}

func (r *Repository) MarkAgencyOpeningApplicationFailed(ctx context.Context, applicationID string, reason string, nowMS int64) error {
	_, err := r.db.ExecContext(ctx, `
		UPDATE agency_opening_applications
		SET status = ?, failure_reason = ?, updated_at_ms = ?
		WHERE app_code = ? AND application_id = ?`,
		domain.ApplicationStatusFailed, truncateAgencyOpeningFailure(reason), nowMS, appcode.FromContext(ctx), strings.TrimSpace(applicationID))
	return err
}

func truncateString(value string, limit int) string {
	value = strings.TrimSpace(value)
	if limit <= 0 || len(value) <= limit {
		return value
	}
	return value[:limit]
}

func (r *Repository) FinishAgencyOpeningCycleIfComplete(ctx context.Context, cycleID string, nowMS int64) (bool, error) {
	var pending int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM agency_opening_applications
		WHERE app_code = ? AND cycle_id = ? AND status IN (?, ?)`,
		appcode.FromContext(ctx), strings.TrimSpace(cycleID), domain.ApplicationStatusPending, domain.ApplicationStatusFailed).Scan(&pending); err != nil {
		return false, err
	}
	if pending > 0 {
		return false, nil
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE agency_opening_cycles
		SET status = ?, settled_at_ms = ?, locked_by = '', lock_until_ms = 0, updated_at_ms = ?
		WHERE app_code = ? AND cycle_id = ?`,
		domain.StatusSettled, nowMS, nowMS, appcode.FromContext(ctx), strings.TrimSpace(cycleID))
	return err == nil, err
}

func (r *Repository) getAgencyOpeningApplication(ctx context.Context, applicationID string) (domain.Application, error) {
	return scanAgencyOpeningApplication(r.db.QueryRowContext(ctx, agencyOpeningApplicationSelect()+` WHERE app_code = ? AND application_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(applicationID)))
}

func (r *Repository) getAgencyOpeningCycle(ctx context.Context, q interface {
	QueryRowContext(context.Context, string, ...any) *sql.Row
}, cycleID string) (domain.Cycle, error) {
	cycleID = strings.TrimSpace(cycleID)
	if cycleID == "" {
		return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "cycle_id is required")
	}
	return scanAgencyOpeningCycle(q.QueryRowContext(ctx, `
		SELECT app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
		       created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
		FROM agency_opening_cycles
		WHERE app_code = ? AND cycle_id = ?`, appcode.FromContext(ctx), cycleID))
}

func (r *Repository) attachAgencyOpeningRewards(ctx context.Context, cycles []domain.Cycle) error {
	if len(cycles) == 0 {
		return nil
	}
	ids := make([]string, 0, len(cycles))
	index := make(map[string]int, len(cycles))
	for i, cycle := range cycles {
		ids = append(ids, cycle.CycleID)
		index[cycle.CycleID] = i
	}
	args := []any{appcode.FromContext(ctx)}
	for _, id := range ids {
		args = append(args, id)
	}
	rows, err := r.db.QueryContext(ctx, `
			SELECT app_code, cycle_id, rank_no, threshold_coin_spent, reward_coin_amount, created_at_ms, updated_at_ms
			FROM agency_opening_cycle_rewards
			WHERE app_code = ? AND cycle_id IN (`+placeholders(len(ids))+`)
			ORDER BY rank_no ASC`, args...)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var reward domain.Reward
		if err := rows.Scan(&reward.AppCode, &reward.CycleID, &reward.RankNo, &reward.ThresholdCoinSpent, &reward.RewardCoinAmount, &reward.CreatedAtMS, &reward.UpdatedAtMS); err != nil {
			return err
		}
		if i, ok := index[reward.CycleID]; ok {
			cycles[i].Rewards = append(cycles[i].Rewards, reward)
		}
	}
	return rows.Err()
}

func (r *Repository) checkAgencyOpeningCycleOverlap(ctx context.Context, q interface {
	QueryRowContext(context.Context, string, ...any) *sql.Row
}, cycle domain.Cycle) error {
	if cycle.Status != domain.StatusActive {
		return nil
	}
	var count int64
	if err := q.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM agency_opening_cycles
		WHERE app_code = ? AND region_id = ? AND activity_code = ? AND status = ? AND cycle_id <> ? AND start_ms < ? AND end_ms > ?`,
		appcode.FromContext(ctx), cycle.RegionID, domain.ActivityCode, domain.StatusActive, cycle.CycleID, cycle.EndMS, cycle.StartMS).Scan(&count); err != nil {
		return err
	}
	if count > 0 {
		return xerr.New(xerr.Conflict, "active agency opening cycle overlaps")
	}
	return nil
}

func agencyOpeningCycleWhere(ctx context.Context, query domain.ListQuery) (string, []any) {
	where := []string{"app_code = ?", "activity_code = ?"}
	args := []any{appcode.FromContext(ctx), domain.ActivityCode}
	if query.RegionID > 0 {
		where = append(where, "region_id = ?")
		args = append(args, query.RegionID)
	}
	if strings.TrimSpace(query.Status) != "" {
		where = append(where, "status = ?")
		args = append(args, strings.TrimSpace(query.Status))
	}
	if query.StartMS > 0 {
		where = append(where, "end_ms > ?")
		args = append(args, query.StartMS)
	}
	if query.EndMS > 0 {
		where = append(where, "start_ms < ?")
		args = append(args, query.EndMS)
	}
	return "WHERE " + strings.Join(where, " AND "), args
}

func agencyOpeningApplicationWhere(ctx context.Context, query domain.ApplicationQuery) (string, []any) {
	where := []string{"app_code = ?"}
	args := []any{appcode.FromContext(ctx)}
	if query.RegionID > 0 {
		where = append(where, "region_id = ?")
		args = append(args, query.RegionID)
	}
	if strings.TrimSpace(query.CycleID) != "" {
		where = append(where, "cycle_id = ?")
		args = append(args, strings.TrimSpace(query.CycleID))
	}
	if strings.TrimSpace(query.Status) != "" {
		where = append(where, "status = ?")
		args = append(args, strings.TrimSpace(query.Status))
	}
	if query.AgencyID > 0 {
		where = append(where, "agency_id = ?")
		args = append(args, query.AgencyID)
	}
	if query.AgencyOwnerUserID > 0 {
		where = append(where, "agency_owner_user_id = ?")
		args = append(args, query.AgencyOwnerUserID)
	}
	return "WHERE " + strings.Join(where, " AND "), args
}

func agencyOpeningApplicationSelect() string {
	return `SELECT app_code, region_id, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms,
	       status, score_coin_amount, reward_rank_no, reward_threshold_coin_spent, reward_coin_amount, wallet_command_id, wallet_transaction_id, failure_reason,
	       applied_at_ms, approved_at_ms, score_start_ms, score_end_ms, reviewed_by_admin_id, granted_at_ms, created_at_ms, updated_at_ms FROM agency_opening_applications`
}

func scanAgencyOpeningCycle(row interface{ Scan(...any) error }) (domain.Cycle, error) {
	var item domain.Cycle
	err := row.Scan(&item.AppCode, &item.RegionID, &item.CycleID, &item.ActivityCode, &item.Title, &item.Status, &item.StartMS, &item.EndMS,
		&item.MinHostCount, &item.MaxAgencyAgeDays, &item.CreatedByAdminID, &item.UpdatedByAdminID, &item.SettledAtMS,
		&item.CreatedAtMS, &item.UpdatedAtMS)
	return item, err
}

func scanAgencyOpeningApplication(row interface{ Scan(...any) error }) (domain.Application, error) {
	var item domain.Application
	err := row.Scan(&item.AppCode, &item.RegionID, &item.ApplicationID, &item.CycleID, &item.AgencyID, &item.AgencyOwnerUserID, &item.AgencyName,
		&item.HostCount, &item.AgencyCreatedAtMS, &item.Status, &item.ScoreCoinAmount, &item.RewardRankNo, &item.RewardThresholdCoin, &item.RewardCoinAmount,
		&item.WalletCommandID, &item.WalletTransactionID, &item.FailureReason, &item.AppliedAtMS, &item.ApprovedAtMS, &item.ScoreStartMS, &item.ScoreEndMS,
		&item.ReviewedByAdminID, &item.GrantedAtMS, &item.CreatedAtMS, &item.UpdatedAtMS)
	return item, err
}

func activeAgencyOpeningRewardTiers(rewards []domain.Reward) []domain.Reward {
	out := make([]domain.Reward, 0, len(rewards))
	for _, reward := range rewards {
		if reward.RankNo > 0 && reward.ThresholdCoinSpent > 0 && reward.RewardCoinAmount >= 0 {
			out = append(out, reward)
		}
	}
	return out
}

func matchAgencyOpeningRewardTier(rewards []domain.Reward, scoreCoinAmount int64) (domain.Reward, bool) {
	var matched domain.Reward
	found := false
	for _, reward := range rewards {
		if scoreCoinAmount >= reward.ThresholdCoinSpent && (!found || reward.ThresholdCoinSpent > matched.ThresholdCoinSpent) {
			matched = reward
			found = true
		}
	}
	return matched, found
}

func truncateAgencyOpeningFailure(value string) string {
	value = strings.TrimSpace(value)
	if len(value) <= 512 {
		return value
	}
	return value[:512]
}
