/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * license agreements; and to You under the Apache License, version 2.0:
 *
 *   https://www.apache.org/licenses/LICENSE-2.0
 *
 * This file is part of the Apache Pekko project, which was derived from Akka.
 */

/*
 * Copyright (C) 2017-2021 Lightbend Inc. <https://www.lightbend.com>
 */

package org.apache.pekko.management.cluster.bootstrap

import java.time.LocalDateTime
import java.net.InetAddress
import scala.concurrent.duration._
import org.apache.pekko
import pekko.actor.ActorSystem
import pekko.actor.Address
import pekko.discovery.ServiceDiscovery.ResolvedTarget
import pekko.event.NoLogging
import pekko.testkit.SocketUtil
import pekko.testkit.TestKit
import com.typesafe.config.ConfigFactory
import org.scalatest.Inside

abstract class JoinDeciderSpec extends AbstractBootstrapSpec with Inside {

  val (managementPort, remotingPort) = inside(SocketUtil.temporaryServerAddresses(2, "127.0.0.1").map(_.getPort)) {
    case Vector(mPort: Int, rPort: Int) => (mPort, rPort)
    case o                              => fail("Expected 2 ports but got: " + o)
  }

  val config =
    ConfigFactory.parseString(s"""
        pekko {
          loglevel = INFO

          cluster.http.management.port = $managementPort
          remote.netty.tcp.port = $remotingPort
          remote.artery.canonical.port = $remotingPort

          discovery {
            mock-dns.class = "org.apache.pekko.discovery.MockDiscovery"
          }

          management {
            cluster.bootstrap {
              contact-point-discovery {
                discovery-method = mock-dns
                service-namespace = "svc.cluster.local"
                required-contact-point-nr = 3
              }
            }

            http {
              hostname = "10.0.0.2"
              base-path = "test"
              port = $managementPort
            }
          }
        }
        """)

  val contactA = ResolvedTarget(
    host = "10-0-0-2.default.pod.cluster.local",
    port = None,
    address = Some(InetAddress.getByName("10.0.0.2")))

  val contactB = ResolvedTarget(
    host = "10-0-0-3.default.pod.cluster.local",
    port = None,
    address = Some(InetAddress.getByName("10.0.0.3")))

  val contactC = ResolvedTarget(
    host = "10-0-0-4.default.pod.cluster.local",
    port = None,
    address = Some(InetAddress.getByName("10.0.0.4")))

  lazy val system = ActorSystem("join-decider-spec-system", config)

  override def afterAll(): Unit = TestKit.shutdownActorSystem(system, 5.seconds)
}

class LowestAddressJoinDeciderSpec extends JoinDeciderSpec {

  "LowestAddressJoinDecider" should {
    val settings = ClusterBootstrapSettings(system.settings.config, NoLogging)

    "sort ResolvedTarget by lowest hostname:port" in {
      List(ResolvedTarget("c", None, None), ResolvedTarget("a", None, None),
        ResolvedTarget("b", None, None)).sorted should ===(
        List(ResolvedTarget("a", None, None), ResolvedTarget("b", None, None), ResolvedTarget("c", None, None)))
      List(ResolvedTarget("c", Some(1), None), ResolvedTarget("a", Some(3), None),
        ResolvedTarget("b", Some(2), None)).sorted should ===(
        List(ResolvedTarget("a", Some(3), None), ResolvedTarget("b", Some(2), None),
          ResolvedTarget("c", Some(1), None)))
      List(ResolvedTarget("a", Some(2), None), ResolvedTarget("a", Some(1), None),
        ResolvedTarget("a", Some(3), None)).sorted should ===(
        List(ResolvedTarget("a", Some(1), None), ResolvedTarget("a", Some(2), None),
          ResolvedTarget("a", Some(3), None)))
    }

    /**
     * This may happen for example in case of #344
     */
    "when addresses are known, sort deterministically on address even when names are inconsistent" in {
      val addr1 = InetAddress.getByName("127.0.0.1")
      val addr2 = InetAddress.getByName("127.0.0.2")
      val addr3 = InetAddress.getByName("127.0.0.3")

      List(
        ResolvedTarget("c", None, Some(addr2)),
        ResolvedTarget("x", None, Some(addr1)),
        ResolvedTarget("b", None, Some(addr3))).sorted should ===(
        List(
          ResolvedTarget("x", None, Some(addr1)),
          ResolvedTarget("c", None, Some(addr2)),
          ResolvedTarget("b", None, Some(addr3))))
    }

    "join existing cluster immediately" in {
      val decider = new LowestAddressJoinDecider(system, settings)
      val now = LocalDateTime.now()
      val info = new SeedNodesInformation(
        currentTime = now,
        contactPointsChangedAt = now.minusSeconds(2),
        contactPoints = Set(contactA, contactB, contactC),
        seedNodesObservations = Set(
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactA,
            Address("pekko", "join-decider-spec-system", "10.0.0.2", 7355),
            Set(Address("pekko", "join-decider-spec-system", "10.0.0.2", 7355)))))
      decider.decide(info).futureValue should ===(
        JoinOtherSeedNodes(Set(Address("pekko", "join-decider-spec-system", "10.0.0.2", 7355))))
    }

