package com.github.retro_game.retro_game.service.impl;

import com.github.retro_game.retro_game.cache.BodyInfoCache;
import com.github.retro_game.retro_game.cache.CacheObserver;
import com.github.retro_game.retro_game.cache.UserBodiesCache;
import com.github.retro_game.retro_game.dto.*;
import com.github.retro_game.retro_game.entity.*;
import com.github.retro_game.retro_game.repository.BodyRepository;
import com.github.retro_game.retro_game.repository.UserRepository;
import com.github.retro_game.retro_game.security.CustomUser;
import com.github.retro_game.retro_game.service.exception.*;
import io.vavr.Tuple;
import io.vavr.Tuple2;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert;

import javax.annotation.PostConstruct;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;

@Service("bodyService")
class BodyServiceImpl implements BodyServiceInternal {
  private static final Logger logger = LoggerFactory.getLogger(BodyServiceImpl.class);
  private final int homeworldDiameter;
  private final int productionSpeed;
  private final int metalBaseProduction;
  private final int crystalBaseProduction;
  private final int deuteriumBaseProduction;
  private final int metalMineBaseProduction;
  private final int crystalMineBaseProduction;
  private final int deuteriumSynthesizerBaseProduction;
  private final int metalMineBaseEnergyUsage;
  private final int crystalMineBaseEnergyUsage;
  private final int deuteriumSynthesizerBaseEnergyUsage;
  private final int solarPlantBaseEnergyProduction;
  private final int fusionReactorBaseEnergyProduction;
  private final int fusionReactorBaseDeuteriumUsage;
  private final int fieldsPerTerraformerLevel;
  private final int fieldsPerLunarBaseLevel;
  private final CacheObserver cacheObserver;
  private final BodyInfoCache bodyInfoCache;
  private final UserBodiesCache userBodiesCache;
  private final BodyRepository bodyRepository;
  private final UserRepository userRepository;
  private BuildingsServiceInternal buildingsServiceInternal;
  private FlightServiceInternal flightServiceInternal;
  private ShipyardServiceInternal shipyardServiceInternal;
  private UserServiceInternal userServiceInternal;

  public BodyServiceImpl(@Value("${retro-game.homeworld-diameter}") int homeworldDiameter,
                         @Value("${retro-game.production-speed}") int productionSpeed,
                         @Value("${retro-game.metal-base-production}") int metalBaseProduction,
                         @Value("${retro-game.crystal-base-production}") int crystalBaseProduction,
                         @Value("${retro-game.deuterium-base-production}") int deuteriumBaseProduction,
                         @Value("${retro-game.metal-mine-base-production}") int metalMineBaseProduction,
                         @Value("${retro-game.crystal-mine-base-production}") int crystalMineBaseProduction,
                         @Value("${retro-game.deuterium-synthesizer-base-production}") int deuteriumSynthesizerBaseProduction,
                         @Value("${retro-game.metal-mine-base-energy-usage}") int metalMineBaseEnergyUsage,
                         @Value("${retro-game.crystal-mine-base-energy-usage}") int crystalMineBaseEnergyUsage,
                         @Value("${retro-game.deuterium-synthesizer-base-energy-usage}") int deuteriumSynthesizerBaseEnergyUsage,
                         @Value("${retro-game.solar-plant-base-energy-production}") int solarPlantBaseEnergyProduction,
                         @Value("${retro-game.fusion-reactor-base-energy-production}") int fusionReactorBaseEnergyProduction,
                         @Value("${retro-game.fusion-reactor-base-deuterium-usage}") int fusionReactorBaseDeuteriumUsage,
                         @Value("${retro-game.fields-per-terraformer-level}") int fieldsPerTerraformerLevel,
                         @Value("${retro-game.fields-per-lunar-base-level}") int fieldsPerLunarBaseLevel,
                         CacheObserver cacheObserver,
                         BodyInfoCache bodyInfoCache,
                         UserBodiesCache userBodiesCache,
                         BodyRepository bodyRepository,
                         UserRepository userRepository) {
    this.homeworldDiameter = homeworldDiameter;
    this.productionSpeed = productionSpeed;
    this.metalBaseProduction = metalBaseProduction;
    this.crystalBaseProduction = crystalBaseProduction;
    this.deuteriumBaseProduction = deuteriumBaseProduction;
    this.metalMineBaseProduction = metalMineBaseProduction;
    this.crystalMineBaseProduction = crystalMineBaseProduction;
    this.deuteriumSynthesizerBaseProduction = deuteriumSynthesizerBaseProduction;
    this.metalMineBaseEnergyUsage = metalMineBaseEnergyUsage;
    this.crystalMineBaseEnergyUsage = crystalMineBaseEnergyUsage;
    this.deuteriumSynthesizerBaseEnergyUsage = deuteriumSynthesizerBaseEnergyUsage;
    this.solarPlantBaseEnergyProduction = solarPlantBaseEnergyProduction;
    this.fusionReactorBaseEnergyProduction = fusionReactorBaseEnergyProduction;
    this.fusionReactorBaseDeuteriumUsage = fusionReactorBaseDeuteriumUsage;
    this.fieldsPerTerraformerLevel = fieldsPerTerraformerLevel;
    this.fieldsPerLunarBaseLevel = fieldsPerLunarBaseLevel;
    this.cacheObserver = cacheObserver;
    this.bodyInfoCache = bodyInfoCache;
    this.userBodiesCache = userBodiesCache;
    this.bodyRepository = bodyRepository;
    this.userRepository = userRepository;
  }

  @Autowired
  public void setBuildingsServiceInternal(BuildingsServiceInternal buildingsServiceInternal) {
    this.buildingsServiceInternal = buildingsServiceInternal;
  }

  @Autowired
  public void setFlightServiceInternal(FlightServiceInternal flightServiceInternal) {
    this.flightServiceInternal = flightServiceInternal;
  }

