// Copyright (c) 2022 PSForever
package net.psforever.services

import akka.actor.{ActorRef, Cancellable}
import akka.actor.typed.receptionist.{Receptionist, ServiceKey}
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer}
import akka.actor.typed.{Behavior, SupervisorStrategy}
import net.psforever.actors.session.SessionActor
import net.psforever.actors.zone.{BuildingActor, ZoneActor}
import net.psforever.actors.zone.building.WarpGateLogic
import net.psforever.objects.Default
import net.psforever.objects.serverobject.structures.{Building, WarpGate}
import net.psforever.objects.zones.Zone
import net.psforever.packet.game.ChatMsg
import net.psforever.services.galaxy.{GalaxyAction, GalaxyResponse, GalaxyServiceMessage, GalaxyServiceResponse}
import net.psforever.types.ChatMessageType
import net.psforever.util.Config
import net.psforever.zones.Zones

import scala.concurrent.duration._

object CavernRotationService {
  val CavernRotationServiceKey: ServiceKey[Command] =
    ServiceKey[CavernRotationService.Command](id = "cavernRotationService")

  def apply(): Behavior[Command] =
    Behaviors
      .supervise[Command] {
      Behaviors.withStash(100) { buffer =>
        Behaviors.setup { context =>
          context.system.receptionist ! Receptionist.Register(CavernRotationServiceKey, context.self)
          new CavernRotationService(context, buffer).start()
        }
      }
    }.onFailure[Exception](SupervisorStrategy.restart)

  sealed trait Command

  private case class ServiceManagerLookupResult(result: ServiceManager.LookupResult) extends Command

  final case class ManageCaverns(zones: Iterable[Zone]) extends Command

  final case class SendCavernRotationUpdates(sendToSession: ActorRef) extends Command

  final case class LockedZoneUpdate(zone: Zone, timeUntilUnlock: Long)

  final case class UnlockedZoneUpdate(zone: Zone)

  sealed trait HurryRotation extends Command {
    def zoneid: String
  }

  case object HurryNextRotation extends HurryRotation { def zoneid = "" }

  final case class HurryRotationToZoneLock(zoneid: String) extends HurryRotation

  final case class HurryRotationToZoneUnlock(zoneid: String) extends HurryRotation

  final case class ReportRotationOrder(sendToSession: ActorRef) extends Command

  private case object SwitchZone extends Command

  private case class ClosingWarning(counter: Int) extends Command

  /**
    * A token designed to keep track of the managed cavern zone.
    * @param zone the zone
    */
  class ZoneMonitor(val zone: Zone) {
    /** is the zone currently accessible */
    var locked: Boolean = true
    /** when did the timer start (ms) */
    var start: Long = 0L
    /** for how long does the timer go on (ms) */
    var duration: Long = 0L
  }

  /**
    * The periodic warning for when a cavern closes,
    * usually announcing fifteen, ten, then five minutes before closure.
    * @see `ChatMsg`
    * @see `GalaxyService`
    * @param zone zone monitor
    * @param counter current time until closure
    * @param galaxyService callback to display the warning;
    *                      should be the reference to `GalaxyService`, hence the literal name
    * @return `true`, if the zone was actually locked and the message was shown;
    *        `false`, otherwise
    */
  private def closedCavernWarning(zone: ZoneMonitor, counter: Int, galaxyService: ActorRef): Boolean = {
    if (!zone.locked) {
      galaxyService ! GalaxyServiceMessage(GalaxyAction.SendResponse(
        ChatMsg(ChatMessageType.UNK_229, s"@cavern_closing_warning^@${zone.zone.id}~^@$counter~")
      ))
      true
    } else {
      false
    }
  }

  /**
    * Configure the cavern zones lattice links
    * for cavern access.
    * @param zones the cavern zones being configured
    */
  private def activateLatticeLinksAndWarpGateAccessibility(zones: Seq[Zone]): Unit = {
    val sortedZones = zones.sortBy(_.Number)
    establishLatticeLinksForUnlockedCaverns(sortedZones)
    sortedZones.foreach { zone =>
      openZoneWarpGateAccessibility(zone)
    }
  }