    "keep probing when contact points changed within stable-margin" in {
      val decider = new LowestAddressJoinDecider(system, settings)
      val now = LocalDateTime.now()
      val info = new SeedNodesInformation(
        currentTime = now,
        contactPointsChangedAt = now.minusSeconds(2), // << 2 < stable-margin
        contactPoints = Set(contactA, contactB, contactC),
        seedNodesObservations = Set(
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactA,
            Address("pekko", "join-decider-spec-system", "10.0.0.2", 7355),
            Set.empty),
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactB,
            Address("pekko", "join-decider-spec-system", "b", 7355),
            Set.empty),
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactC,
            Address("pekko", "join-decider-spec-system", "c", 7355),
            Set.empty)))
      decider.decide(info).futureValue should ===(KeepProbing)
    }

    "keep probing when not enough contact points" in {
      val decider = new LowestAddressJoinDecider(system, settings)
      val now = LocalDateTime.now()
      val info = new SeedNodesInformation(
        currentTime = now,
        contactPointsChangedAt = now.minusSeconds(2),
        contactPoints = Set(contactA, contactB), // << 2 < required-contact-point-nr
        seedNodesObservations = Set(
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactA,
            Address("pekko", "join-decider-spec-system", "10.0.0.2", 7355),
            Set.empty),
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactB,
            Address("pekko", "join-decider-spec-system", "b", 7355),
            Set.empty)))
      decider.decide(info).futureValue should ===(KeepProbing)
    }

    "keep probing when not enough confirmed contact points" in {
      val decider = new LowestAddressJoinDecider(system, settings)
      val now = LocalDateTime.now()
      val info = new SeedNodesInformation(
        currentTime = now,
        contactPointsChangedAt = now.minusSeconds(2),
        contactPoints = Set(contactA, contactB, contactC),
        seedNodesObservations = Set(
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactA,
            Address("pekko", "join-decider-spec-system", "10.0.0.2", 7355),
            Set.empty),
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactB,
            Address("pekko", "join-decider-spec-system", "b", 7355),
            Set.empty))
        // << 2 < required-contact-point-nr
      )
      decider.decide(info).futureValue should ===(KeepProbing)
    }

    "join self when all conditions met and self has the lowest address" in {
      settings.newClusterEnabled should ===(true)
      ClusterBootstrap(system).setSelfContactPoint(s"http://10.0.0.2:$managementPort/test")
      val decider = new LowestAddressJoinDecider(system, settings)
      val now = LocalDateTime.now()
      val info = new SeedNodesInformation(
        currentTime = now,
        contactPointsChangedAt = now.minusSeconds(6),
        contactPoints = Set(contactA, contactB, contactC),
        seedNodesObservations = Set(
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactA,
            Address("pekko", "join-decider-spec-system", "10.0.0.2", 7355),
            Set.empty),
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactB,
            Address("pekko", "join-decider-spec-system", "b", 7355),
            Set.empty),
          new SeedNodesObservation(
            now.minusSeconds(1),
            contactC,
            Address("pekko", "join-decider-spec-system", "c", 7355),
            Set.empty)))
      decider.decide(info).futureValue should ===(JoinSelf)
    }
  }
}

class SelfAwareJoinDeciderSpec extends JoinDeciderSpec {

  override val remotingPort = 0

  val disabled =
    ConfigFactory.parseString("pekko.management.cluster.bootstrap.new-cluster-enabled=off")

  override lazy val system = ActorSystem("join-decider-spec-system-selfaware", disabled.withFallback(config))

  val settings = ClusterBootstrapSettings(system.settings.config, NoLogging)

  def seedNodes = {
    val now = LocalDateTime.now()
    new SeedNodesInformation(
      currentTime = now,
      contactPointsChangedAt = now.minusSeconds(6),
      contactPoints = Set(contactA, contactB, contactC),
      seedNodesObservations = Set(
        new SeedNodesObservation(
          now.minusSeconds(1),
          contactA,
          Address("pekko", "join-decider-spec-system-selfaware", "10.0.0.2", 7355),
          Set.empty),
        new SeedNodesObservation(
          now.minusSeconds(1),
          contactB,
          Address("pekko", "join-decider-spec-system-selfaware", "b", 7355),
          Set.empty),
        new SeedNodesObservation(
          now.minusSeconds(1),
          contactC,
          Address("pekko", "join-decider-spec-system-selfaware", "c", 7355),
          Set.empty)))
  }