  @Autowired
  public void setShipyardServiceInternal(ShipyardServiceInternal shipyardServiceInternal) {
    this.shipyardServiceInternal = shipyardServiceInternal;
  }

  @Autowired
  public void setUserServiceInternal(UserServiceInternal userServiceInternal) {
    this.userServiceInternal = userServiceInternal;
  }

  @PostConstruct
  private void checkProperties() {
    Assert.isTrue(homeworldDiameter > 0,
        "retro-game.homeworld-diameter must be greater than 0");

    Assert.isTrue(productionSpeed >= 1,
        "retro-game.production-speed must be at least 1");

    Assert.isTrue(metalBaseProduction >= 0,
        "retro-game.metal-base-production must be at least 0");
    Assert.isTrue(crystalBaseProduction >= 0,
        "retro-game.crystal-base-production must be at least 0");
    Assert.isTrue(deuteriumBaseProduction >= 0,
        "retro-game.deuterium-base-production must be at least 0");

    Assert.isTrue(metalMineBaseProduction > 0,
        "retro-game.metal-mine-base-production must be greater than 0");
    Assert.isTrue(crystalMineBaseProduction > 0,
        "retro-game.crystal-mine-base-production must be greater than 0");
    Assert.isTrue(deuteriumSynthesizerBaseProduction > 0,
        "retro-game.deuterium-synthesizer-base-production must be greater than 0");

    Assert.isTrue(metalMineBaseEnergyUsage >= 0,
        "retro-game.metal-mine-base-production must be at least 0");
    Assert.isTrue(crystalMineBaseEnergyUsage >= 0,
        "retro-game.crystal-mine-base-production must be at least 0");
    Assert.isTrue(deuteriumSynthesizerBaseEnergyUsage >= 0,
        "retro-game.deuterium-synthesizer-base-production must be at least 0");

    Assert.isTrue(solarPlantBaseEnergyProduction > 0,
        "retro-game.solar-plant-base-energy-production must be greater than 0");
    Assert.isTrue(fusionReactorBaseEnergyProduction > 0,
        "retro-game.fusion-reactor-base-energy-production must be greater than 0");
    Assert.isTrue(fusionReactorBaseDeuteriumUsage >= 0,
        "retro-game.fusion-reactor-base-deuterium-usage must be at least 0");

    Assert.isTrue(fieldsPerTerraformerLevel > 1,
        "retro-game.fields-per-terraformer-level must be greater than 1");
    Assert.isTrue(fieldsPerLunarBaseLevel > 1,
        "retro-game.fields-per-lunar-base-level must be greater than 1");
  }

  @Override
  @Transactional(isolation = Isolation.SERIALIZABLE)
  public long createHomeworld(int galaxy, int system, int position) {
    long userId = CustomUser.getCurrentUserId();
    User user = userRepository.getOne(userId);

    if (!user.getBodies().isEmpty()) {
      logger.warn("Creating homeworld failed, homeworld exists: userId={}", userId);
      throw new HomeworldExistsException();
    }

    Coordinates coordinates = new Coordinates(galaxy, system, position, CoordinatesKind.PLANET);

    if (bodyRepository.existsByCoordinates(coordinates)) {
      logger.warn("Creating homeworld failed, body exists: userId={}, coordinates={}", userId, coordinates);
      throw new BodyExistsException();
    }

    logger.info("Creating homeworld: userId={}, coordinates={}", userId, coordinates);
    Date now = Date.from(Instant.ofEpochSecond(Instant.now().getEpochSecond()));
    Body body = new Body();
    body.setUser(user);
    body.setCoordinates(coordinates);
    body.setName("Homeworld");
    body.setCreatedAt(now);
    body.setUpdatedAt(now);
    body.setDiameter(homeworldDiameter);
    body.setTemperature(generateTemperature(position));
    body.setType(generatePlanetType(coordinates.getPosition()));
    body.setImage(ThreadLocalRandom.current().nextInt(1, 11));
    body.setResources(new Resources(1000.0, 500.0, 0.0));
    body.setProductionFactors(new ProductionFactors());
    body.setBuildings(Collections.emptyMap());
    body.setUnits(Collections.emptyMap());
    body = bodyRepository.save(body);
    var bodyId = body.getId();

    cacheObserver.notifyBodyCreated(userId);

    return bodyId;
  }

  @Override
  public Body createColony(User user, Coordinates coordinates, Date at) {
    assert coordinates.getKind() == CoordinatesKind.PLANET;

    Body body = new Body();
    body.setUser(user);
    body.setCoordinates(coordinates);
    body.setName("Colony");
    body.setCreatedAt(at);
    body.setUpdatedAt(at);
    body.setDiameter(generatePlanetDiameter(coordinates.getPosition()));
    body.setTemperature(generateTemperature(coordinates.getPosition()));
    body.setType(generatePlanetType(coordinates.getPosition()));
    body.setImage(ThreadLocalRandom.current().nextInt(1, 11));
    body.setResources(new Resources(1000.0, 500.0, 0.0));
    body.setProductionFactors(new ProductionFactors());
    body.setBuildings(Collections.emptyMap());
    body.setUnits(Collections.emptyMap());
    body = bodyRepository.save(body);

    cacheObserver.notifyBodyCreated(user.getId());

    return body;
  }

  @Override
  public Body createMoon(User user, Coordinates coordinates, Date at, double chance) {
    assert coordinates.getKind() == CoordinatesKind.MOON;
    assert chance >= 0.01 && chance <= 0.2;

    Body body = new Body();
    body.setUser(user);
    body.setCoordinates(coordinates);
    body.setName("Moon");
    body.setCreatedAt(at);
    body.setUpdatedAt(at);
    body.setDiameter(generateMoonDiameter(chance));
    body.setTemperature(generateTemperature(coordinates.getPosition()));
    body.setType(BodyType.MOON);
    body.setImage(1);
    body.setResources(new Resources());
    body.setProductionFactors(new ProductionFactors());
    body.setLastJumpAt(at);
    body.setBuildings(Collections.emptyMap());
    body.setUnits(Collections.emptyMap());
    body = bodyRepository.save(body);

    cacheObserver.notifyBodyCreated(user.getId());

    return body;
  }