  /**
    * Apply the lattice links that connect geowarp gates and cavern warp gates to the lattices for each zone.
    * Separate the connection entry strings,
    * locate each individual zone and warp gate in that zone,
    * then add one to the other's lattice connectivity on the fly.
    * @param zones the cavern zones
    */
  private def establishLatticeLinksForUnlockedCaverns(zones: Seq[Zone]): Unit = {
    val key = s"caverns-${zones.map(_.id).mkString("-")}"
    Zones.cavernLattice.get(key) match {
      case Some(links) =>
        links.foreach { link =>
          val entryA = link.head
          val entryB = link.last
          val splitA = entryA.split("/")
          val splitB = entryB.split("/")
          ((zones.find { _.id.equals(splitA.head) }, Zones.zones.find { _.id.equals(splitB.head) }) match {
            case (Some(zone1), Some(zone2)) => (zone1.Building(splitA.last), zone2.Building(splitB.last))
            case _                          => (None, None)
          }) match {
            case (Some(gate1), Some(gate2)) =>
              gate1.Zone.AddIntercontinentalLatticeLink(gate1, gate2)
              gate2.Zone.AddIntercontinentalLatticeLink(gate2, gate1)
            case _ => ;
          }
        }
      case _ =>
        org.log4s.getLogger("CavernRotationService").error(s"can not find mapping to open $key")
    }
  }

  /**
    * Collect all of the warp gates in a (cavern) zone and the adjacent building along the lattice
    * and update the connectivity of the gate pairs
    * so that the gate pair is active and broadcasts correctly.
    * @param zone the zone
    * @return all of the affected warp gates
    */
  private def openZoneWarpGateAccessibility(zone: Zone): Iterable[WarpGate] = {
    findZoneWarpGatesForChangingAccessibility(zone).map { case (wg, otherWg, building) =>
      wg.Active = true
      otherWg.Active = true
      wg.Actor ! BuildingActor.AlertToFactionChange(building)
      otherWg.Zone.actor ! ZoneActor.ZoneMapUpdate()
      wg
    }
  }

  /**
    * Configure the cavern zones lattice links
    * for cavern closures.
    * @param zones the cavern zones being configured
    */
  private def disableLatticeLinksAndWarpGateAccessibility(zones: Seq[Zone]): Unit = {
    val sortedZones = zones.sortBy(_.Number)
    sortedZones.foreach { zone =>
      closeZoneWarpGateAccessibility(zone)
    }
    revokeLatticeLinksForUnlockedCaverns(sortedZones)
  }

  /**
    * Disconnect the lattice links that connect geowarp gates and cavern warp gates to the lattices for each zone.
    * Separate the connection entry strings,
    * locate each individual zone and warp gate in that zone,
    * then remove one from the other's lattice connectivity on the fly.
    * @param zones the cavern zones
    */
  private def revokeLatticeLinksForUnlockedCaverns(zones: Seq[Zone]): Unit = {
    val key = s"caverns-${zones.map(_.id).mkString("-")}"
    Zones.cavernLattice.get(key) match {
      case Some(links) =>
        links.foreach { link =>
          val entryA = link.head
          val entryB = link.last
          val splitA = entryA.split("/")
          val splitB = entryB.split("/")
          ((zones.find { _.id.equals(splitA.head) }, Zones.zones.find { _.id.equals(splitB.head) }) match {
            case (Some(zone1), Some(zone2)) => (zone1.Building(splitA.last), zone2.Building(splitB.last))
            case _                          => (None, None)
          }) match {
            case (Some(gate1), Some(gate2)) =>
              gate1.Zone.RemoveIntercontinentalLatticeLink(gate1, gate2)
              gate2.Zone.RemoveIntercontinentalLatticeLink(gate2, gate1)
            case _ => ;
          }
        }
      case _ =>
        org.log4s.getLogger("CavernRotationService").error(s"can not find mapping to close $key")
    }
  }

  /**
    * Collect all of the warp gates in a (cavern) zone and the adjacent building along the lattice
    * and update the connectivity of the gate pairs
    * so that the gate pair is inactive and stops broadcasting.
    * @param zone the zone
    * @return all of the affected warp gates
    */
  private def closeZoneWarpGateAccessibility(zone: Zone): Iterable[WarpGate] = {
    findZoneWarpGatesForChangingAccessibility(zone).map { case (wg, otherWg, building) =>
      wg.Active = false
      otherWg.Active = false
      wg.Actor ! BuildingActor.AlertToFactionChange(building)
      otherWg.Zone.actor ! ZoneActor.ZoneMapUpdate()
      //must trigger the connection test from the other side to equalize
      WarpGateLogic.findNeighborhoodNormalBuilding(otherWg.Neighbours.getOrElse(Nil)) match {
        case Some(b) => otherWg.Actor ! BuildingActor.AlertToFactionChange(b)
        case None    => ;
      }
      wg
    }
  }

