package com.red.circle.console.app.service.app.datav;

import com.red.circle.console.app.dto.clientobject.datav.CountryDashboardCO;
import com.red.circle.console.app.dto.clientobject.datav.CountryDashboardArpuSummaryCO;
import com.red.circle.console.app.dto.clientobject.datav.CountryDashboardMetricCO;
import com.red.circle.console.app.dto.clientobject.datav.CountryDashboardRechargeDetailCO;
import com.red.circle.console.app.dto.cmd.datav.CountryDashboardQryCmd;
import com.red.circle.console.app.dto.cmd.datav.CountryDashboardRechargeDetailQryCmd;
import com.red.circle.console.infra.database.rds.dao.datav.CountryDashboardDAO;
import com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardMetricDTO;
import com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardRechargeDetailDTO;
import com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardRetentionUserDTO;
import com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardUserCountryDTO;
import com.red.circle.framework.dto.PageResult;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Timestamp;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.time.temporal.WeekFields;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiConsumer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bson.Document;
import org.bson.types.Decimal128;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Service;

/**
 * 国家数据大屏.
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class CountryDashboardServiceImpl implements CountryDashboardService {

  private static final String COLLECTION_GIFT_GIVE_RUNNING_WATER = "gift_give_running_water";
  private static final String COLLECTION_USER_DAILY_ACTIVE_LOG = "user_daily_active_log";
  private static final String COLLECTION_USER_BANK_RUNNING_WATER = "user_bank_running_water";
  private static final String BANK_EXCHANGE_GOLD_EVENT = "EXCHANGE_GOLD_COINS";
  private static final String BANK_TRANSFER_GOLD_EVENT = "TRANSFER_GOLD";
  private static final int RECEIPT_TYPE_EXPENDITURE = 1;
  private static final String PERIOD_ALL = "ALL";
  private static final ZoneId STORAGE_ZONE_ID = ZoneId.of("Asia/Riyadh");
  private static final ZoneId DEFAULT_STAT_ZONE_ID = STORAGE_ZONE_ID;
  private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
  private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
  private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
  private static final int USER_COUNTRY_BATCH_SIZE = 1000;

  private final CountryDashboardDAO countryDashboardDAO;
  private final MongoTemplate mongoTemplate;
  private volatile boolean rechargeDetailSchemaEnsured;
  private volatile boolean periodMetricSchemaEnsured;

  @Override
  public CountryDashboardCO query(CountryDashboardQryCmd cmd) {
    ensurePeriodMetricSchema();
    QueryCondition condition = QueryCondition.of(cmd);
    CountryDashboardCO dashboard = queryPrecomputed(condition);
    dashboard.setArpuSummary(buildArpuSummary(condition));
    return dashboard;
  }

  private CountryDashboardCO queryPrecomputed(QueryCondition condition) {
    Map<String, CountryDashboardMetricCO> rows = new LinkedHashMap<>();

    mergeSql(rows, countryDashboardDAO.listPrecomputedPeriodMetrics(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.statTimezone, condition.sysOrigin));

    List<CountryDashboardMetricCO> records = rows.values().stream()
        .peek(this::calculateFormulaFields)
        .sorted(Comparator.comparing(CountryDashboardMetricCO::getPeriodKey,
                Comparator.nullsLast(Comparator.reverseOrder()))
            .thenComparing(CountryDashboardMetricCO::getCountryCode,
                Comparator.nullsLast(String::compareTo)))
        .toList();

    CountryDashboardMetricCO total = buildTotal(records, condition.periodType);

    return new CountryDashboardCO()
        .setPeriodType(condition.periodType)
        .setStartDate(condition.startDate)
        .setEndDate(condition.endDate)
        .setStatTimezone(condition.statTimezone)
        .setComputedAt(ZonedDateTime.now(condition.statZoneId).format(DATETIME_FORMATTER))
        .setTotal(total)
        .setRecords(records);
  }

  private CountryDashboardArpuSummaryCO buildArpuSummary(QueryCondition condition) {
    return new CountryDashboardArpuSummaryCO()
        .setDay(queryPrecomputed(condition.recentDays(1)))
        .setThreeDay(queryPrecomputed(condition.recentDays(3)))
        .setWeek(queryPrecomputed(condition.recentDays(7)));
  }

  private void mergeLiveSql(Map<String, CountryDashboardMetricCO> rows, QueryCondition condition) {
    mergeSql(rows, countryDashboardDAO.listNewUsers(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeSql(rows, countryDashboardDAO.listNewUserRecharge(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeSql(rows, countryDashboardDAO.listOfficialRecharge(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeSql(rows, countryDashboardDAO.listMifapayRecharge(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeSql(rows, countryDashboardDAO.listGoogleRecharge(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeSql(rows, countryDashboardDAO.listDealerRecharge(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeSql(rows, countryDashboardDAO.listSalaryExchange(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeSql(rows, countryDashboardDAO.listLuckyGift(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeSql(rows, countryDashboardDAO.listWalletGame(
        condition.periodType, condition.startTime, condition.endTime,
        condition.startTimeMillis(), condition.endTimeMillis(),
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeSql(rows, countryDashboardDAO.listLuckyBoxGame(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeSql(rows, countryDashboardDAO.listGameUser(
        condition.periodType, condition.startTime, condition.endTime,
        condition.startTimeMillis(), condition.endTimeMillis(),
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
  }

  private void overlayLiveSalaryExchange(Map<String, CountryDashboardMetricCO> rows,
      QueryCondition condition) {
    rows.values().forEach(row -> row.setSalaryExchange(BigDecimal.ZERO));
    mergeSqlSalaryExchange(rows, countryDashboardDAO.listSalaryExchange(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset));
    mergeBankSalaryExchange(rows, condition);
  }

  private void mergeSqlSalaryExchange(Map<String, CountryDashboardMetricCO> rows,
      List<CountryDashboardMetricDTO> metrics) {
    if (metrics == null || metrics.isEmpty()) {
      return;
    }
    for (CountryDashboardMetricDTO metric : metrics) {
      CountryDashboardMetricCO row = getRow(rows, metric.getCountryCode(), metric.getCountryName(),
          metric.getPeriodKey(), metric.getPeriodName());
      row.setSalaryExchange(add(row.getSalaryExchange(), metric.getSalaryExchange()));
    }
  }

  private void mergeBankSalaryExchange(Map<String, CountryDashboardMetricCO> rows,
      QueryCondition condition) {
    mergeMongo(rows, condition, listBankSalaryExchange(condition),
        (row, amount) -> row.setSalaryExchange(add(row.getSalaryExchange(), amount)));
  }

  private boolean shouldUsePrecomputedMetrics(QueryCondition condition) {
    if (!hasText(condition.sysOrigin)) {
      return false;
    }
    if (PERIOD_ALL.equals(condition.periodType)) {
      if (condition.startTime != null || condition.endTime != null) {
        return false;
      }
      long count = safeLong(countryDashboardDAO.countAllPrecomputedPeriodMetrics(
          condition.statTimezone, condition.sysOrigin));
      if (count <= 0) {
        log.warn("country dashboard all precomputed metric missing, skip live all-history scan, sysOrigin={}, statTimezone={}",
            condition.sysOrigin, condition.statTimezone);
      }
      return true;
    }

    List<String> periodKeys = expectedPrecomputedPeriodKeys(condition);
    if (periodKeys.isEmpty()) {
      return false;
    }
    long actualPeriods = safeLong(countryDashboardDAO.countPrecomputedPeriods(
        condition.periodType, periodKeys, condition.statTimezone, condition.sysOrigin));
    boolean complete = actualPeriods >= periodKeys.size();
    if (!complete) {
      log.info("country dashboard use live sql, period precomputed metric incomplete, periodType={}, sysOrigin={}, statTimezone={}, start={}, end={}, expectedPeriods={}, actualPeriods={}",
          condition.periodType, condition.sysOrigin, condition.statTimezone, condition.startDate, condition.endDate,
          periodKeys.size(), actualPeriods);
    }
    return complete;
  }

  private List<String> expectedPrecomputedPeriodKeys(QueryCondition condition) {
    if (condition.startDateValue == null || condition.endDateValue == null
        || condition.endDateValue.isBefore(condition.startDateValue)) {
      return List.of();
    }
    LocalDate start = condition.startDateValue;
    LocalDate endExclusive = condition.endDateValue.plusDays(1);
    return switch (condition.periodType) {
      case "DAY" -> expectedDayKeys(start, endExclusive);
      case "WEEK", "MONTH" -> expectedAlignedPeriodKeys(condition.periodType, start,
          endExclusive, condition.statZoneId);
      default -> List.of();
    };
  }

  private List<String> expectedDayKeys(LocalDate start, LocalDate endExclusive) {
    List<String> keys = new ArrayList<>();
    for (LocalDate cursor = start; cursor.isBefore(endExclusive); cursor = cursor.plusDays(1)) {
      keys.add(cursor.format(DATE_FORMATTER));
    }
    return keys;
  }

  private List<String> expectedAlignedPeriodKeys(String periodType, LocalDate start,
      LocalDate endExclusive, ZoneId statZoneId) {
    List<String> keys = new ArrayList<>();
    LocalDate cursor = start;
    LocalDate tomorrow = LocalDate.now(statZoneId).plusDays(1);
    while (cursor.isBefore(endExclusive)) {
      LocalDate periodStart = periodStart(periodType, cursor);
      if (!cursor.equals(periodStart)) {
        return List.of();
      }
      LocalDate naturalEnd = periodEnd(periodType, periodStart);
      LocalDate currentEnd = naturalEnd.isAfter(tomorrow) ? tomorrow : naturalEnd;
      LocalDate windowEnd = currentEnd.isAfter(endExclusive) ? endExclusive : currentEnd;
      boolean fullPeriod = windowEnd.equals(naturalEnd);
      boolean currentPartialPeriod = naturalEnd.isAfter(tomorrow) && windowEnd.equals(tomorrow);
      if (!fullPeriod && !currentPartialPeriod) {
        return List.of();
      }
      keys.add(periodKey(periodType, cursor));
      cursor = windowEnd;
    }
    return cursor.equals(endExclusive) ? keys : List.of();
  }

  private LocalDate periodStart(String periodType, LocalDate date) {
    if ("WEEK".equals(periodType)) {
      return date.with(DayOfWeek.MONDAY);
    }
    return date.withDayOfMonth(1);
  }

  private LocalDate periodEnd(String periodType, LocalDate periodStart) {
    if ("WEEK".equals(periodType)) {
      return periodStart.plusDays(7);
    }
    return periodStart.plusMonths(1);
  }

  private String periodKey(String periodType, LocalDate date) {
    if ("WEEK".equals(periodType)) {
      WeekFields weekFields = WeekFields.ISO;
      int weekYear = date.get(weekFields.weekBasedYear());
      int week = date.get(weekFields.weekOfWeekBasedYear());
      return String.format(Locale.ROOT, "%d-W%02d", weekYear, week);
    }
    return date.format(MONTH_FORMATTER);
  }

  @Override
  public PageResult<CountryDashboardRechargeDetailCO> pageRechargeDetails(
      CountryDashboardRechargeDetailQryCmd cmd) {
    ensureRechargeDetailSchema();
    int cursor = Math.max(1, cmd == null || cmd.getCursor() == null ? 1 : cmd.getCursor());
    int limit = Math.max(1, cmd == null || cmd.getLimit() == null ? 100 : cmd.getLimit());
    limit = Math.min(100, limit);

    QueryCondition condition = QueryCondition.of(cmd);
    String countryCodes = QueryCondition.trimToNull(cmd == null ? null : cmd.getCountryCodes());
    long total = safeLong(countryDashboardDAO.countRechargeDetails(
        condition.startTime, condition.endTime, condition.countryKeyword, countryCodes,
        condition.sysOrigin, condition.storageTimezoneOffset, condition.statTimezoneOffset));
    List<CountryDashboardRechargeDetailCO> records = total <= 0 ? List.of()
        : countryDashboardDAO.listRechargeDetails(
                condition.startTime, condition.endTime, condition.countryKeyword, countryCodes,
                condition.sysOrigin, condition.storageTimezoneOffset, condition.statTimezoneOffset,
                (cursor - 1) * limit, limit)
            .stream()
            .map(this::toRechargeDetailCO)
            .toList();
    PageResult<CountryDashboardRechargeDetailCO> result = new PageResult<>();
    result.setCurrent(cursor);
    result.setSize(limit);
    result.setTotal(total);
    result.setRecords(records);
    return result;
  }

  private void ensureRechargeDetailSchema() {
    if (rechargeDetailSchemaEnsured) {
      return;
    }
    synchronized (this) {
      if (rechargeDetailSchemaEnsured) {
        return;
      }
      countryDashboardDAO.createRechargeDetailTable();
      rechargeDetailSchemaEnsured = true;
    }
  }

  private void ensurePeriodMetricSchema() {
    if (periodMetricSchemaEnsured) {
      return;
    }
    synchronized (this) {
      if (periodMetricSchemaEnsured) {
        return;
      }
      countryDashboardDAO.createPeriodMetricTable();
      countryDashboardDAO.ensurePeriodMetricUserColumnsSchema();
      countryDashboardDAO.ensureMetricValueColumnsSchema();
      periodMetricSchemaEnsured = true;
    }
  }

  private CountryDashboardRechargeDetailCO toRechargeDetailCO(
      CountryDashboardRechargeDetailDTO dto) {
    return new CountryDashboardRechargeDetailCO()
        .setRecordId(dto.getRecordId())
        .setSourceType(dto.getSourceType())
        .setSourceChannel(dto.getSourceChannel())
        .setUserId(dto.getUserId())
        .setUserAccount(dto.getUserAccount())
        .setUserNickname(dto.getUserNickname())
        .setCountryCode(dto.getCountryCode())
        .setCountryName(dto.getCountryName())
        .setAmount(safe(dto.getAmount()))
        .setCoinQuantity(safe(dto.getCoinQuantity()))
        .setRemark(dto.getRemark())
        .setCreateTime(dto.getCreateTime());
  }

  private void mergeSql(Map<String, CountryDashboardMetricCO> rows,
      List<CountryDashboardMetricDTO> metrics) {
    if (metrics == null || metrics.isEmpty()) {
      return;
    }
    for (CountryDashboardMetricDTO metric : metrics) {
      CountryDashboardMetricCO row = getRow(rows, metric.getCountryCode(), metric.getCountryName(),
          metric.getPeriodKey(), metric.getPeriodName());
      row.setCountryNewUser(row.getCountryNewUser() + safeLong(metric.getCountryNewUser()));
      row.setDailyActiveUser(row.getDailyActiveUser() + safeLong(metric.getDailyActiveUser()));
      row.setDailyRechargeUser(row.getDailyRechargeUser() + safeLong(metric.getDailyRechargeUser()));
      row.setNewUserRecharge(add(row.getNewUserRecharge(), metric.getNewUserRecharge()));
      row.setOfficialRecharge(add(row.getOfficialRecharge(), metric.getOfficialRecharge()));
      row.setMifapayRecharge(add(row.getMifapayRecharge(), metric.getMifapayRecharge()));
      row.setGoogleRecharge(add(row.getGoogleRecharge(), metric.getGoogleRecharge()));
      row.setDealerRecharge(add(row.getDealerRecharge(), metric.getDealerRecharge()));
      row.setUserRecharge(add(row.getUserRecharge(), metric.getUserRecharge()));
      row.setNewDealerUserRecharge(add(row.getNewDealerUserRecharge(), metric.getNewDealerUserRecharge()));
      row.setSalaryExchange(add(row.getSalaryExchange(), metric.getSalaryExchange()));
      row.setSalaryTransfer(add(row.getSalaryTransfer(), metric.getSalaryTransfer()));
      row.setGiftConsume(add(row.getGiftConsume(), metric.getGiftConsume()));
      row.setLuckyGiftTotalFlow(add(row.getLuckyGiftTotalFlow(), metric.getLuckyGiftTotalFlow()));
      row.setLuckyGiftUser(row.getLuckyGiftUser() + safeLong(metric.getLuckyGiftUser()));
      row.setLuckyGiftPayout(add(row.getLuckyGiftPayout(), metric.getLuckyGiftPayout()));
      row.setLuckyGiftAnchorShare(add(row.getLuckyGiftAnchorShare(), metric.getLuckyGiftAnchorShare()));
      row.setGameTotalFlow(add(row.getGameTotalFlow(), metric.getGameTotalFlow()));
      row.setGameUser(row.getGameUser() + safeLong(metric.getGameUser()));
      row.setGamePayout(add(row.getGamePayout(), metric.getGamePayout()));
      row.setD1RetentionUser(row.getD1RetentionUser() + safeLong(metric.getD1RetentionUser()));
      row.setD1RetentionBaseUser(row.getD1RetentionBaseUser() + safeLong(metric.getD1RetentionBaseUser()));
      row.setD7RetentionUser(row.getD7RetentionUser() + safeLong(metric.getD7RetentionUser()));
      row.setD7RetentionBaseUser(row.getD7RetentionBaseUser() + safeLong(metric.getD7RetentionBaseUser()));
      row.setD30RetentionUser(row.getD30RetentionUser() + safeLong(metric.getD30RetentionUser()));
      row.setD30RetentionBaseUser(row.getD30RetentionBaseUser() + safeLong(metric.getD30RetentionBaseUser()));
    }
  }

  private void mergeMongo(Map<String, CountryDashboardMetricCO> rows, QueryCondition condition,
      List<MongoMetric> metrics, BiConsumer<CountryDashboardMetricCO, BigDecimal> merger) {
    if (metrics == null || metrics.isEmpty()) {
      return;
    }

    Map<Long, CountryDashboardUserCountryDTO> countryMap = mapUserCountries(
        metrics.stream().map(MongoMetric::userId).filter(Objects::nonNull).distinct().toList());

    for (MongoMetric metric : metrics) {
      CountryDashboardUserCountryDTO country = countryMap.get(metric.userId());
      if (country == null || !matchesCountry(country, condition.countryKeyword)) {
        continue;
      }
      PeriodBucket bucket = condition.toPeriodBucket(metric.day());
      CountryDashboardMetricCO row = getRow(rows, country.getCountryCode(), country.getCountryName(),
          bucket.key(), bucket.name());
      merger.accept(row, metric.amount());
    }
  }

  private void mergeDailyActive(Map<String, CountryDashboardMetricCO> rows, QueryCondition condition,
      List<MongoUserDayMetric> metrics) {
    if (metrics == null || metrics.isEmpty()) {
      return;
    }

    Map<Long, CountryDashboardUserCountryDTO> countryMap = mapUserCountries(
        metrics.stream().map(MongoUserDayMetric::userId).filter(Objects::nonNull).distinct().toList());
    Set<String> counted = new HashSet<>();

    for (MongoUserDayMetric metric : metrics) {
      CountryDashboardUserCountryDTO country = countryMap.get(metric.userId());
      if (country == null || !matchesCountry(country, condition.countryKeyword)) {
        continue;
      }
      PeriodBucket bucket = condition.toPeriodBucket(metric.day());
      String dedupeKey = country.getCountryCode() + "|" + bucket.key() + "|" + metric.userId();
      if (!counted.add(dedupeKey)) {
        continue;
      }
      CountryDashboardMetricCO row = getRow(rows, country.getCountryCode(), country.getCountryName(),
          bucket.key(), bucket.name());
      row.setDailyActiveUser(row.getDailyActiveUser() + 1);
    }
  }

  private void mergeRetention(Map<String, CountryDashboardMetricCO> rows, QueryCondition condition) {
    List<CountryDashboardRetentionUserDTO> cohorts = countryDashboardDAO.listNewUserCohorts(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin,
        condition.storageTimezoneOffset, condition.statTimezoneOffset);
    if (cohorts == null || cohorts.isEmpty()) {
      return;
    }

    LocalDate today = LocalDate.now(condition.statZoneId);
    Set<Long> userIds = new HashSet<>();
    Set<String> activeDates = new HashSet<>();
    for (CountryDashboardRetentionUserDTO cohort : cohorts) {
      if (cohort.getUserId() == null || !hasText(cohort.getRegisterDate())) {
        continue;
      }
      userIds.add(cohort.getUserId());
      collectRetentionActiveDate(activeDates, cohort.getRegisterDate(), 1, today);
      collectRetentionActiveDate(activeDates, cohort.getRegisterDate(), 7, today);
      collectRetentionActiveDate(activeDates, cohort.getRegisterDate(), 30, today);
    }

    Set<String> activeUserDates = listActiveUserDateSet(userIds, activeDates, condition.sysOrigin);
    for (CountryDashboardRetentionUserDTO cohort : cohorts) {
      if (cohort.getUserId() == null || !hasText(cohort.getRegisterDate())) {
        continue;
      }
      CountryDashboardMetricCO row = getRow(rows, cohort.getCountryCode(), cohort.getCountryName(),
          cohort.getPeriodKey(), cohort.getPeriodName());
      mergeRetentionDay(row, cohort.getUserId(), cohort.getRegisterDate(), 1, today, activeUserDates);
      mergeRetentionDay(row, cohort.getUserId(), cohort.getRegisterDate(), 7, today, activeUserDates);
      mergeRetentionDay(row, cohort.getUserId(), cohort.getRegisterDate(), 30, today, activeUserDates);
    }
  }

  private List<MongoMetric> listGiftConsume(QueryCondition condition) {
    List<Criteria> criteria = baseGiftCriteria(condition);
    criteria.add(Criteria.where("luckyGift").ne(Boolean.TRUE));
    criteria.add(Criteria.where("originId").regex("^\\d+$"));
    criteria.add(new Criteria().orOperator(
        Criteria.where("dynamicContentId").exists(false),
        Criteria.where("dynamicContentId").is(null),
        Criteria.where("dynamicContentId").is("")
    ));
    return aggregateGiftAmount(criteria, "$giftValue.actualAmount", false, condition.statTimezone);
  }

  private List<MongoMetric> listLuckyGiftAnchorShare(QueryCondition condition) {
    List<Criteria> criteria = baseGiftCriteria(condition);
    criteria.add(Criteria.where("luckyGift").is(Boolean.TRUE));
    return aggregateGiftAmount(criteria, "$acceptUsers.targetAmount", true, condition.statTimezone);
  }

  private List<MongoMetric> listBankSalaryExchange(QueryCondition condition) {
    List<Criteria> criteria = baseBankRunningWaterCriteria(condition);
    criteria.add(Criteria.where("event").is(BANK_EXCHANGE_GOLD_EVENT));
    criteria.add(Criteria.where("type").is(RECEIPT_TYPE_EXPENDITURE));
    return aggregateMongoAmount(criteria, "$amount", false, condition.statTimezone,
        COLLECTION_USER_BANK_RUNNING_WATER);
  }

  private List<MongoMetric> listBankSalaryTransfer(QueryCondition condition) {
    List<Criteria> criteria = baseBankRunningWaterCriteria(condition);
    criteria.add(Criteria.where("event").is(BANK_TRANSFER_GOLD_EVENT));
    criteria.add(Criteria.where("type").is(RECEIPT_TYPE_EXPENDITURE));
    return aggregateMongoAmount(criteria, "$amount", false, condition.statTimezone,
        COLLECTION_USER_BANK_RUNNING_WATER);
  }

  private List<MongoUserDayMetric> listDailyActive(QueryCondition condition) {
    List<Criteria> criteria = baseActiveCriteria(condition);
    List<AggregationOperation> operations = new ArrayList<>();
    if (!criteria.isEmpty()) {
      operations.add(Aggregation.match(new Criteria().andOperator(criteria.toArray(new Criteria[0]))));
    }
    operations.add(Aggregation.group("userId", "activeDate"));

    return mongoTemplate.aggregate(Aggregation.newAggregation(operations),
            COLLECTION_USER_DAILY_ACTIVE_LOG, Document.class)
        .getMappedResults()
        .stream()
        .map(this::toMongoUserDayMetric)
        .filter(Objects::nonNull)
        .toList();
  }

  private List<Criteria> baseGiftCriteria(QueryCondition condition) {
    List<Criteria> criteria = new ArrayList<>();
    criteria.add(Criteria.where("refunded").ne(Boolean.TRUE));
    criteria.add(Criteria.where("userId").ne(null));
    if (condition.startInstant != null) {
      criteria.add(Criteria.where("createTime").gte(Timestamp.from(condition.startInstant)));
    }
    if (condition.endInstant != null) {
      criteria.add(Criteria.where("createTime").lt(Timestamp.from(condition.endInstant)));
    }
    if (hasText(condition.sysOrigin)) {
      criteria.add(Criteria.where("sysOrigin").is(condition.sysOrigin));
    }
    return criteria;
  }

  private List<Criteria> baseActiveCriteria(QueryCondition condition) {
    List<Criteria> criteria = new ArrayList<>();
    Criteria activeDate = Criteria.where("activeDate");
    boolean hasActiveDateCriteria = false;
    if (hasText(condition.startDate)) {
      activeDate = activeDate.gte(condition.startDate);
      hasActiveDateCriteria = true;
    }
    if (hasText(condition.endDate)) {
      activeDate = activeDate.lte(condition.endDate);
      hasActiveDateCriteria = true;
    }
    if (hasActiveDateCriteria) {
      criteria.add(activeDate);
    }
    if (hasText(condition.sysOrigin)) {
      criteria.add(Criteria.where("sysOrigin").is(condition.sysOrigin));
    }
    return criteria;
  }

  private List<Criteria> baseBankRunningWaterCriteria(QueryCondition condition) {
    List<Criteria> criteria = new ArrayList<>();
    criteria.add(Criteria.where("userId").ne(null));
    if (condition.startInstant != null) {
      criteria.add(Criteria.where("createTime").gte(Timestamp.from(condition.startInstant)));
    }
    if (condition.endInstant != null) {
      criteria.add(Criteria.where("createTime").lt(Timestamp.from(condition.endInstant)));
    }
    if (hasText(condition.sysOrigin)) {
      criteria.add(Criteria.where("sysOrigin").is(condition.sysOrigin));
    }
    return criteria;
  }

  private List<MongoMetric> aggregateGiftAmount(List<Criteria> criteria, String amountPath,
      boolean unwindAcceptUsers, String statTimezone) {
    return aggregateMongoAmount(criteria, amountPath, unwindAcceptUsers, statTimezone,
        COLLECTION_GIFT_GIVE_RUNNING_WATER);
  }

  private List<MongoMetric> aggregateMongoAmount(List<Criteria> criteria, String amountPath,
      boolean unwindAcceptUsers, String statTimezone, String collectionName) {
    List<AggregationOperation> operations = new ArrayList<>();
    operations.add(Aggregation.match(new Criteria().andOperator(criteria.toArray(new Criteria[0]))));
    if (unwindAcceptUsers) {
      operations.add(Aggregation.unwind("acceptUsers"));
    }
    operations.add(projectMongoDayAmount(amountPath, statTimezone));
    operations.add(Aggregation.group("userId", "day").sum("amount").as("amount"));

    return mongoTemplate.aggregate(Aggregation.newAggregation(operations),
            collectionName, Document.class)
        .getMappedResults()
        .stream()
        .map(this::toMongoMetric)
        .filter(Objects::nonNull)
        .toList();
  }

  private AggregationOperation projectMongoDayAmount(String amountPath, String statTimezone) {
    return context -> new Document("$project",
        new Document("userId", "$userId")
            .append("amount", amountPath)
            .append("day", new Document("$dateToString",
                new Document("format", "%Y-%m-%d")
                    .append("date", "$createTime")
                    .append("timezone", statTimezone))));
  }

  private MongoMetric toMongoMetric(Document document) {
    Object id = document.get("_id");
    if (!(id instanceof Document idDoc)) {
      return null;
    }
    Long userId = toLong(idDoc.get("userId"));
    String day = Objects.toString(idDoc.get("day"), null);
    BigDecimal amount = toBigDecimal(document.get("amount"));
    if (userId == null || !hasText(day) || amount == null) {
      return null;
    }
    return new MongoMetric(userId, day, amount);
  }

  private MongoUserDayMetric toMongoUserDayMetric(Document document) {
    Object id = document.get("_id");
    if (!(id instanceof Document idDoc)) {
      return null;
    }
    Long userId = toLong(idDoc.get("userId"));
    String day = Objects.toString(idDoc.get("activeDate"), null);
    if (userId == null || !hasText(day)) {
      return null;
    }
    return new MongoUserDayMetric(userId, day);
  }

  private void collectRetentionActiveDate(Set<String> activeDates, String registerDate,
      int retentionDays, LocalDate today) {
    LocalDate targetDate = LocalDate.parse(registerDate, DATE_FORMATTER).plusDays(retentionDays);
    if (!targetDate.isAfter(today)) {
      activeDates.add(DATE_FORMATTER.format(targetDate));
    }
  }

  private void mergeRetentionDay(CountryDashboardMetricCO row, Long userId, String registerDate,
      int retentionDays, LocalDate today, Set<String> activeUserDates) {
    LocalDate targetDate = LocalDate.parse(registerDate, DATE_FORMATTER).plusDays(retentionDays);
    if (targetDate.isAfter(today)) {
      return;
    }
    String activeKey = userId + "|" + DATE_FORMATTER.format(targetDate);
    boolean retained = activeUserDates.contains(activeKey);
    if (retentionDays == 1) {
      row.setD1RetentionBaseUser(row.getD1RetentionBaseUser() + 1);
      row.setD1RetentionUser(row.getD1RetentionUser() + (retained ? 1 : 0));
      return;
    }
    if (retentionDays == 7) {
      row.setD7RetentionBaseUser(row.getD7RetentionBaseUser() + 1);
      row.setD7RetentionUser(row.getD7RetentionUser() + (retained ? 1 : 0));
      return;
    }
    row.setD30RetentionBaseUser(row.getD30RetentionBaseUser() + 1);
    row.setD30RetentionUser(row.getD30RetentionUser() + (retained ? 1 : 0));
  }

  private Set<String> listActiveUserDateSet(Set<Long> userIds, Set<String> activeDates,
      String sysOrigin) {
    Set<String> result = new HashSet<>();
    if (userIds == null || userIds.isEmpty() || activeDates == null || activeDates.isEmpty()) {
      return result;
    }

    List<Long> ids = new ArrayList<>(userIds);
    for (int start = 0; start < ids.size(); start += USER_COUNTRY_BATCH_SIZE) {
      int end = Math.min(start + USER_COUNTRY_BATCH_SIZE, ids.size());
      List<Criteria> criteria = new ArrayList<>();
      criteria.add(Criteria.where("userId").in(ids.subList(start, end)));
      criteria.add(Criteria.where("activeDate").in(activeDates));
      if (hasText(sysOrigin)) {
        criteria.add(Criteria.where("sysOrigin").is(sysOrigin));
      }
      Query query = Query.query(new Criteria().andOperator(criteria.toArray(new Criteria[0])));
      query.fields().include("userId").include("activeDate");
      List<Document> documents = mongoTemplate.find(query, Document.class, COLLECTION_USER_DAILY_ACTIVE_LOG);
      for (Document document : documents) {
        Long userId = toLong(document.get("userId"));
        String activeDate = Objects.toString(document.get("activeDate"), null);
        if (userId != null && hasText(activeDate)) {
          result.add(userId + "|" + activeDate);
        }
      }
    }
    return result;
  }

  private Map<Long, CountryDashboardUserCountryDTO> mapUserCountries(Collection<Long> userIds) {
    Map<Long, CountryDashboardUserCountryDTO> result = new HashMap<>();
    if (userIds == null || userIds.isEmpty()) {
      return result;
    }
    List<Long> ids = new ArrayList<>(userIds);
    for (int start = 0; start < ids.size(); start += USER_COUNTRY_BATCH_SIZE) {
      int end = Math.min(start + USER_COUNTRY_BATCH_SIZE, ids.size());
      List<CountryDashboardUserCountryDTO> batch = countryDashboardDAO.listUserCountryByIds(
          ids.subList(start, end));
      if (batch == null) {
        continue;
      }
      for (CountryDashboardUserCountryDTO item : batch) {
        result.put(item.getUserId(), item);
      }
    }
    return result;
  }

  private boolean matchesCountry(CountryDashboardUserCountryDTO country, String keyword) {
    if (!hasText(keyword)) {
      return true;
    }
    String countryCode = Objects.toString(country.getCountryCode(), "");
    String countryName = Objects.toString(country.getCountryName(), "");
    return countryCode.equals(keyword) || countryName.contains(keyword);
  }

  private CountryDashboardMetricCO getRow(Map<String, CountryDashboardMetricCO> rows,
      String countryCode, String countryName, String periodKey, String periodName) {
    String fixedCountryCode = hasText(countryCode) ? countryCode : "UNKNOWN";
    String fixedCountryName = hasText(countryName) ? countryName : fixedCountryCode;
    String fixedPeriodKey = hasText(periodKey) ? periodKey : PERIOD_ALL;
    String fixedPeriodName = hasText(periodName) ? periodName : "全部";
    return rows.computeIfAbsent(fixedCountryCode + "|" + fixedPeriodKey,
        key -> new CountryDashboardMetricCO()
            .setCountryCode(fixedCountryCode)
            .setCountryName(fixedCountryName)
            .setPeriodKey(fixedPeriodKey)
            .setPeriodName(fixedPeriodName));
  }

  private void calculateFormulaFields(CountryDashboardMetricCO row) {
    row.setTotalRecharge(add(add(row.getOfficialRecharge(), row.getMifapayRecharge()),
        row.getDealerRecharge()));
    row.setLuckyGiftProfit(safe(row.getLuckyGiftTotalFlow())
        .subtract(safe(row.getLuckyGiftPayout()))
        .subtract(safe(row.getLuckyGiftAnchorShare())));
    row.setLuckyGiftUserRate(percent(row.getLuckyGiftUser(), row.getDailyActiveUser()));
    row.setLuckyGiftPayoutRate(percent(row.getLuckyGiftPayout(), row.getLuckyGiftTotalFlow()));
    row.setLuckyGiftProfitRate(percent(row.getLuckyGiftProfit(), row.getLuckyGiftTotalFlow()));
    row.setGameProfit(safe(row.getGameTotalFlow()).subtract(safe(row.getGamePayout())));
    row.setGamePayoutRate(percent(row.getGamePayout(), row.getGameTotalFlow()));
    row.setGameProfitRate(percent(row.getGameProfit(), row.getGameTotalFlow()));
    row.setD1RetentionRate(percent(row.getD1RetentionUser(), row.getD1RetentionBaseUser()));
    row.setD7RetentionRate(percent(row.getD7RetentionUser(), row.getD7RetentionBaseUser()));
    row.setD30RetentionRate(percent(row.getD30RetentionUser(), row.getD30RetentionBaseUser()));
  }

  private CountryDashboardMetricCO buildTotal(List<CountryDashboardMetricCO> records,
      String periodType) {
    CountryDashboardMetricCO total = new CountryDashboardMetricCO()
        .setCountryCode("ALL")
        .setCountryName("全部国家")
        .setPeriodKey(PERIOD_ALL.equals(periodType) ? PERIOD_ALL : "TOTAL")
        .setPeriodName("合计");
    for (CountryDashboardMetricCO row : records) {
      total.setCountryNewUser(total.getCountryNewUser() + safeLong(row.getCountryNewUser()));
      total.setDailyActiveUser(total.getDailyActiveUser() + safeLong(row.getDailyActiveUser()));
      total.setDailyRechargeUser(total.getDailyRechargeUser() + safeLong(row.getDailyRechargeUser()));
      total.setNewUserRecharge(add(total.getNewUserRecharge(), row.getNewUserRecharge()));
      total.setOfficialRecharge(add(total.getOfficialRecharge(), row.getOfficialRecharge()));
      total.setMifapayRecharge(add(total.getMifapayRecharge(), row.getMifapayRecharge()));
      total.setGoogleRecharge(add(total.getGoogleRecharge(), row.getGoogleRecharge()));
      total.setDealerRecharge(add(total.getDealerRecharge(), row.getDealerRecharge()));
      total.setUserRecharge(add(total.getUserRecharge(), row.getUserRecharge()));
      total.setNewDealerUserRecharge(add(total.getNewDealerUserRecharge(), row.getNewDealerUserRecharge()));
      total.setSalaryExchange(add(total.getSalaryExchange(), row.getSalaryExchange()));
      total.setSalaryTransfer(add(total.getSalaryTransfer(), row.getSalaryTransfer()));
      total.setGiftConsume(add(total.getGiftConsume(), row.getGiftConsume()));
      total.setLuckyGiftTotalFlow(add(total.getLuckyGiftTotalFlow(), row.getLuckyGiftTotalFlow()));
      total.setLuckyGiftUser(total.getLuckyGiftUser() + safeLong(row.getLuckyGiftUser()));
      total.setLuckyGiftPayout(add(total.getLuckyGiftPayout(), row.getLuckyGiftPayout()));
      total.setLuckyGiftAnchorShare(add(total.getLuckyGiftAnchorShare(), row.getLuckyGiftAnchorShare()));
      total.setGameTotalFlow(add(total.getGameTotalFlow(), row.getGameTotalFlow()));
      total.setGameUser(total.getGameUser() + safeLong(row.getGameUser()));
      total.setGamePayout(add(total.getGamePayout(), row.getGamePayout()));
      total.setD1RetentionUser(total.getD1RetentionUser() + safeLong(row.getD1RetentionUser()));
      total.setD1RetentionBaseUser(total.getD1RetentionBaseUser() + safeLong(row.getD1RetentionBaseUser()));
      total.setD7RetentionUser(total.getD7RetentionUser() + safeLong(row.getD7RetentionUser()));
      total.setD7RetentionBaseUser(total.getD7RetentionBaseUser() + safeLong(row.getD7RetentionBaseUser()));
      total.setD30RetentionUser(total.getD30RetentionUser() + safeLong(row.getD30RetentionUser()));
      total.setD30RetentionBaseUser(total.getD30RetentionBaseUser() + safeLong(row.getD30RetentionBaseUser()));
    }
    calculateFormulaFields(total);
    return total;
  }

  private static BigDecimal add(BigDecimal left, BigDecimal right) {
    return safe(left).add(safe(right));
  }

  private static BigDecimal safe(BigDecimal value) {
    return value == null ? BigDecimal.ZERO : value;
  }

  private static BigDecimal percent(BigDecimal numerator, BigDecimal denominator) {
    BigDecimal safeDenominator = safe(denominator);
    if (BigDecimal.ZERO.compareTo(safeDenominator) == 0) {
      return BigDecimal.ZERO;
    }
    return safe(numerator)
        .multiply(BigDecimal.valueOf(100))
        .divide(safeDenominator, 4, RoundingMode.HALF_UP);
  }

  private static BigDecimal percent(Long numerator, Long denominator) {
    return percent(BigDecimal.valueOf(safeLong(numerator)), BigDecimal.valueOf(safeLong(denominator)));
  }

  private static long safeLong(Long value) {
    return value == null ? 0L : value;
  }

  private static BigDecimal toBigDecimal(Object value) {
    if (value == null) {
      return BigDecimal.ZERO;
    }
    if (value instanceof BigDecimal bigDecimal) {
      return bigDecimal;
    }
    if (value instanceof Decimal128 decimal128) {
      return decimal128.bigDecimalValue();
    }
    if (value instanceof Number number) {
      return new BigDecimal(number.toString());
    }
    return new BigDecimal(Objects.toString(value, "0"));
  }

  private static Long toLong(Object value) {
    if (value instanceof Number number) {
      return number.longValue();
    }
    if (value == null) {
      return null;
    }
    return Long.valueOf(Objects.toString(value));
  }

  private static boolean hasText(String value) {
    return value != null && !value.trim().isEmpty();
  }

  private static String normalizeCountryCodes(String value) {
    if (!hasText(value)) {
      return null;
    }
    String normalized = java.util.Arrays.stream(value.split(","))
        .map(String::trim)
        .filter(CountryDashboardServiceImpl::hasText)
        .distinct()
        .collect(java.util.stream.Collectors.joining(","));
    return hasText(normalized) ? normalized : null;
  }

  private record MongoMetric(Long userId, String day, BigDecimal amount) {
  }

  private record MongoUserDayMetric(Long userId, String day) {
  }

  private record PeriodBucket(String key, String name) {
  }

  private static final class QueryCondition {

    private final String periodType;
    private final String startDate;
    private final String endDate;
    private final String statTimezone;
    private final ZoneId statZoneId;
    private final String storageTimezoneOffset;
    private final String statTimezoneOffset;
    private final LocalDate startDateValue;
    private final LocalDate endDateValue;
    private final LocalDateTime startTime;
    private final LocalDateTime endTime;
    private final Instant startInstant;
    private final Instant endInstant;
    private final String countryKeyword;
    private final String sysOrigin;

    private QueryCondition(String periodType, String startDate, String endDate,
        String statTimezone, ZoneId statZoneId, String storageTimezoneOffset,
        String statTimezoneOffset, LocalDate startDateValue, LocalDate endDateValue,
        LocalDateTime startTime, LocalDateTime endTime, Instant startInstant,
        Instant endInstant, String countryKeyword, String sysOrigin) {
      this.periodType = periodType;
      this.startDate = startDate;
      this.endDate = endDate;
      this.statTimezone = statTimezone;
      this.statZoneId = statZoneId;
      this.storageTimezoneOffset = storageTimezoneOffset;
      this.statTimezoneOffset = statTimezoneOffset;
      this.startDateValue = startDateValue;
      this.endDateValue = endDateValue;
      this.startTime = startTime;
      this.endTime = endTime;
      this.startInstant = startInstant;
      this.endInstant = endInstant;
      this.countryKeyword = countryKeyword;
      this.sysOrigin = sysOrigin;
    }

    private static QueryCondition of(CountryDashboardQryCmd cmd) {
      String periodType = normalizePeriodType(cmd == null ? null : cmd.getPeriodType());
      ZoneId statZoneId = normalizeStatZoneId(cmd == null ? null : cmd.getStatTimezone());
      LocalDate start = parseDate(cmd == null ? null : cmd.getStartDate());
      LocalDate end = parseDate(cmd == null ? null : cmd.getEndDate());

      if (!PERIOD_ALL.equals(periodType) && start == null && end == null) {
        LocalDate today = LocalDate.now(statZoneId);
        switch (periodType) {
          case "WEEK" -> {
            start = today.with(DayOfWeek.MONDAY);
            end = today.with(DayOfWeek.SUNDAY);
          }
          case "MONTH" -> {
            start = today.withDayOfMonth(1);
            end = today.withDayOfMonth(today.lengthOfMonth());
          }
          default -> {
            start = today;
            end = today;
          }
        }
      }

      if (start != null && end == null) {
        end = start;
      }
      if (start == null && end != null) {
        start = end;
      }
      if (start != null && end != null && end.isBefore(start)) {
        throw new IllegalArgumentException("endDate must not be before startDate.");
      }

      Instant startInstant = start == null ? null : start.atStartOfDay(statZoneId).toInstant();
      Instant endInstant = end == null ? null : end.plusDays(1).atStartOfDay(statZoneId).toInstant();
      LocalDateTime storageStartTime = startInstant == null ? null
          : LocalDateTime.ofInstant(startInstant, STORAGE_ZONE_ID);
      LocalDateTime storageEndTime = endInstant == null ? null
          : LocalDateTime.ofInstant(endInstant, STORAGE_ZONE_ID);

      return new QueryCondition(
          periodType,
          start == null ? null : DATE_FORMATTER.format(start),
          end == null ? null : DATE_FORMATTER.format(end),
          statZoneId.getId(),
          statZoneId,
          zoneOffset(STORAGE_ZONE_ID),
          zoneOffset(statZoneId),
          start,
          end,
          storageStartTime,
          storageEndTime,
          startInstant,
          endInstant,
          trimToNull(cmd == null ? null : cmd.getCountryKeyword()),
          trimToNull(cmd == null ? null : cmd.getSysOrigin())
      );
    }

    private QueryCondition recentDays(int dayCount) {
      LocalDate end = LocalDate.now(statZoneId);
      LocalDate start = end.minusDays(Math.max(1, dayCount) - 1L);
      Instant recentStartInstant = start.atStartOfDay(statZoneId).toInstant();
      Instant recentEndInstant = end.plusDays(1).atStartOfDay(statZoneId).toInstant();
      LocalDateTime recentStorageStartTime =
          LocalDateTime.ofInstant(recentStartInstant, STORAGE_ZONE_ID);
      LocalDateTime recentStorageEndTime =
          LocalDateTime.ofInstant(recentEndInstant, STORAGE_ZONE_ID);
      return new QueryCondition(
          "DAY",
          DATE_FORMATTER.format(start),
          DATE_FORMATTER.format(end),
          statTimezone,
          statZoneId,
          storageTimezoneOffset,
          statTimezoneOffset,
          start,
          end,
          recentStorageStartTime,
          recentStorageEndTime,
          recentStartInstant,
          recentEndInstant,
          null,
          sysOrigin
      );
    }

    private PeriodBucket toPeriodBucket(String day) {
      if (PERIOD_ALL.equals(periodType)) {
        return new PeriodBucket(PERIOD_ALL, "全部");
      }
      LocalDate date = LocalDate.parse(day, DATE_FORMATTER);
      if ("MONTH".equals(periodType)) {
        return new PeriodBucket(date.format(MONTH_FORMATTER), date.format(MONTH_FORMATTER));
      }
      if ("WEEK".equals(periodType)) {
        WeekFields weekFields = WeekFields.ISO;
        int weekYear = date.get(weekFields.weekBasedYear());
        int week = date.get(weekFields.weekOfWeekBasedYear());
        String key = String.format(Locale.ROOT, "%d-W%02d", weekYear, week);
        return new PeriodBucket(key, key);
      }
      return new PeriodBucket(day, day);
    }

    private boolean shouldOverlayLiveSalaryExchange() {
      if (PERIOD_ALL.equals(periodType) || startDateValue == null || endDateValue == null) {
        return false;
      }
      long days = ChronoUnit.DAYS.between(startDateValue, endDateValue.plusDays(1));
      return days <= 31;
    }

    private Long startTimeMillis() {
      return startInstant == null ? null : startInstant.toEpochMilli();
    }

    private Long endTimeMillis() {
      return endInstant == null ? null : endInstant.toEpochMilli();
    }

    private boolean isStorageTimezone() {
      return STORAGE_ZONE_ID.equals(statZoneId);
    }

    private static String normalizePeriodType(String periodType) {
      String value = trimToNull(periodType);
      if (value == null) {
        return "DAY";
      }
      value = value.toUpperCase(Locale.ROOT);
      return switch (value) {
        case "DAY", "WEEK", "MONTH", "ALL" -> value;
        default -> "DAY";
      };
    }

    private static ZoneId normalizeStatZoneId(String statTimezone) {
      String value = trimToNull(statTimezone);
      if (value == null) {
        return DEFAULT_STAT_ZONE_ID;
      }
      value = value.trim();
      String upperValue = value.toUpperCase(Locale.ROOT);
      return switch (upperValue) {
        case "UTC", "Z", "+00:00" -> ZoneId.of("UTC");
        case "ASIA/SHANGHAI", "BEIJING", "CHINA", "+08:00" -> ZoneId.of("Asia/Shanghai");
        case "ASIA/RIYADH", "RIYADH", "SERVER", "+03:00" -> STORAGE_ZONE_ID;
        default -> DEFAULT_STAT_ZONE_ID;
      };
    }

    private static String zoneOffset(ZoneId zoneId) {
      ZoneOffset offset = zoneId.getRules().getOffset(Instant.now());
      String id = offset.getId();
      return "Z".equals(id) ? "+00:00" : id;
    }

    private static LocalDate parseDate(String value) {
      String fixed = trimToNull(value);
      return fixed == null ? null : LocalDate.parse(fixed, DATE_FORMATTER);
    }

    private static String trimToNull(String value) {
      if (value == null) {
        return null;
      }
      String trimmed = value.trim();
      return trimmed.isEmpty() ? null : trimmed;
    }
  }
}
