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.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.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
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 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
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 PERIOD_ALL = "ALL";
  private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
  private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
  private static final int USER_COUNTRY_BATCH_SIZE = 1000;

  private final CountryDashboardDAO countryDashboardDAO;
  private final MongoTemplate mongoTemplate;

  @Override
  public CountryDashboardCO query(CountryDashboardQryCmd cmd) {
    QueryCondition condition = QueryCondition.of(cmd);
    Map<String, CountryDashboardMetricCO> rows = new LinkedHashMap<>();

    mergeSql(rows, countryDashboardDAO.listNewUsers(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));
    mergeSql(rows, countryDashboardDAO.listNewUserRecharge(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));
    mergeSql(rows, countryDashboardDAO.listOfficialRecharge(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));
    mergeSql(rows, countryDashboardDAO.listMifapayRecharge(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));
    mergeSql(rows, countryDashboardDAO.listGoogleRecharge(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));
    mergeSql(rows, countryDashboardDAO.listDealerRecharge(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));
    mergeSql(rows, countryDashboardDAO.listSalaryExchange(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));
    mergeSql(rows, countryDashboardDAO.listLuckyGift(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));
    mergeSql(rows, countryDashboardDAO.listWalletGame(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));
    mergeSql(rows, countryDashboardDAO.listLuckyBoxGame(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));
    mergeSql(rows, countryDashboardDAO.listGameUser(
        condition.periodType, condition.startTime, condition.endTime,
        condition.countryKeyword, condition.sysOrigin));

    mergeMongo(rows, condition, listGiftConsume(condition),
        (row, amount) -> row.setGiftConsume(add(row.getGiftConsume(), amount)));
    mergeMongo(rows, condition, listLuckyGiftAnchorShare(condition),
        (row, amount) -> row.setLuckyGiftAnchorShare(add(row.getLuckyGiftAnchorShare(), amount)));
    mergeDailyActive(rows, condition, listDailyActive(condition));
    mergeRetention(rows, condition);

    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)
        .setComputedAt(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
        .setTotal(total)
        .setRecords(records);
  }

  @Override
  public PageResult<CountryDashboardRechargeDetailCO> pageRechargeDetails(
      CountryDashboardRechargeDetailQryCmd cmd) {
    QueryCondition condition = QueryCondition.of(cmd);
    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);
    int offset = (cursor - 1) * limit;
    String countryCodes = normalizeCountryCodes(cmd == null ? null : cmd.getCountryCodes());

    Long total = countryDashboardDAO.countRechargeDetails(
        condition.startTime, condition.endTime, condition.countryKeyword, countryCodes,
        condition.sysOrigin);
    List<CountryDashboardRechargeDetailCO> records = countryDashboardDAO.listRechargeDetails(
            condition.startTime, condition.endTime, condition.countryKeyword, countryCodes,
            condition.sysOrigin, offset, limit)
        .stream()
        .map(this::toRechargeDetailCO)
        .toList();

    PageResult<CountryDashboardRechargeDetailCO> result = new PageResult<>();
    result.setCurrent(cursor);
    result.setSize(limit);
    result.setTotal(total == null ? 0L : total);
    result.setRecords(records);
    return result;
  }

  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.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.setSalaryExchange(add(row.getSalaryExchange(), metric.getSalaryExchange()));
      row.setLuckyGiftTotalFlow(add(row.getLuckyGiftTotalFlow(), metric.getLuckyGiftTotalFlow()));
      row.setLuckyGiftUser(row.getLuckyGiftUser() + safeLong(metric.getLuckyGiftUser()));
      row.setLuckyGiftPayout(add(row.getLuckyGiftPayout(), metric.getLuckyGiftPayout()));
      row.setGameTotalFlow(add(row.getGameTotalFlow(), metric.getGameTotalFlow()));
      row.setGameUser(row.getGameUser() + safeLong(metric.getGameUser()));
      row.setGamePayout(add(row.getGamePayout(), metric.getGamePayout()));
    }
  }

  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);
    if (cohorts == null || cohorts.isEmpty()) {
      return;
    }

    LocalDate today = LocalDate.now();
    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);
  }

  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);
  }

  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.startTime != null) {
      criteria.add(Criteria.where("createTime").gte(Timestamp.valueOf(condition.startTime)));
    }
    if (condition.endTime != null) {
      criteria.add(Criteria.where("createTime").lt(Timestamp.valueOf(condition.endTime)));
    }
    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<MongoMetric> aggregateGiftAmount(List<Criteria> criteria, String amountPath,
      boolean unwindAcceptUsers) {
    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(projectGiftDayAmount(amountPath));
    operations.add(Aggregation.group("userId", "day").sum("amount").as("amount"));

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

  private AggregationOperation projectGiftDayAmount(String amountPath) {
    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"))));
  }

  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.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.setSalaryExchange(add(total.getSalaryExchange(), row.getSalaryExchange()));
      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 LocalDateTime startTime;
    private final LocalDateTime endTime;
    private final String countryKeyword;
    private final String sysOrigin;

    private QueryCondition(String periodType, String startDate, String endDate,
        LocalDateTime startTime, LocalDateTime endTime, String countryKeyword,
        String sysOrigin) {
      this.periodType = periodType;
      this.startDate = startDate;
      this.endDate = endDate;
      this.startTime = startTime;
      this.endTime = endTime;
      this.countryKeyword = countryKeyword;
      this.sysOrigin = sysOrigin;
    }

    private static QueryCondition of(CountryDashboardQryCmd cmd) {
      String periodType = normalizePeriodType(cmd == null ? null : cmd.getPeriodType());
      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();
        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.");
      }

      return new QueryCondition(
          periodType,
          start == null ? null : DATE_FORMATTER.format(start),
          end == null ? null : DATE_FORMATTER.format(end),
          start == null ? null : start.atStartOfDay(),
          end == null ? null : end.plusDays(1).atStartOfDay(),
          trimToNull(cmd == null ? null : cmd.getCountryKeyword()),
          trimToNull(cmd == null ? null : cmd.getSysOrigin())
      );
    }

    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 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 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;
    }
  }
}
