InsightsService.kt

package com.distasilucas.cryptobalancetracker.service

import com.distasilucas.cryptobalancetracker.constants.DATES_BALANCES_CACHE
import com.distasilucas.cryptobalancetracker.constants.HOME_INSIGHTS_RESPONSE_CACHE
import com.distasilucas.cryptobalancetracker.entity.Crypto
import com.distasilucas.cryptobalancetracker.entity.DateBalance
import com.distasilucas.cryptobalancetracker.entity.UserCrypto
import com.distasilucas.cryptobalancetracker.exception.ApiException
import com.distasilucas.cryptobalancetracker.model.DateRange
import com.distasilucas.cryptobalancetracker.model.response.insights.BalanceChanges
import com.distasilucas.cryptobalancetracker.model.response.insights.Balances
import com.distasilucas.cryptobalancetracker.model.response.insights.CryptoInfo
import com.distasilucas.cryptobalancetracker.model.response.insights.DateBalances
import com.distasilucas.cryptobalancetracker.model.response.insights.DatesBalanceResponse
import com.distasilucas.cryptobalancetracker.model.response.insights.DifferencesChanges
import com.distasilucas.cryptobalancetracker.model.response.insights.FiatBalance
import com.distasilucas.cryptobalancetracker.model.response.insights.HomeInsightsResponse
import com.distasilucas.cryptobalancetracker.model.response.insights.Price
import com.distasilucas.cryptobalancetracker.model.response.insights.PriceChange
import com.distasilucas.cryptobalancetracker.repository.DateBalanceRepository
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.cache.annotation.Cacheable
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.Clock
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.stream.LongStream