  "SelfAwareJoinDecider" should {

    "return true if a target matches selfContactPoint" in {
      ClusterBootstrap(system).setSelfContactPoint(s"http://10.0.0.2:$managementPort/test")
      val decider = new LowestAddressJoinDecider(system, settings)
      val selfContactPoint = decider.selfContactPoint
      val info = seedNodes
      val target = info.seedNodesObservations.toList.map(_.contactPoint).sorted.headOption
      target.exists(decider.matchesSelf(_, selfContactPoint)) should ===(true)
    }

    "be able to join self if all conditions met" in {
      val decider = new LowestAddressJoinDecider(system, settings)
      val info = seedNodes
      val target = info.seedNodesObservations.toList.map(_.contactPoint).sorted.headOption
      target.exists(decider.canJoinSelf(_, info)) should ===(true)
    }

    "not join self if `new-cluster-enabled=off`, even if all conditions met" in {
      val decider = new LowestAddressJoinDecider(system, settings)
      decider.decide(seedNodes).futureValue should ===(KeepProbing)
    }
  }
}

class SelfAwareJoinDeciderIPv6Spec extends JoinDeciderSpec {

  override val remotingPort = 0

  val disabled =
    ConfigFactory.parseString("pekko.management.cluster.bootstrap.new-cluster-enabled=off")

  override lazy val system = ActorSystem("join-decider-spec-system-selfaware-ipv6", disabled.withFallback(config))

  val settings = ClusterBootstrapSettings(system.settings.config, NoLogging)

  val contactIPv6A = ResolvedTarget(
    host = "240b-c0e0-202-5e2b-b424-2-0-450.default.pod.cluster.local",
    port = None,
    address = Some(InetAddress.getByName("240b:c0e0:202:5e2b:b424:2:0:450")))

  val contactIPv6B = ResolvedTarget(
    host = "240b-c0e0-202-5e2b-b424-2-0-cc4.default.pod.cluster.local",
    port = None,
    address = Some(InetAddress.getByName("240b:c0e0:202:5e2b:b424:2:0:cc4")))

  val contactIPv6C = ResolvedTarget(
    host = "240b-c0e0-202-5e2b-b424-2-0-cc5.default.pod.cluster.local",
    port = None,
    address = Some(InetAddress.getByName("240b:c0e0:202:5e2b:b424:2:0:cc5")))

  def seedNodesIPv6 = {
    val now = LocalDateTime.now()
    new SeedNodesInformation(
      currentTime = now,
      contactPointsChangedAt = now.minusSeconds(6),
      contactPoints = Set(contactIPv6A, contactIPv6B, contactIPv6C),
      seedNodesObservations = Set(
        new SeedNodesObservation(
          now.minusSeconds(1),
          contactIPv6A,
          Address("pekko", "join-decider-spec-system-selfaware-ipv6", "[240b:c0e0:202:5e2b:b424:2:0:450]", 7355),
          Set.empty),
        new SeedNodesObservation(
          now.minusSeconds(1),
          contactIPv6B,
          Address("pekko", "join-decider-spec-system-selfaware-ipv6", "[240b:c0e0:202:5e2b:b424:2:0:cc4]", 7355),
          Set.empty),
        new SeedNodesObservation(
          now.minusSeconds(1),
          contactIPv6C,
          Address("pekko", "join-decider-spec-system-selfaware-ipv6", "c", 7355),
          Set.empty)))
  }

  "SelfAwareJoinDecider (IPv6)" should {

    "return true if a target matches selfContactPoint" in {
      ClusterBootstrap(system).setSelfContactPoint(s"http://[240b:c0e0:202:5e2b:b424:2:0:450]:$managementPort/test")
      val decider = new LowestAddressJoinDecider(system, settings)
      val selfContactPoint = decider.selfContactPoint
      val info = seedNodesIPv6
      val target = info.seedNodesObservations.toList.map(_.contactPoint).sorted.headOption
      target.exists(decider.matchesSelf(_, selfContactPoint)) should ===(true)
    }

    "be able to join self if all conditions met" in {
      val decider = new LowestAddressJoinDecider(system, settings)
      val info = seedNodesIPv6
      val target = info.seedNodesObservations.toList.map(_.contactPoint).sorted.headOption
      target.exists(decider.canJoinSelf(_, info)) should ===(true)
    }

    "not join self if `new-cluster-enabled=off`, even if all conditions met" in {
      val decider = new LowestAddressJoinDecider(system, settings)
      decider.decide(seedNodesIPv6).futureValue should ===(KeepProbing)
    }
  }
}
