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.cmd.datav.CountryDashboardQryCmd;
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.CountryDashboardUserCountryDTO;
import java.math.BigDecimal;
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.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
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.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 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.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));

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

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

  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.setDealerRecharge(add(row.getDealerRecharge(), metric.getDealerRecharge()));
      row.setSalaryExchange(add(row.getSalaryExchange(), metric.getSalaryExchange()));
      row.setLuckyGiftTotalFlow(add(row.getLuckyGiftTotalFlow(), metric.getLuckyGiftTotalFlow()));
      row.setLuckyGiftPayout(add(row.getLuckyGiftPayout(), metric.getLuckyGiftPayout()));
      row.setGameTotalFlow(add(row.getGameTotalFlow(), metric.getGameTotalFlow()));
      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 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<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<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 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(row.getOfficialRecharge(), row.getDealerRecharge()));
    row.setLuckyGiftProfit(safe(row.getLuckyGiftTotalFlow())
        .subtract(safe(row.getLuckyGiftPayout()))
        .subtract(safe(row.getLuckyGiftAnchorShare())));
    row.setGameProfit(safe(row.getGameTotalFlow()).subtract(safe(row.getGamePayout())));
  }

  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.setNewUserRecharge(add(total.getNewUserRecharge(), row.getNewUserRecharge()));
      total.setOfficialRecharge(add(total.getOfficialRecharge(), row.getOfficialRecharge()));
      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.setLuckyGiftPayout(add(total.getLuckyGiftPayout(), row.getLuckyGiftPayout()));
      total.setLuckyGiftAnchorShare(add(total.getLuckyGiftAnchorShare(), row.getLuckyGiftAnchorShare()));
      total.setGameTotalFlow(add(total.getGameTotalFlow(), row.getGameTotalFlow()));
      total.setGamePayout(add(total.getGamePayout(), row.getGamePayout()));
    }
    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 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 record MongoMetric(Long userId, String day, BigDecimal amount) {
  }

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