  private BodyType generatePlanetType(int position) {
    switch (position) {
      case 1:
      case 2:
      case 3:
        return ThreadLocalRandom.current().nextInt(0, 2) == 0 ? BodyType.DRY : BodyType.DESERT;
      case 4:
      case 5:
      case 6:
        return BodyType.JUNGLE;
      case 7:
      case 8:
        return BodyType.NORMAL;
      case 9:
        return ThreadLocalRandom.current().nextInt(0, 2) == 0 ? BodyType.NORMAL : BodyType.WATER;
      case 10:
      case 11:
      case 12:
        return BodyType.WATER;
      case 13:
        return BodyType.ICE;
      case 14:
      case 15:
        return ThreadLocalRandom.current().nextInt(0, 2) == 0 ? BodyType.ICE : BodyType.GAS;
      default:
        throw new IllegalArgumentException();
    }
  }

  private int generatePlanetDiameter(int position) {
    ThreadLocalRandom random = ThreadLocalRandom.current();
    double x = Math.abs(8 - position);
    double mean = 200.0 - 10.0 * x;
    double sd = 60.0 - 5.0 * x;
    double numFields = mean + sd * random.nextGaussian();
    numFields = Math.max(numFields, 42.0);
    return (int) (Math.sqrt(numFields) * 100.0) * 10;
  }

  private int generateMoonDiameter(double chance) {
    assert chance >= 0.01 && chance <= 0.2;
    int r = ThreadLocalRandom.current().nextInt(10, 20 + 1);
    return (int) (1000.0 * Math.sqrt(r + 3 * (int) (100.0 * chance)));
  }

  private int generateTemperature(int position) {
    ThreadLocalRandom random = ThreadLocalRandom.current();
    double x = 8 - position;
    double mean = 30.0 + 1.75 * Math.signum(x) * x * x;
    int temperature = (int) (mean + 10.0 * random.nextGaussian());
    return Math.max(-60, Math.min(120, temperature));
  }

  @Override
  public int getUsedFields(Body body) {
    return body.getBuildings().values().stream().mapToInt(Integer::intValue).sum();
  }

  @Override
  public int getMaxFields(Body body) {
    if (body.getCoordinates().getKind() == CoordinatesKind.PLANET) {
      int level = body.getBuildingLevel(BuildingKind.TERRAFORMER);
      return getPlanetMaxFields(body.getDiameter(), level);
    } else {
      assert body.getCoordinates().getKind() == CoordinatesKind.MOON;
      int level = body.getBuildingLevel(BuildingKind.LUNAR_BASE);
      return getMoonMaxFields(level);
    }
  }

  @Override
  public int getMaxFields(Body body, Map<BuildingKind, Integer> buildings) {
    if (body.getCoordinates().getKind() == CoordinatesKind.PLANET) {
      int level = buildings.getOrDefault(BuildingKind.TERRAFORMER, 0);
      return getPlanetMaxFields(body.getDiameter(), level);
    } else {
      assert body.getCoordinates().getKind() == CoordinatesKind.MOON;
      int level = buildings.getOrDefault(BuildingKind.LUNAR_BASE, 0);
      return getMoonMaxFields(level);
    }
  }

  private int getPlanetMaxFields(int diameter, int terraformerLevel) {
    assert diameter > 0;
    float x = diameter / 1000.0f;
    return (int) (x * x) + fieldsPerTerraformerLevel * terraformerLevel;
  }

  private int getMoonMaxFields(int lunarBaseLevel) {
    return 1 + fieldsPerLunarBaseLevel * lunarBaseLevel;
  }

  @Override
  public int getTemperature(long bodyId) {
    Body body = bodyRepository.findById(bodyId).orElseThrow(BodyDoesntExistException::new);
    return body.getTemperature();
  }

  @Override
  public BodyInfoDto getBodyBasicInfo(long bodyId) {
    return bodyInfoCache.get(bodyId);
  }

  @Override
  public List<BodyInfoDto> getBodiesBasicInfo(long bodyId) {
    var userId = CustomUser.getCurrentUserId();
    User user = userRepository.getOne(userId);

    var bodiesIds = userBodiesCache.get(userId);
    var bodies = new ArrayList<>(bodyInfoCache.getAll(bodiesIds).values());

    var keyExtractors = new BodyKeyExtractors<>(BodyInfoDto::getId, BodyInfoDto::getCoordinates, BodyInfoDto::getName);
    sort(bodies, keyExtractors, user);

    return bodies;
  }

  @Override
  public BodyTypeAndImagePairDto getBodyTypeAndImagePair(long bodyId) {
    Body body = bodyRepository.findById(bodyId).orElseThrow(BodyDoesntExistException::new);
    return new BodyTypeAndImagePairDto(Converter.convert(body.getType()), body.getImage());
  }

  @Override
  public BodiesPointersDto getBodiesPointers(long bodyId) {
    BodyInfoDto prev = null;
    BodyInfoDto next = null;
    Iterator<BodyInfoDto> it = getBodiesBasicInfo(bodyId).iterator();
    assert it.hasNext();
    BodyInfoDto cur = it.next();
    while (cur != null) {
      next = it.hasNext() ? it.next() : null;
      if (cur.getId() == bodyId) {
        break;
      }
      prev = cur;
      cur = next;
    }
    return new BodiesPointersDto(prev, next);
  }