  /**
    * Within a given zone, find:
    * (1) all warp gates;
    * (2) the warp gates that are adjacent along the intercontinental lattice (in the other zone); and,
    * (3) the facility building that is adjacent to the warp gate (in this zone).
    * Will be using the recovered grouping for manipulation of the intercontinental lattice extending from the zone.
    * @param zone the zone
    * @return the triples
    */
  private def findZoneWarpGatesForChangingAccessibility(zone: Zone): Iterable[(WarpGate, WarpGate, Building)] = {
    zone.Buildings.values
      .collect {
        case wg: WarpGate =>
          val neighborhood = wg.AllNeighbours.getOrElse(Nil)
          (
            WarpGateLogic.findNeighborhoodWarpGate(neighborhood),
            WarpGateLogic.findNeighborhoodNormalBuilding(neighborhood)
          ) match {
            case (Some(otherWg: WarpGate), Some(building)) =>
              Some(wg, otherWg, building)
            case _ =>
              None
          }
      }.flatten
  }

  /**
    * Take two zone monitors and swap the order of the zones.
    * Keep the timers from each other the same.
    * @param list the ordered zone monitors
    * @param to index of one zone monitor
    * @param from index of another zone monitor
    */
  private def swapMonitors(list: List[ZoneMonitor], to: Int, from: Int): Unit = {
    val toMonitor = list(to)
    val fromMonitor = list(from)
    list.updated(to, new ZoneMonitor(fromMonitor.zone) {
      locked = toMonitor.locked
      start = toMonitor.start
      duration = toMonitor.duration
    })
    list.updated(from, new ZoneMonitor(toMonitor.zone) {
      locked = fromMonitor.locked
      start = fromMonitor.start
      duration = fromMonitor.duration
    })
  }
}

/**
  * A service that assists routine access to a series of game zones
  * through the manipulation of connections between transmit point structures.<br>
  * <br>
  * The caverns were a group of game zones that were intended to be situated underground.
  * Access to the caverns was only sometimes possible
  * through the use of special above-ground warp gates called geowarps (geowarp gates)
  * and those geowarps were not always functional.
  * Usually, two caverns were available at a time and connections to these caverns were fixed
  * to specific active geowarp gates.
  * The changing availability of the caverns through the change of geowarp gate activity
  * was colloquially referred to as a "rotation" since it followed a predictable cycle.
  * The cycle was not just one of time but one of route
  * as one specific geowarp gates would open to the same destination cavern.<br>
  * <br>
  * The client controls warp gate destinations.
  * The server can only confirm those destinations.
  * The connectivity of a geowarp gate to a cavern warp gate had to have been determined
  * by opening the cavern with an appropriate packet
  * and checking the map description of the cavern gates.
  * The description text explains which of the geowarp gates in whichever zone has been connected; and,
  * where usually static and inanimate, that geowarp gate will bubble online and begin to rotate
  * and have a complementary destination map description.
  * Opening different combinations of caverns changes the destination these warp gate pairs will connect
  * and not always being connected at all.
  * The warp gate pairs for the cavern connections must be re-evaluated for each combination and with each rotation
  * and all relevant pairings must be defined in advance.
  * @see `ActorContext`
  * @see `Building`
  * @see `ChatMsg`
  * @see `Config.app.game.cavernRotation`
  * @see `GalaxyService`
  * @see `GalaxyAction.LockedZoneUpdate`
  * @see `GalaxyResponse.UnlockedZoneUpdate`
  * @see `InterstellarClusterService`
  * @see `org.log4s.getLogger`
  * @see `resources/zonemaps/lattice.json`
  * @see `SessionActor`
  * @see `SessionActor.SendResponse`
  * @see `StashBuffer`
  * @see `WarpGate`
  * @see `Zone`
  * @see `ZoneForcedCavernConnectionsMessage`
  * @see `ZoneInfoMessage`
  */