@Service
class InsightsService(
  private val userCryptoService: UserCryptoService,
  private val cryptoService: CryptoService,
  private val dateBalanceRepository: DateBalanceRepository,
  private val clock: Clock
) {

  private val logger = KotlinLogging.logger { }

  // TODO - Retrieve from Coingecko API
  private val stableCoinsIds = listOf("tether", "usd-coin", "ethena-usde", "dai", "first-digital-usd")

  @Cacheable(cacheNames = [HOME_INSIGHTS_RESPONSE_CACHE])
  fun retrieveHomeInsightsResponse(): HomeInsightsResponse {
    logger.info { "Retrieving home insights" }

    val userCryptos = userCryptoService.findAll()

    if (userCryptos.isEmpty()) {
      throw ApiException(HttpStatus.NOT_FOUND, "No user cryptos were found")
    }

    val userCryptoQuantity = getUserCryptoQuantity(userCryptos)
    val userCryptosIds = userCryptos.map { it.coingeckoCryptoId }.toSet()
    val cryptos = cryptoService.findAllByIds(userCryptosIds)
    val balances = getTotalBalances(cryptos, userCryptoQuantity)
    val stableCoins = cryptos.filter { stableCoinsIds.contains(it.id) }
    val userStableCoins = userCryptos.filter { stableCoinsIds.contains(it.coingeckoCryptoId) }
    val stableCoinsBalance = retrieveStableCoinsBalance(userStableCoins, stableCoins)
    val top24hGainer = with(cryptoService.findTopGainer24h(userCryptosIds)) {
      CryptoInfo(
        coingeckoCryptoId = id,
        symbol = ticker,
        image = image,
        price = Price(lastKnownPrice, lastKnownPriceInEUR),
        priceChange = PriceChange(changePercentageIn24h)
      )
    }

    return HomeInsightsResponse(balances, stableCoinsBalance, top24hGainer)
  }

  @Cacheable(cacheNames = [DATES_BALANCES_CACHE], key = "#dateRange")
  fun retrieveDatesBalances(dateRange: DateRange): DatesBalanceResponse {
    logger.info { "Retrieving balances for date range: $dateRange" }
    val now = LocalDate.now(clock)

    val dateBalances = when (dateRange) {
      DateRange.LAST_DAY -> now.retrieveDatesBalances(1)
      DateRange.THREE_DAYS -> now.retrieveDatesBalances(2)
      DateRange.ONE_WEEK -> now.retrieveDatesBalances(6)
      DateRange.ONE_MONTH -> retrieveDatesBalances(2, 4, now.minusMonths(1), now)
      DateRange.THREE_MONTHS -> retrieveDatesBalances(6, 5, now.minusMonths(3), now)
      DateRange.SIX_MONTHS -> retrieveDatesBalances(10, 6, now.minusMonths(6), now)
      DateRange.ONE_YEAR -> retrieveYearDatesBalances(now)
    }

    val datesBalances = dateBalances.map {
      val localDate = LocalDate.parse(it.date, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
      val formattedDate = localDate.format(DateTimeFormatter.ofPattern("d MMMM yyyy"))
      val fiatBalance = FiatBalance(it.usdBalance, it.eurBalance)

      DateBalances(formattedDate, Balances(fiatBalance, it.btcBalance))
    }.toList()

    logger.info { "Balances found: ${datesBalances.size}" }

    if (datesBalances.isEmpty()) {
      throw ApiException(HttpStatus.NO_CONTENT, "No balances found for range $dateRange")
    }

    val changesPair = changesPair(datesBalances)

    return DatesBalanceResponse(datesBalances, changesPair.first, changesPair.second)
  }

  fun getTotalBalances(cryptos: List<Crypto>, userCryptoQuantity: Map<String, BigDecimal>): Balances {
    val cryptosMap = cryptos.associateBy { it.id }
    var totalUSDBalance = BigDecimal.ZERO
    var totalBTCBalance = BigDecimal.ZERO
    var totalEURBalance = BigDecimal.ZERO

    userCryptoQuantity.forEach { (coingeckoCryptoId, quantity) ->
      val crypto = cryptosMap[coingeckoCryptoId] ?: throw ApiException(
        HttpStatus.NOT_FOUND,
        "No crypto with id $coingeckoCryptoId"
      )
      val lastKnownPrice = crypto.lastKnownPrice
      val lastKnownPriceInBTC = crypto.lastKnownPriceInBTC
      val lastKnownPriceInEUR = crypto.lastKnownPriceInEUR

      totalUSDBalance = totalUSDBalance.plus(lastKnownPrice.multiply(quantity))
      totalBTCBalance = totalBTCBalance.plus(lastKnownPriceInBTC.multiply(quantity))
      totalEURBalance = totalEURBalance.plus(lastKnownPriceInEUR.multiply(quantity))
    }

    return Balances(FiatBalance(totalUSDBalance, totalEURBalance), totalBTCBalance)
  }

  fun getCryptoTotalBalances(crypto: Crypto, quantity: BigDecimal): Balances {
    val fiatBalance = FiatBalance(
      crypto.lastKnownPrice.multiply(quantity),
      crypto.lastKnownPriceInEUR.multiply(quantity)
    )
    val btc = crypto.lastKnownPriceInBTC.multiply(quantity)

    return Balances(fiatBalance, btc)
  }

  fun calculatePercentage(totalBalance: String, balance: String) = BigDecimal(balance)
    .multiply(BigDecimal("100"))
    .divide(BigDecimal(totalBalance), 2, RoundingMode.HALF_UP)
    .toFloat()

  // Map<coingecko crypto id, user crypto quantity>
  fun getUserCryptoQuantity(userCryptos: List<UserCrypto>) = userCryptos.groupBy { it.coingeckoCryptoId }
    .mapValues { (_, userCryptos) -> userCryptos.sumOf { it.quantity } }

  private fun LocalDate.retrieveDatesBalances(minusDays: Long): List<DateBalance> {
    val from = this.minusDays(minusDays)
    logger.info { "Retrieving date balances from $from to $this" }

    return dateBalanceRepository.findDateBalancesByInclusiveDateBetween(from.toString(), this.toString())
  }

  private fun retrieveDatesBalances(
    daysSubtraction: Long, minRequired: Int,
    from: LocalDate, to: LocalDate
  ): List<DateBalance> {
    val dates = mutableListOf<String>()
    var toDate = to

    while (from.isBefore(toDate)) {
      dates.add(toDate.toString())
      toDate = toDate.minusDays(daysSubtraction)
    }

    logger.info { "Searching balances for dates $dates" }

    val datesBalances = dateBalanceRepository.findAllByDateIn(dates)
    logger.info { "Found balances for dates ${datesBalances.map { it.date }}" }

    return if (datesBalances.size >= minRequired) {
      datesBalances
    } else {
      retrieveLastTwelveDaysBalances()
    }
  }

  private fun retrieveYearDatesBalances(now: LocalDate): List<DateBalance> {
    val dates = mutableListOf<String>()
    dates.add(now.toString())

    LongStream.range(1, 12)
      .forEach { dates.add(now.minusMonths(it).toString()) }

    logger.info { "Searching balances for dates $dates" }

    val datesBalances = dateBalanceRepository.findAllByDateIn(dates)

    return if (datesBalances.size > 3) {
      datesBalances
    } else {
      retrieveLastTwelveDaysBalances()
    }
  }

  private fun changesPair(dateBalances: List<DateBalances>): Pair<BalanceChanges, DifferencesChanges> {
    val usdChange = getChange(BalanceType.USD_BALANCE, dateBalances)
    val eurChange = getChange(BalanceType.EUR_BALANCE, dateBalances)
    val btcChange = getChange(BalanceType.BTC_BALANCE, dateBalances)

    return Pair(
      BalanceChanges(usdChange.first, eurChange.first, btcChange.first),
      DifferencesChanges(usdChange.second, eurChange.second, btcChange.second)
    )
  }

  private fun retrieveStableCoinsBalance(userStableCoins: List<UserCrypto>, stableCoins: List<Crypto>): String {
    val userCryptoQuantity = getUserCryptoQuantity(userStableCoins)
    val totalBalances = getTotalBalances(stableCoins, userCryptoQuantity)

    return totalBalances.usd()
  }

  private fun getChange(balanceType: BalanceType, dateBalances: List<DateBalances>): Pair<Float, String> {
    val newestValues = dateBalances.last().balances
    val oldestValues = dateBalances.first().balances
    val divisionScale = if (BalanceType.BTC_BALANCE == balanceType) 10 else 4

    val values = when (balanceType) {
      BalanceType.USD_BALANCE -> Pair(BigDecimal(oldestValues.usd()), BigDecimal(newestValues.usd()))
      BalanceType.EUR_BALANCE -> Pair(BigDecimal(oldestValues.eur()), BigDecimal(newestValues.eur()))
      BalanceType.BTC_BALANCE -> Pair(BigDecimal(oldestValues.btc), BigDecimal(newestValues.btc))
    }

    val newestValue = values.second
    val oldestValue = values.first

    val change = newestValue
      .subtract(oldestValue)
      .divide(oldestValue, divisionScale, RoundingMode.HALF_UP)
      .multiply(BigDecimal("100"))
      .setScale(2, RoundingMode.HALF_UP)
      .toFloat()
    val difference = newestValue.subtract(oldestValue).toPlainString()

    return Pair(change, difference)
  }

  private fun retrieveLastTwelveDaysBalances(): List<DateBalance> {
    val to = LocalDate.now(clock)
    val from = to.minusDays(12)
    logger.info { "Not enough balances. Retrieving balances for the last twelve days from $from to $to" }

    return dateBalanceRepository.findDateBalancesByInclusiveDateBetween(from.toString(), to.toString())
  }
}

enum class BalanceType {
  USD_BALANCE,
  EUR_BALANCE,
  BTC_BALANCE
}