  @Override
  @Transactional(readOnly = true)
  public OverviewBodiesDto getOverviewBodies(long bodyId) {
    long userId = CustomUser.getCurrentUserId();
    User user = userRepository.getOne(userId);

    Map<Long, Body> bodies = user.getBodies();

    OverviewBodyInfoDto selectedInfo;
    Body selected = bodies.get(bodyId);
    {
      BuildingKindDto kind = null;
      int level = 0;
      Optional<OngoingBuildingDto> ongoingBuildingOptional = buildingsServiceInternal.getOngoingBuilding(selected);
      if (ongoingBuildingOptional.isPresent()) {
        OngoingBuildingDto ongoingBuilding = ongoingBuildingOptional.get();
        kind = Converter.convert(ongoingBuilding.getKind());
        level = ongoingBuilding.getLevel();
      }

      Date finishAt = buildingsServiceInternal.getOngoingBuildingFinishAt(selected).orElse(null);

      int usedFields = getUsedFields(selected);
      int maxFields = getMaxFields(selected);

      selectedInfo = new OverviewBodyInfoDto(selected.getId(), Converter.convert(selected.getCoordinates()),
          selected.getName(), selected.getDiameter(), selected.getTemperature(), Converter.convert(selected.getType()),
          selected.getImage(), kind, level, finishAt, usedFields, maxFields);
    }

    // Find associated body, i.e. planet / moon with the same coordinates except for the kind, which is typically
    // displayed on the left side in overview.
    Coordinates selectedCoords = selected.getCoordinates();
    Optional<Body> associatedOptional = bodies.values().stream()
        .filter(b -> {
          Coordinates coords = b.getCoordinates();
          return coords.getGalaxy() == selectedCoords.getGalaxy() &&
              coords.getSystem() == selectedCoords.getSystem() &&
              coords.getPosition() == selectedCoords.getPosition() &&
              coords.getKind() != selectedCoords.getKind();
        })
        .findFirst();

    // All other planets.
    List<Body> otherBodies = bodies.entrySet().stream()
        .filter(entry -> entry.getKey() != bodyId &&
            (!associatedOptional.isPresent() || entry.getKey() != associatedOptional.get().getId()) &&
            entry.getValue().getCoordinates().getKind() == CoordinatesKind.PLANET)
        .map(Map.Entry::getValue)
        .collect(Collectors.toList());

    // Put associated body into otherBodies, so that we can generate required information without code duplication.
    associatedOptional.ifPresent(otherBodies::add);

    // Generate info for other planets and the associated body (if exists).
    List<OverviewBodyBasicInfoDto> basicInfo = new ArrayList<>(otherBodies.size());
    for (Body body : otherBodies) {
      BuildingKindDto kind = null;
      int level = 0;
      Optional<OngoingBuildingDto> ongoingBuildingOptional = buildingsServiceInternal.getOngoingBuilding(body);
      if (ongoingBuildingOptional.isPresent()) {
        OngoingBuildingDto ongoingBuilding = ongoingBuildingOptional.get();
        kind = Converter.convert(ongoingBuilding.getKind());
        level = ongoingBuilding.getLevel();
      }

      basicInfo.add(new OverviewBodyBasicInfoDto(body.getId(), Converter.convert(body.getCoordinates()), body.getName(),
          Converter.convert(body.getType()), body.getImage(), kind, level));
    }

    // The associated body info is at the end, remove it from the rest.
    OverviewBodyBasicInfoDto associatedInfo =
        associatedOptional.isPresent() ? basicInfo.remove(basicInfo.size() - 1) : null;

    // Sort other bodies.
    BodyKeyExtractors<OverviewBodyBasicInfoDto> keyExtractors = new BodyKeyExtractors<>(OverviewBodyBasicInfoDto::getId,
        OverviewBodyBasicInfoDto::getCoordinates, OverviewBodyBasicInfoDto::getName);
    sort(basicInfo, keyExtractors, user);

    return new OverviewBodiesDto(selectedInfo, associatedInfo, basicInfo);
  }