//TODO currently, can only support any 1 cavern unlock order and the predetermined 2 cavern unlock order
class CavernRotationService(
                             context: ActorContext[CavernRotationService.Command],
                             buffer: StashBuffer[CavernRotationService.Command]
                           ) {
  import CavernRotationService._

  ServiceManager.serviceManager ! ServiceManager.LookupFromTyped(
    "galaxy",
    context.messageAdapter[ServiceManager.LookupResult](ServiceManagerLookupResult)
  )

  /** monitors for the cavern zones */
  var managedZones: List[ZoneMonitor] = Nil
  /** index of the next cavern that will lock */
  var nextToLock: Int = 0
  /** index of the next cavern that will unlock */
  var nextToUnlock: Int = 0
  /** timer for cavern rotation - the cavern closing warning */
  var lockTimer: Cancellable = Default.Cancellable
  /** timer for cavern rotation - the actual opening and closing functionality */
  var unlockTimer: Cancellable = Default.Cancellable
  var simultaneousUnlockedZones: Int = Config.app.game.cavernRotation.simultaneousUnlockedZones
  /** time between individual cavern rotation events (hours) */
  val timeBetweenRotationsHours: Float = Config.app.game.cavernRotation.hoursBetweenRotation
  /** number of zones unlocked at the same time */
  /** period of all caverns having rotated (hours) */
  var timeToCompleteAllRotationsHours: Float = 0f
  /** how long before any given cavern closure that the first closing message is shown (minutes) */
  val firstClosingWarningAtMinutes: Int = 15

  def start(): Behavior[CavernRotationService.Command] = {
    Behaviors.receiveMessage {
      case ServiceManagerLookupResult(ServiceManager.LookupResult(request, endpoint)) =>
        request match {
          case "galaxy" =>
            buffer.unstashAll(active(endpoint))
          case _ =>
            Behaviors.same
        }

      case other =>
        buffer.stash(other)
        Behaviors.same
    }
  }

  def active(galaxyService: ActorRef): Behavior[CavernRotationService.Command] = {
    Behaviors.receiveMessage {
      case ManageCaverns(zones) =>
        manageCaverns(zones.toSeq)
        Behaviors.same

      case ClosingWarning(counter)
        if counter == 15 || counter == 10 =>
        if (CavernRotationService.closedCavernWarning(managedZones(nextToLock), counter, galaxyService)) {
          val next = counter - 5
          lockTimerToDisplayWarning(next.minutes, next)
        }
        Behaviors.same

      case ClosingWarning(counter) =>
        CavernRotationService.closedCavernWarning(managedZones(nextToLock), counter, galaxyService)
        Behaviors.same

      case ReportRotationOrder(sendToSession) =>
        reportRotationOrder(sendToSession)
        Behaviors.same

      case SwitchZone =>
        zoneRotationFunc(galaxyService)
        Behaviors.same

      case HurryNextRotation =>
        hurryNextRotation(galaxyService)
        Behaviors.same

      case HurryRotationToZoneLock(zoneid) =>
        hurryRotationToZoneLock(zoneid, galaxyService)
        Behaviors.same

      case HurryRotationToZoneUnlock(zoneid) =>
        hurryRotationToZoneUnlock(zoneid, galaxyService)
        Behaviors.same

      case SendCavernRotationUpdates(sendToSession) =>
        sendCavernRotationUpdates(sendToSession)
        Behaviors.same

      case _ =>
        Behaviors.same
    }
  }

  /**
    * na
    * @param zones the zones for submission
    * @return `true`, if the setup has been completed;
    *        `false`, otherwise
    */
  def manageCaverns(zones: Seq[Zone]): Boolean = {
    if (managedZones.isEmpty) {
      val onlyCaverns = zones.filter{ z => z.map.cavern }
      val collectedZones = Config.app.game.cavernRotation.enhancedRotationOrder match {
        case Nil  => onlyCaverns
        case list => list.flatMap { index => onlyCaverns.find(_.Number == index ) }
      }
      if (collectedZones.nonEmpty) {
        simultaneousUnlockedZones = math.min(simultaneousUnlockedZones, collectedZones.size)
        managedZones = collectedZones.map(zone => new ZoneMonitor(zone)).toList
        val rotationSize = managedZones.size
        timeToCompleteAllRotationsHours = rotationSize.toFloat * timeBetweenRotationsHours
        val curr = System.currentTimeMillis()
        val fullDurationAsHours = timeToCompleteAllRotationsHours.hours
        val fullDurationAsMillis = fullDurationAsHours.toMillis
        val startingInThePast = curr - fullDurationAsMillis
        val (unlockedZones, lockedZones) = managedZones.splitAt(simultaneousUnlockedZones)
        var i = 0
        //the timer data in all zone monitors
        (lockedZones ++ unlockedZones).foreach { zone =>
          i += 1
          zone.locked = true
          zone.start = startingInThePast + (i * timeBetweenRotationsHours).hours.toMillis
          zone.duration = fullDurationAsMillis
        }
        //unlocked zones
        unlockedZones.foreach { zone =>
          zone.locked = false
        }
        CavernRotationService.activateLatticeLinksAndWarpGateAccessibility(unlockedZones.map(_.zone))
        nextToLock = 0
        lockTimerToDisplayWarning(timeBetweenRotationsHours.hours - firstClosingWarningAtMinutes.minutes)
        //locked zones ...
        nextToUnlock = simultaneousUnlockedZones
        unlockTimerToSwitchZone(timeBetweenRotationsHours.hours)
        //println(managedZones.flatMap { z => s"[${z.start + z.duration - curr}]"}.mkString(""))
        true
      } else {
        false
      }
    } else {
      false
    }
  }

  /**
    * na
    * @param sendToSession callback reference
    */
  def reportRotationOrder(sendToSession: ActorRef): Unit = {
    val zoneStates = managedZones.collect {
      case zone =>
        if (zone.locked) {
          s"<${zone.zone.id}>"
        } else {
          s"${zone.zone.id}"
        }
    }.mkString(" ")
    sendToSession ! SessionActor.SendResponse(
      ChatMsg(ChatMessageType.UNK_229, s"[$zoneStates]")
    )
    Behaviors.same
  }

  /**
    * na
    * @see `GalaxyService`
    * @param zoneid zone to lock next
    * @param galaxyService callback to update the server and clients;
    *                      should be the reference to `GalaxyService`, hence the literal name
    * @return `true`, if the target zone is locked when complete;
    *        `false`, otherwise
    */
  def hurryRotationToZoneLock(zoneid: String, galaxyService: ActorRef): Boolean = {
    //TODO currently, can only switch for 1 active cavern
    if (simultaneousUnlockedZones == 1) {
      if ((nextToLock until nextToLock + simultaneousUnlockedZones)
        .map { i => managedZones(i % managedZones.size) }
        .indexWhere { _.zone.id.equals(zoneid) } match {
        case -1 =>
          false
        case  0 =>
          true
        case  index =>
          CavernRotationService.swapMonitors(managedZones, nextToLock, index)
          true
      }) {
        hurryNextRotation(galaxyService, forcedRotationOverride=true)
      }
      true
    } else {
      org.log4s.getLogger("CavernRotationService").warn(s"can not alter cavern order")
      false
    }
  }

  /**
    * na
    * @see `GalaxyService`
    * @param zoneid zone to unlock next
    * @param galaxyService callback to update the server and clients;
    *                      should be the reference to `GalaxyService`, hence the literal name
    * @return `true`, if the target zone is unlocked when complete;
    *        `false`, otherwise
    */
  def hurryRotationToZoneUnlock(zoneid: String, galaxyService: ActorRef): Boolean = {
    //TODO currently, can only switch for 1 active cavern
    if (simultaneousUnlockedZones == 1) {
      if (managedZones(nextToUnlock).zone.id.equals(zoneid)) {
        hurryNextRotation(galaxyService, forcedRotationOverride = true)
        true
      } else {
        managedZones.indexWhere { z => z.zone.id.equals(zoneid) } match {
          case -1 =>
            false //not found
          case index if nextToLock <= index && index < nextToUnlock + simultaneousUnlockedZones =>
            true //already unlocked
          case index =>
            CavernRotationService.swapMonitors(managedZones, nextToUnlock, index)
            hurryNextRotation(galaxyService, forcedRotationOverride = true)
            true
        }
      }
    } else {
      org.log4s.getLogger("CavernRotationService").error(s"can not alter cavern order")
      false
    }
  }

  /**
    *
    * @param sendToSession callback reference
    */
  def sendCavernRotationUpdates(sendToSession: ActorRef): Unit = {
    val curr = System.currentTimeMillis()
    val (lockedZones, unlockedZones) = managedZones.partition(_.locked)
    //borrow GalaxyService response structure, but send to the specific endpoint
    lockedZones.foreach { monitor =>
      sendToSession ! GalaxyServiceResponse(
        "",
        GalaxyResponse.LockedZoneUpdate(monitor.zone, math.max(0, monitor.start + monitor.duration - curr))
      )
    }
    unlockedZones.foreach { monitor =>
      sendToSession ! GalaxyServiceResponse("", GalaxyResponse.UnlockedZoneUpdate(monitor.zone))
    }
  }

  /**
    * Progress to the next significant cavern rotation event.<br>
    * <br>
    * If the time until the next rotation is greater than the time where the cavern closing warning would be displayed,
    * progress to that final cavern closing warning.
    * Adjust the timing for that advancement.
    * If the final cavern closing warning was already displayed,
    * just perform the cavern rotation.
    * @see `GalaxyService`
    * @param galaxyService callback to update the server and clients;
    *                      should be the reference to `GalaxyService`, hence the literal name
    * @param forcedRotationOverride force a cavern rotation in a case where a closing warning would be displayed instead
    */
  def hurryNextRotation(
                         galaxyService: ActorRef,
                         forcedRotationOverride: Boolean = false
                       ): Unit = {
    val curr = System.currentTimeMillis() //ms
    val unlocking = managedZones(nextToUnlock)
    val timeToNextClosingEvent = unlocking.start + unlocking.duration - curr //ms
    val fiveMinutes = 5.minutes //minutes duration
    if (
      forcedRotationOverride || Config.app.game.cavernRotation.forceRotationImmediately ||
      timeToNextClosingEvent < fiveMinutes.toMillis
    ) {
      //zone transition immediately
      lockTimer.cancel()
      unlockTimer.cancel()
      zoneRotationFunc(galaxyService)
      lockTimerToDisplayWarning(timeBetweenRotationsHours.hours - firstClosingWarningAtMinutes.minutes)
      retimeZonesUponForcedRotation(galaxyService)
    } else {
      //instead of transitioning immediately, jump to the 5 minute rotation warning for the benefit of players
      lockTimer.cancel() //won't need to retime until zone change
      CavernRotationService.closedCavernWarning(managedZones(nextToLock), counter=5, galaxyService)
      unlockTimerToSwitchZone(fiveMinutes)
      retimeZonesUponForcedAdvancement(timeToNextClosingEvent.milliseconds - fiveMinutes, galaxyService)
    }
  }

  /**
    * Actually perform zone rotation as determined by the managed zone monitors and the timers.<br>
    * <br>
    * The process of zone rotation occurs by having a zone that is determined to be closing
    * and a zone that is determied to be opening
    * and a potential series of zones "in between" the two that are also open.
    * All of the currently opened zones are locked and the zone to be permanently closed is forgotten.
    * The zone that should be opening is added to the aforementioned sequence of zones
    * and then the zones in that sequence are opened.
    * The zones that would otherwise be unaffected by a single zone opening and a single cone closing must be affected
    * because the cavern gates will not connect to the same geowarp gates with the change in the sequence.
    * After the rotation, the indices to the next closing zone and next opening zone are updated.
    * Modifying the zone monitor timekeeping and the actual timers and the indices are the easy parts.
    * @see `GalaxyService`
    * @param galaxyService callback to update the server and clients;
    *                      should be the reference to `GalaxyService`, hence the literal name
    */
  def zoneRotationFunc(
                        galaxyService: ActorRef
                      ): Unit = {
    val curr = System.currentTimeMillis()
    val locking = managedZones(nextToLock)
    val unlocking = managedZones(nextToUnlock)
    val lockingZone = locking.zone
    val unlockingZone = unlocking.zone
    val fullHoursBetweenRotationsAsHours = timeToCompleteAllRotationsHours.hours
    val fullHoursBetweenRotationsAsMillis = fullHoursBetweenRotationsAsHours.toMillis
    val hoursBetweenRotationsAsHours = timeBetweenRotationsHours.hours
    val prevToLock = nextToLock
    nextToLock = (nextToLock + 1) % managedZones.size
    nextToUnlock = (nextToUnlock + 1) % managedZones.size
    //this zone will be locked; open when the timer runs out
    locking.locked = true
    locking.start = curr
    unlockTimerToSwitchZone(hoursBetweenRotationsAsHours)
    //this zone will be unlocked; alert the player that it will lock soon when the timer runs out
    unlocking.locked = false
    unlocking.start = curr
    lockTimerToDisplayWarning(hoursBetweenRotationsAsHours - firstClosingWarningAtMinutes.minutes)
    //alert clients to change
    if (lockingZone ne unlockingZone) {
      galaxyService ! GalaxyServiceMessage(GalaxyAction.SendResponse(
        ChatMsg(ChatMessageType.UNK_229, s"@cavern_switched^@${lockingZone.id}~^@${unlockingZone.id}")
      ))
      galaxyService ! GalaxyServiceMessage(GalaxyAction.UnlockedZoneUpdate(unlockingZone))
      //change warp gate statuses to reflect zone lock state
      CavernRotationService.disableLatticeLinksAndWarpGateAccessibility(
        ((prevToLock until managedZones.size) ++ (0 until prevToLock))
          .take(simultaneousUnlockedZones)
          .map(managedZones(_).zone)
      )
      CavernRotationService.activateLatticeLinksAndWarpGateAccessibility(
        ((nextToLock until managedZones.size) ++ (0 until nextToLock))
          .take(simultaneousUnlockedZones)
          .map(managedZones(_).zone)
      )
    }
    galaxyService ! GalaxyServiceMessage(GalaxyAction.LockedZoneUpdate(locking.zone, fullHoursBetweenRotationsAsMillis))
  }

  /**
    * If the zones are forced to rotate before the timer would normally complete,
    * correct all of the zone monitors to give the impression of the rotation that occurred.
    * Only affect the backup parameters of the timers that are maintained by the zone monitors.
    * Do not actually affect the functional timers.
    * @see `GalaxyService`
    * @param galaxyService callback to update the zone timers;
    *                      should be the reference to `GalaxyService`, hence the literal name
    */
  def retimeZonesUponForcedRotation(galaxyService: ActorRef) : Unit = {
    val curr = System.currentTimeMillis()
    val rotationSize = managedZones.size
    val fullDurationAsMillis = timeToCompleteAllRotationsHours.hours.toMillis
    val startingInThePast = curr - fullDurationAsMillis
    //this order allows the monitors to be traversed in order of ascending time to unlock
    (0 +: ((nextToUnlock until rotationSize) ++ (0 until nextToUnlock)))
      .zipWithIndex
      .drop(1)
      .foreach { case (monitorIndex, index) =>
        val zone = managedZones(monitorIndex)
        val newStart = startingInThePast + (index * timeBetweenRotationsHours).hours.toMillis
        zone.start = newStart
        if (zone.locked) {
          galaxyService ! GalaxyServiceMessage(GalaxyAction.LockedZoneUpdate(zone.zone, newStart + fullDurationAsMillis - curr))
        }
      }
    //println(managedZones.flatMap { z => s"[${z.start + z.duration - curr}]"}.mkString(""))
  }

  /**
    * If the natural process of switching between caverns is hurried,
    * advance the previous start time of each zone monitor to give the impression of the hastened rotation.
    * This does not actually affect the functional timers
    * nor is it in response to an actual zone rotation event.
    * It only affects the backup parameters of the timers that are maintained by the zone monitors.
    * @see `GalaxyService`
    * @param advanceTimeBy amount of time advancement
    * @param galaxyService callback to update the zone timers;
    *                      should be the reference to `GalaxyService`, hence the literal name
    */
  def retimeZonesUponForcedAdvancement(
                                        advanceTimeBy: FiniteDuration,
                                        galaxyService: ActorRef
                                      ) : Unit = {
    val curr = System.currentTimeMillis()
    val advanceByTimeAsMillis = advanceTimeBy.toMillis
    managedZones.foreach { zone =>
      zone.start = zone.start - advanceByTimeAsMillis
      if (zone.locked) {
        galaxyService ! GalaxyServiceMessage(GalaxyAction.LockedZoneUpdate(zone.zone, zone.start + zone.duration - curr))
      }
    }
    //println(managedZones.flatMap { z => s"[${z.start + z.duration - curr}]"}.mkString(""))
  }

  /**
    * Update the timer for the cavern closing message.
    * @param duration new time until message display
    * @param counter the counter that indicates the next message to display
    */
  def lockTimerToDisplayWarning(
                                 duration: FiniteDuration,
                                 counter: Int = firstClosingWarningAtMinutes
                               ): Unit = {
    lockTimer.cancel()
    lockTimer = context.scheduleOnce(duration, context.self, ClosingWarning(counter))
  }

  /**
    * Update the timer for the zone switching process.
    * @param duration new time until switching
    */
  def unlockTimerToSwitchZone(duration: FiniteDuration): Unit = {
    unlockTimer.cancel()
    unlockTimer = context.scheduleOnce(duration, context.self, SwitchZone)
  }
}
