/*
 * 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.rollingupdate.kubernetes

import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.actor.Address
import org.apache.pekko.actor.Props
import org.apache.pekko.cluster.Cluster
import org.apache.pekko.cluster.ClusterEvent.MemberUp
import org.apache.pekko.cluster.Member
import org.apache.pekko.cluster.MemberStatus
import org.apache.pekko.cluster.MemberStatus.Up
import org.apache.pekko.cluster.UniqueAddress
import org.apache.pekko.testkit.EventFilter
import org.apache.pekko.testkit.ImplicitSender
import org.apache.pekko.testkit.TestKit
import org.apache.pekko.testkit.TestProbe
import org.apache.pekko.util.Version
import com.github.tomakehurst.wiremock.WireMockServer
import com.github.tomakehurst.wiremock.client.MappingBuilder
import com.github.tomakehurst.wiremock.client.WireMock
import com.github.tomakehurst.wiremock.client.WireMock.aResponse
import com.github.tomakehurst.wiremock.client.WireMock.patch
import com.github.tomakehurst.wiremock.client.WireMock.patchRequestedFor
import com.github.tomakehurst.wiremock.client.WireMock.stubFor
import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo
import com.github.tomakehurst.wiremock.client.WireMock.verify
import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig
import com.github.tomakehurst.wiremock.matching.ContainsPattern
import com.github.tomakehurst.wiremock.matching.EqualToPattern
import com.typesafe.config.ConfigFactory
import org.scalatest.BeforeAndAfterAll
import org.scalatest.BeforeAndAfterEach
import org.scalatest.concurrent.Eventually
import org.scalatest.matchers.should.Matchers
import org.scalatest.time.Millis
import org.scalatest.time.Seconds
import org.scalatest.time.Span
import org.scalatest.wordspec.AnyWordSpecLike

import scala.jdk.CollectionConverters._
import scala.concurrent.duration._

object PodDeletionCostAnnotatorSpec {
  val config = ConfigFactory.parseString("""
      pekko.loggers = ["org.apache.pekko.testkit.TestEventListener"]
      pekko.actor.provider = cluster
      pekko.rollingupdate.kubernetes.pod-deletion-cost.retry-delay = 1s

      pekko.remote.artery.canonical.port = 0
      pekko.remote.artery.canonical.hostname = 127.0.0.1

      pekko.cluster.jmx.multi-mbeans-in-same-jvm = on
      pekko.coordinated-shutdown.terminate-actor-system = off
      pekko.coordinated-shutdown.run-by-actor-system-terminate = off
      pekko.test.filter-leeway = 10s
    """)
}

class PodDeletionCostAnnotatorSpec
    extends TestKit(
      ActorSystem(
        "MySpec",
        PodDeletionCostAnnotatorSpec.config
      ))
    with ImplicitSender
    with AnyWordSpecLike
    with Matchers
    with BeforeAndAfterAll
    with BeforeAndAfterEach
    with Eventually {

  private val wireMockServer = new WireMockServer(wireMockConfig().port(0))
  wireMockServer.start()
  WireMock.configureFor(wireMockServer.port())

  private val namespace = "namespace-test"
  private val podName1 = "pod-test-1"
  private val podName2 = "pod-test-2"
  private lazy val system2 = ActorSystem("MySpec", PodDeletionCostAnnotatorSpec.config)

  private def settings(podName: String) = {
    new KubernetesSettings(
      apiCaPath = "",
      apiTokenPath = "",
      apiServiceHost = "localhost",
      apiServicePort = wireMockServer.port(),
      namespace = Some(namespace),
      namespacePath = "",
      podName = podName,
      secure = false)
  }

  private def annotatorProps(pod: String) = Props(
    classOf[PodDeletionCostAnnotator],
    settings(pod),
    "apiToken",
    namespace,
    PodDeletionCostSettings(system.settings.config.getConfig("pekko.rollingupdate.kubernetes"))
  )

  override implicit val patienceConfig: PatienceConfig =
    PatienceConfig(timeout = Span(5, Seconds), interval = Span(100, Millis))

  override protected def afterAll(): Unit = {
    super.shutdown()
    TestKit.shutdownActorSystem(system2)
  }

  override protected def beforeEach(): Unit = {
    wireMockServer.resetAll()
  }

  private def podK8sPath(podName: String) = urlEqualTo("/api/v1/namespaces/" + namespace + "/pods/" + podName)

  private def stubForPods(podName: String, cost: Int = 10000, returnCode: Int) =
    stubFor(patchPodDeletionCost(podName, cost).willReturn(aResponse().withStatus(returnCode)))

  private def patchPodDeletionCost(podName: String, cost: Int = 10000): MappingBuilder =
    patch(podK8sPath(podName))
      .withHeader("Content-Type", new EqualToPattern("application/merge-patch+json"))
      .withRequestBody(new ContainsPattern(
        s"""{"metadata": {"annotations": {"controller.kubernetes.io/pod-deletion-cost": "$cost" }}}"""))

  "The pod-deletion-cost annotator, when under normal behavior" should {

    "have a single node cluster running first" in {
      val probe = TestProbe()
      Cluster(system).join(Cluster(system).selfMember.address)
      probe.awaitAssert({
          Cluster(system).selfMember.status == MemberStatus.Up
        }, 3.seconds)

    }

    "correctly annotate the cluster node" in {
      stubForPods(podName1, returnCode = 200)
      expectLogInfo(pattern = ".*Updating pod-deletion-cost annotation.*") {
        system.actorOf(annotatorProps(podName1))
      }
      eventually {
        verify(1, patchRequestedFor(podK8sPath(podName1)))
      }
    }

    "give up when failing with non-transient error" in {
      stubForPods(podName1, returnCode = 404)
      expectLogError(pattern = ".*Not retrying, check configuration.*") {
        system.actorOf(annotatorProps(podName1))
      }
    }

    "retry when failing with transient error" in {
      val scenarioName = "RetryScenario"

      // first call fails
      stubFor(
        patchPodDeletionCost(podName1)
          .inScenario(scenarioName)
          .whenScenarioStateIs("FAILING")
          .willReturn(aResponse().withStatus(500))
          .willSetStateTo("AVAILABLE"))

      // second call succeeds
      stubFor(
        patchPodDeletionCost(podName1)
          .inScenario(scenarioName)
          .whenScenarioStateIs("AVAILABLE")
          .willReturn(aResponse().withStatus(200))
          .willSetStateTo("OK"))
      wireMockServer.setScenarioState(scenarioName, "FAILING") // set starting state to failing

      assertState(scenarioName, "FAILING")
      system.actorOf(annotatorProps(podName1))
      assertState(scenarioName, "AVAILABLE")
      // after the retry backoff delay
      assertState(scenarioName, "OK")

      wireMockServer.checkForUnmatchedRequests()
    }

    "annotate a second node correctly" in {

      stubForPods(podName2, cost = 9900, returnCode = 200)

      val probe = TestProbe()
      Cluster(system2).join(Cluster(system).selfMember.address)
      probe.awaitAssert({
          Cluster(system2).selfMember.status == MemberStatus.Up
        }, 3.seconds)

      system2.actorOf(annotatorProps(podName2))
      eventually {
        verify(1, patchRequestedFor(podK8sPath(podName2)))
      }

      wireMockServer.checkForUnmatchedRequests()
    }

  }

  "The pod-deletion-cost annotator, when under retry backoff" should {

    "have a single node cluster running first" in {
      val probe = TestProbe()
      Cluster(system).join(Cluster(system).selfMember.address)
      probe.awaitAssert({
          Cluster(system).selfMember.status == MemberStatus.Up
        }, 3.seconds)
    }

    "not annotate until backoff delay expires" in {
      val scenarioName = "RetryScenario"

      // first call fails
      stubFor(
        patchPodDeletionCost(podName1)
          .inScenario(scenarioName)
          .whenScenarioStateIs("FAILING")
          .willReturn(aResponse().withStatus(500)))
      wireMockServer.setScenarioState(scenarioName, "FAILING") // set starting state to failing

      // second call succeeds
      stubFor(
        patchPodDeletionCost(podName1)
          .inScenario(scenarioName)
          .whenScenarioStateIs("AVAILABLE")
          .willReturn(aResponse().withStatus(200))
          .willSetStateTo("OK"))

      assertState(scenarioName, "FAILING")

      val underTest = expectLogWarning(".*Failed to update annotation:.*") {
        system.actorOf(annotatorProps(podName1))
      }

      wireMockServer.resetRequests()
      val dummyNewMember =
        MemberUp(Member(UniqueAddress(Address("pekko", ""), 2L), Set("dc-default"), Version("v1")).copy(Up))
      underTest ! dummyNewMember
      underTest ! dummyNewMember
      underTest ! dummyNewMember

      // no other interactions should have occurred while on backoff regardless of updates to the cluster
      verify(0, patchRequestedFor(podK8sPath(podName1)))

      wireMockServer.setScenarioState(scenarioName, "AVAILABLE")

      eventually {
        verify(1, patchRequestedFor(podK8sPath(podName1)))
      }
      assertState(scenarioName, "OK")
    }
  }

  private def assertState(scenarioName: String, state: String) = eventually {
    val scenario = wireMockServer.getAllScenarios.getScenarios.asScala.toList.find(_.getName == scenarioName).get
    scenario.getState should ===(state)
  }
  def expectLogInfo[T](pattern: String = null)(block: => T): T =
    EventFilter.info(pattern = pattern, occurrences = 1).intercept(block)(system)

  def expectLogError[T](pattern: String = null, occurrences: Int = 1)(block: => T): T =
    EventFilter.error(pattern = pattern, occurrences = occurrences).intercept(block)(system)

  def expectLogWarning[T](pattern: String = null, occurrences: Int = 1)(block: => T): T =
    EventFilter.warning(pattern = pattern, occurrences = occurrences).intercept(block)(system)

}