  @Override
  @Transactional(readOnly = true)
  public EmpireDto getEmpire(long bodyId, @Nullable Integer galaxy, @Nullable Integer system,
                             @Nullable Integer position, @Nullable CoordinatesKindDto kind) {
    long userId = CustomUser.getCurrentUserId();
    User user = userRepository.getOne(userId);

    CoordinatesKind k = kind != null ? Converter.convert(kind) : null;

    List<Body> bodies = bodyRepository.findByUserForEmpire(user, galaxy, system, position, k);

    // Selected bodies.
    Date now = Date.from(Instant.ofEpochSecond(Instant.now().getEpochSecond()));
    List<EmpireBodyDto> empireBodies = bodies.stream()
        .map(body -> {
          updateResources(body, now);

          int usedFields = getUsedFields(body);
          int maxFields = getMaxFields(body);

          ResourcesDto res = Converter.convert(body.getResources());
          long totalRes = (long) (res.getMetal() + res.getCrystal() + res.getDeuterium());
          Tuple2<ResourcesDto, Long> resources = Tuple.of(res, totalRes);

          ProductionDto production = getProduction(body);

          int availableEnergy = production.getAvailableEnergy();
          int totalEnergy = production.getTotalEnergy();

          double m = production.getMetalProduction();
          double c = production.getCrystalProduction();
          double d = production.getDeuteriumProduction();

          ResourcesDto hourly = new ResourcesDto(m, c, d);
          long hourlyTotal = (long) (m + c + d);
          Tuple2<ResourcesDto, Long> productionHourly = Tuple.of(hourly, hourlyTotal);

          ResourcesDto daily = new ResourcesDto(24 * m, 24 * c, 24 * d);
          long dailyTotal = 24 * (long) (m + c + d);
          Tuple2<ResourcesDto, Long> productionDaily = Tuple.of(daily, dailyTotal);

          ResourcesDto weekly = new ResourcesDto(24 * 7 * m, 24 * 7 * c, 24 * 7 * d);
          long weeklyTotal = 24 * 7 * (long) (m + c + d);
          Tuple2<ResourcesDto, Long> productionWeekly = Tuple.of(weekly, weeklyTotal);

          ResourcesDto _30days = new ResourcesDto(24 * 30 * m, 24 * 30 * c, 24 * 30 * d);
          long _30daysTotal = 24 * 30 * (long) (m + c + d);
          Tuple2<ResourcesDto, Long> production30days = Tuple.of(_30days, _30daysTotal);

          ResourcesDto cap = getCapacity(body);
          long totalCap = (long) (cap.getMetal() + cap.getCrystal() + cap.getDeuterium());
          Tuple2<ResourcesDto, Long> capacity = Tuple.of(cap, totalCap);

          Map<BuildingKindDto, Tuple2<Integer, Integer>> buildings = Converter.convertToEnumMap(
              buildingsServiceInternal.getCurrentAndFutureLevels(body), BuildingKindDto.class, Converter::convert,
              Function.identity());

          Map<UnitKindDto, Tuple2<Integer, Integer>> units = Converter.convertToEnumMap(
              shipyardServiceInternal.getCurrentAndFutureCounts(body), UnitKindDto.class, Converter::convert,
              Function.identity());

          return new EmpireBodyDto(body.getId(), body.getName(), Converter.convert(body.getCoordinates()),
              Converter.convert(body.getType()), body.getImage(), body.getDiameter(), usedFields, maxFields,
              body.getTemperature(), resources, availableEnergy, totalEnergy, productionHourly, productionDaily,
              productionWeekly, production30days, capacity, buildings, units);
        })
        .collect(Collectors.toList());

    // Total.
    Supplier<Tuple2<ResourcesDto, Long>> resTuplesId = () -> Tuple.of(new ResourcesDto(0.0, 0.0, 0.0), 0L);
    BinaryOperator<Tuple2<ResourcesDto, Long>> resTuplesAcc = (acc, elem) -> {
      ResourcesDto a = acc._1;
      ResourcesDto b = elem._1;
      ResourcesDto r = new ResourcesDto(
          a.getMetal() + b.getMetal(),
          a.getCrystal() + b.getCrystal(),
          a.getDeuterium() + b.getDeuterium());
      return Tuple.of(r, acc._2 + elem._2);
    };
    EmpireSummaryDto<Long> total = new EmpireSummaryDto<>(
        empireBodies.stream().mapToLong(EmpireBodyDto::getDiameter).sum(),
        empireBodies.stream().mapToLong(EmpireBodyDto::getUsedFields).sum(),
        empireBodies.stream().mapToLong(EmpireBodyDto::getMaxFields).sum(),
        empireBodies.stream().mapToLong(EmpireBodyDto::getTemperature).sum(),
        empireBodies.stream().map(EmpireBodyDto::getResources).reduce(resTuplesId.get(), resTuplesAcc),
        empireBodies.stream().mapToLong(EmpireBodyDto::getAvailableEnergy).sum(),
        empireBodies.stream().mapToLong(EmpireBodyDto::getTotalEnergy).sum(),
        empireBodies.stream().map(EmpireBodyDto::getProductionHourly).reduce(resTuplesId.get(), resTuplesAcc),
        empireBodies.stream().map(EmpireBodyDto::getProductionDaily).reduce(resTuplesId.get(), resTuplesAcc),
        empireBodies.stream().map(EmpireBodyDto::getProductionWeekly).reduce(resTuplesId.get(), resTuplesAcc),
        empireBodies.stream().map(EmpireBodyDto::getProduction30days).reduce(resTuplesId.get(), resTuplesAcc),
        empireBodies.stream().map(EmpireBodyDto::getCapacity).reduce(resTuplesId.get(), resTuplesAcc),
        empireBodies.stream().flatMap(body -> body.getBuildings().entrySet().stream())
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                e -> Tuple.of((long) e.getValue()._1, (long) e.getValue()._2),
                (a, b) -> Tuple.of(a._1 + b._1, a._2 + b._2),
                () -> new EnumMap<>(BuildingKindDto.class)
            )),
        empireBodies.stream().flatMap(body -> body.getUnits().entrySet().stream())
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                e -> Tuple.of((long) e.getValue()._1, (long) e.getValue()._2),
                (a, b) -> Tuple.of(a._1 + b._1, a._2 + b._2),
                () -> new EnumMap<>(UnitKindDto.class)
            )));

    // Average.
    final int size = empireBodies.size();
    assert size > 0;
    Function<Tuple2<ResourcesDto, Long>, Tuple2<ResourcesDto, Double>> resTuplesAvg = (t) -> {
      ResourcesDto r = t._1;
      return Tuple.of(
          new ResourcesDto(r.getMetal() / size, r.getCrystal() / size, r.getDeuterium() / size),
          (double) t._2 / size);
    };
    EmpireSummaryDto<Double> average = new EmpireSummaryDto<>(
        (double) total.getDiameter() / size,
        (double) total.getUsedFields() / size,
        (double) total.getMaxFields() / size,
        (double) total.getTemperature() / size,
        resTuplesAvg.apply(total.getResources()),
        (double) total.getAvailableEnergy() / size,
        (double) total.getTotalEnergy() / size,
        resTuplesAvg.apply(total.getProductionHourly()),
        resTuplesAvg.apply(total.getProductionDaily()),
        resTuplesAvg.apply(total.getProductionWeekly()),
        resTuplesAvg.apply(total.getProduction30days()),
        resTuplesAvg.apply(total.getCapacity()),
        total.getBuildings().entrySet().stream()
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                e -> Tuple.of((double) e.getValue()._1 / size, (double) e.getValue()._2 / size),
                (a, b) -> {
                  throw new IllegalStateException();
                },
                () -> new EnumMap<>(BuildingKindDto.class)
            )),
        total.getUnits().entrySet().stream()
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                e -> Tuple.of((double) e.getValue()._1 / size, (double) e.getValue()._2 / size),
                (a, b) -> {
                  throw new IllegalStateException();
                },
                () -> new EnumMap<>(UnitKindDto.class)
            )));

    // Sort.
    BodyKeyExtractors<EmpireBodyDto> keyExtractors = new BodyKeyExtractors<>(EmpireBodyDto::getId,
        EmpireBodyDto::getCoordinates, EmpireBodyDto::getName);
    sort(empireBodies, keyExtractors, user);

    return new EmpireDto(empireBodies, total, average);
  }

  private static <T> void sort(List<T> bodies, BodyKeyExtractors<T> extractors, User user) {
    Comparator<T> comparator;
    switch (user.getBodiesSortOrder()) {
      case COORDINATES:
        comparator = Comparator.comparing(extractors.getCoordinatesExtractor());
        break;
      case NAME:
        comparator = Comparator.comparing(extractors.getNameExtractor());
        break;
      default:
        assert user.getBodiesSortOrder() == BodiesSortOrder.EMERGENCE;
        comparator = Comparator.comparing(extractors.getIdExtractor());
    }
    if (user.getBodiesSortDirection() == Sort.Direction.DESC) {
      comparator = comparator.reversed();
    }

    if (!user.hasFlag(UserFlag.STICKY_MOONS)) {
      bodies.sort(comparator);
      return;
    }

    // Sticky moons, i.e. a moon will be always after the associated planet.

    // Partition by kind.
    Function<? super T, ? extends CoordinatesDto> coordinatesExtractor = extractors.getCoordinatesExtractor();
    Map<Boolean, ArrayList<T>> partition = bodies.stream()
        .collect(
            Collectors.partitioningBy(
                b -> coordinatesExtractor.apply(b).getKind() == CoordinatesKindDto.PLANET,
                Collectors.toCollection(ArrayList::new)));
    ArrayList<T> moons = partition.get(false);
    ArrayList<T> planets = partition.get(true);

    // Sort planets using user specified comparator.
    planets.sort(comparator);

    bodies.clear();
    for (T planet : planets) {
      bodies.add(planet);
      // Add the associated moon, if any.
      CoordinatesDto planetCoords = coordinatesExtractor.apply(planet);
      assert planetCoords.getKind() == CoordinatesKindDto.PLANET;
      CoordinatesDto moonCoords = new CoordinatesDto(planetCoords.getGalaxy(), planetCoords.getSystem(),
          planetCoords.getPosition(), CoordinatesKindDto.MOON);
      moons.stream()
          .filter(m -> coordinatesExtractor.apply(m).equals(moonCoords))
          .findFirst()
          .ifPresent(bodies::add);
    }
  }

  @Override
  @Transactional(readOnly = true)
  public Body getUpdated(long bodyId) {
    Body body = bodyRepository.getOne(bodyId);
    updateResources(body, null);
    return body;
  }

  @Override
  @Transactional(readOnly = true)
  public ResourcesDto getResources(long bodyId) {
    Body body = getUpdated(bodyId);
    return Converter.convert(body.getResources());
  }

  @Override
  public void updateResources(Body body, Date at) {
    if (at == null) {
      at = Date.from(Instant.ofEpochSecond(Instant.now().getEpochSecond()));
    }

    Date updatedAt = body.getUpdatedAt();

    if (!at.after(updatedAt)) {
      return;
    }

    if (body.getCoordinates().getKind() == CoordinatesKind.PLANET &&
        !userServiceInternal.isOnVacation(body.getUser())) {
      Resources resources = body.getResources();
      ProductionDto production = getProduction(body);
      ResourcesDto capacity = getCapacity(body);
      double seconds = at.toInstant().getEpochSecond() - updatedAt.toInstant().getEpochSecond();

      // Metal.
      double metalProduced = 0.0;
      if (production.getMetalProduction() != 0) {
        double metalCapacity = capacity.getMetal() - resources.getMetal();
        double fullProductionFor = Math.min(seconds, Math.max(0.0, metalCapacity *
            3600.0 / production.getMetalProduction()));
        metalProduced = (production.getMetalProduction() * fullProductionFor +
            production.getMetalBaseProduction() * (seconds - fullProductionFor)) / 3600.0;
      }

      // Crystal.
      double crystalProduced = 0.0;
      if (production.getCrystalProduction() != 0) {
        double crystalCapacity = capacity.getCrystal() - resources.getCrystal();
        double fullProductionFor = Math.min(seconds, Math.max(0.0, crystalCapacity *
            3600.0 / production.getCrystalProduction()));
        crystalProduced = (production.getCrystalProduction() * fullProductionFor +
            production.getCrystalBaseProduction() * (seconds - fullProductionFor)) / 3600.0;
      }

      // Deuterium.
      double deuteriumProduced = 0.0;
      if (production.getDeuteriumProduction() > 0) {
        double deuteriumCapacity = capacity.getDeuterium() - resources.getDeuterium();
        double fullProductionFor = Math.min(seconds, Math.max(0.0, deuteriumCapacity *
            3600.0 / production.getDeuteriumProduction()));
        deuteriumProduced = (production.getDeuteriumProduction() * fullProductionFor +
            production.getDeuteriumBaseProduction() * (seconds - fullProductionFor)) / 3600.0;
      } else if (production.getDeuteriumProduction() < 0) {
        deuteriumProduced = production.getDeuteriumProduction() * seconds / 3600.0;
      }

      // Update.
      Resources bodyResources = body.getResources();
      bodyResources.setMetal(bodyResources.getMetal() + metalProduced);
      bodyResources.setCrystal(bodyResources.getCrystal() + crystalProduced);
      bodyResources.setDeuterium(Math.max(0.0, bodyResources.getDeuterium() + deuteriumProduced));
    }

    body.setUpdatedAt(at);
  }

  @Override
  @Transactional(readOnly = true)
  public ProductionDto getProduction(long bodyId) {
    Body body = bodyRepository.getOne(bodyId);
    return getProduction(body);
  }

  @Override
  @Transactional(readOnly = true)
  public ProductionDto getProduction(Body body) {
    if (body.getCoordinates().getKind() != CoordinatesKind.PLANET) {
      return new ProductionDto(1.0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
    }

    // Base production.
    int metalBaseProduction = this.metalBaseProduction * productionSpeed;
    int crystalBaseProduction = this.crystalBaseProduction * productionSpeed;
    int deuteriumBaseProduction = this.deuteriumBaseProduction * productionSpeed;

    ProductionItemsDto items = getProductionItems(body);
    ProductionFactors factors = body.getProductionFactors();

    // Metal mine.
    int metalMineLevel = items.getMetalMineLevel();
    double metalMineFactor = 0.1 * factors.getMetalMineFactor();
    int metalMineProduction = (int) (metalMineBaseProduction * metalMineLevel * Math.pow(1.1, metalMineLevel) *
        metalMineFactor) * productionSpeed;
    int metalMineMaxEnergyUsage = (int) Math.ceil(metalMineBaseEnergyUsage * metalMineLevel *
        Math.pow(1.1, metalMineLevel) * metalMineFactor);

    // Crystal mine.
    int crystalMineLevel = items.getCrystalMineLevel();
    double crystalMineFactor = 0.1 * factors.getCrystalMineFactor();
    int crystalMineProduction = (int) (crystalMineBaseProduction * crystalMineLevel *
        Math.pow(1.1, crystalMineLevel) * crystalMineFactor) * productionSpeed;
    int crystalMineMaxEnergyUsage = (int) Math.ceil(crystalMineBaseEnergyUsage * crystalMineLevel *
        Math.pow(1.1, crystalMineLevel) * crystalMineFactor);

    // Deuterium synthesizer.
    int deuteriumSynthesizerLevel = items.getDeuteriumSynthesizerLevel();
    double deuteriumSynthesizerFactor = 0.1 * factors.getDeuteriumSynthesizerFactor();
    int deuteriumSynthesizerProduction = (int) (deuteriumSynthesizerBaseProduction * deuteriumSynthesizerLevel *
        Math.pow(1.1, deuteriumSynthesizerLevel) * (1.28 - 0.002 * body.getTemperature()) *
        deuteriumSynthesizerFactor) * productionSpeed;
    int deuteriumSynthesizerMaxEnergyUsage = (int) Math.ceil(deuteriumSynthesizerBaseEnergyUsage *
        deuteriumSynthesizerLevel * Math.pow(1.1, deuteriumSynthesizerLevel) * deuteriumSynthesizerFactor);

    // Solar plant.
    int solarPlantLevel = items.getSolarPlantLevel();
    double solarPlantFactor = 0.1 * factors.getSolarPlantFactor();
    int solarPlantEnergyProduction = (int) (solarPlantBaseEnergyProduction * solarPlantLevel *
        Math.pow(1.1, solarPlantLevel) * solarPlantFactor);

    // Fusion reactor.
    int fusionReactorDeuteriumUsage = 0;
    int fusionReactorEnergyProduction = 0;
    int fusionReactorLevel = items.getFusionReactorLevel();
    if (fusionReactorLevel != 0) {
      var energyTechnologyLevel = body.getUser().getTechnologyLevel(TechnologyKind.ENERGY_TECHNOLOGY);
      double fusionReactorFactor = 0.1 * factors.getFusionReactorFactor();
      fusionReactorDeuteriumUsage = (int) Math.ceil(fusionReactorBaseDeuteriumUsage * fusionReactorLevel *
          Math.pow(1.1, fusionReactorLevel) * fusionReactorFactor) * productionSpeed;
      fusionReactorEnergyProduction = (int) Math.round(Math.floor(fusionReactorBaseEnergyProduction *
          fusionReactorLevel * Math.pow(1.05 + 0.01 * energyTechnologyLevel, fusionReactorLevel)) *
          fusionReactorFactor);
    }

    // Solar satellites.
    int numSolarSatellites = items.getNumSolarSatellites();
    double solarSatellitesFactor = 0.1 * factors.getSolarSatellitesFactor();
    int singleSatelliteEnergy = Math.max(5, Math.min(50, (int) Math.floor(body.getTemperature() / 4.0 + 20.0)));
    int solarSatellitesEnergyProduction = (int) Math.round(singleSatelliteEnergy * numSolarSatellites *
        solarSatellitesFactor);

    // Energy balance.
    int totalEnergy = solarPlantEnergyProduction + fusionReactorEnergyProduction + solarSatellitesEnergyProduction;
    int usedEnergy = metalMineMaxEnergyUsage + crystalMineMaxEnergyUsage + deuteriumSynthesizerMaxEnergyUsage;
    int availableEnergy = totalEnergy - usedEnergy;
    double efficiency = usedEnergy == 0 ? 1.0 : Math.min(1.0, (double) totalEnergy / usedEnergy);

    // Current energy usage.
    int metalMineCurrentEnergyUsage = (int) (metalMineMaxEnergyUsage * efficiency);
    int crystalMineCurrentEnergyUsage = (int) (crystalMineMaxEnergyUsage * efficiency);
    int deuteriumSynthesizerCurrentEnergyUsage = (int) (deuteriumSynthesizerMaxEnergyUsage * efficiency);

    // Mines production with efficiency.
    metalMineProduction = (int) (metalMineProduction * efficiency);
    crystalMineProduction = (int) (crystalMineProduction * efficiency);
    deuteriumSynthesizerProduction = (int) (deuteriumSynthesizerProduction * efficiency);

    // Final production.
    int metalProduction = metalBaseProduction + metalMineProduction;
    int crystalProduction = crystalBaseProduction + crystalMineProduction;
    int deuteriumProduction = deuteriumBaseProduction + deuteriumSynthesizerProduction - fusionReactorDeuteriumUsage;

    return new ProductionDto(efficiency, metalBaseProduction, crystalBaseProduction, deuteriumBaseProduction,
        metalMineProduction, metalMineCurrentEnergyUsage, metalMineMaxEnergyUsage, crystalMineProduction,
        crystalMineCurrentEnergyUsage, crystalMineMaxEnergyUsage, deuteriumSynthesizerProduction,
        deuteriumSynthesizerCurrentEnergyUsage, deuteriumSynthesizerMaxEnergyUsage, solarPlantEnergyProduction,
        fusionReactorDeuteriumUsage, fusionReactorEnergyProduction, solarSatellitesEnergyProduction, metalProduction,
        crystalProduction, deuteriumProduction, totalEnergy, usedEnergy, availableEnergy);
  }

  @Override
  @Transactional(readOnly = true)
  public ProductionItemsDto getProductionItems(long bodyId) {
    Body body = bodyRepository.getOne(bodyId);
    return getProductionItems(body);
  }

  private ProductionItemsDto getProductionItems(Body body) {
    int metalMineLevel = body.getBuildingLevel(BuildingKind.METAL_MINE);
    int crystalMineLevel = body.getBuildingLevel(BuildingKind.CRYSTAL_MINE);
    int deuteriumSynthesizerLevel = body.getBuildingLevel(BuildingKind.DEUTERIUM_SYNTHESIZER);
    int solarPlantLevel = body.getBuildingLevel(BuildingKind.SOLAR_PLANT);
    int fusionReactorLevel = body.getBuildingLevel(BuildingKind.FUSION_REACTOR);
    int numSolarSatellites = body.getUnitsCount(UnitKind.SOLAR_SATELLITE);
    return new ProductionItemsDto(metalMineLevel, crystalMineLevel, deuteriumSynthesizerLevel, solarPlantLevel,
        fusionReactorLevel, numSolarSatellites);
  }

  @Override
  @Transactional(readOnly = true)
  public ProductionFactorsDto getProductionFactors(long bodyId) {
    Body body = bodyRepository.getOne(bodyId);
    return Converter.convert(body.getProductionFactors());
  }

  @Override
  @Transactional(isolation = Isolation.REPEATABLE_READ)
  public void setProductionFactors(long bodyId, ProductionFactorsDto factors) {
    Body body = getUpdated(bodyId);
    body.setProductionFactors(Converter.convert(factors));
  }

  @Override
  @Transactional(readOnly = true)
  public ResourcesDto getCapacity(long bodyId) {
    Body body = bodyRepository.getOne(bodyId);
    return getCapacity(body);
  }

  private ResourcesDto getCapacity(Body body) {
    double metal = getCapacity(body.getBuildingLevel(BuildingKind.METAL_STORAGE));
    double crystal = getCapacity(body.getBuildingLevel(BuildingKind.CRYSTAL_STORAGE));
    double deuterium = getCapacity(body.getBuildingLevel(BuildingKind.DEUTERIUM_TANK));
    return new ResourcesDto(metal, crystal, deuterium);
  }

  private double getCapacity(int level) {
    return Math.ceil(1 + Math.pow(1.6, level)) * 50000;
  }

  @Override
  @Transactional
  public void rename(long bodyId, String name) {
    Body body = bodyRepository.getOne(bodyId);
    body.setName(name);

    cacheObserver.notifyBodyUpdated(bodyId);
  }

  @Override
  @Transactional
  public void setImage(long bodyId, int image) {
    Body body = bodyRepository.getOne(bodyId);
    if (body.getCoordinates().getKind() == CoordinatesKind.PLANET) {
      body.setImage(image);
    }
  }

  @Override
  @Transactional(isolation = Isolation.SERIALIZABLE)
  public long abandonPlanet(long bodyId, String password) {
    if (!userServiceInternal.checkCurrentUserPassword(password)) {
      throw new WrongPasswordException();
    }

    Body body = bodyRepository.getOne(bodyId);
    if (body.getCoordinates().getKind() != CoordinatesKind.PLANET) {
      throw new WrongBodyKindException();
    }

    User user = body.getUser();

    long count = bodyRepository.countByUserAndCoordinatesKind(body.getUser(), CoordinatesKind.PLANET);
    if (count == 1) {
      throw new CannotDeleteLastPlanetException();
    }

    boolean techQueueEntryExists = user.getTechnologyQueue().values().stream()
        .anyMatch(e -> e.getBody().getId() == bodyId);
    if (techQueueEntryExists) {
      throw new TechnologyQueueEntryExistsException();
    }

    Coordinates moonCoords = new Coordinates(body.getCoordinates().getGalaxy(), body.getCoordinates().getSystem(),
        body.getCoordinates().getPosition(), CoordinatesKind.MOON);
    Optional<Body> moon = bodyRepository.findByCoordinates(moonCoords);

    List<Body> bodies = new ArrayList<>(Collections.singletonList(body));
    moon.ifPresent(bodies::add);

    if (flightServiceInternal.existsByStartOrTargetIn(bodies)) {
      throw new FlightsExistException();
    }

    bodies.forEach(this::delete);

    Optional<Long> first = user.getBodies().keySet().stream()
        .filter(id -> bodies.stream().noneMatch(b -> b.getId() == id))
        .findFirst();
    assert first.isPresent();
    return first.get();
  }

  @Override
  @Transactional
  public void destroyMoon(Body moon) {
    assert moon.getCoordinates().getKind() == CoordinatesKind.MOON;
    delete(moon);
  }

  private void delete(Body body) {
    cacheObserver.notifyBodyDeleted(body.getUser().getId(), body.getId());

    buildingsServiceInternal.deleteBuildingsAndQueue(body);
    shipyardServiceInternal.deleteUnitsAndQueue(body);
    bodyRepository.delete(body);
  }
}