// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

@testable import FirebaseCoreInternal
import XCTest

class HeartbeatControllerTests: XCTestCase {
  func testFlush_WhenEmpty_ReturnsEmptyPayload() throws {
    // Given
    let controller = HeartbeatController(storage: HeartbeatStorageFake())
    // Then
    assertHeartbeatControllerFlushesEmptyPayload(controller)
  }

  func testLogAndFlush() throws {
    // Given
    let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST)
    let controller = HeartbeatController(
      storage: HeartbeatStorageFake(),
      dateProvider: { date }
    )

    assertHeartbeatControllerFlushesEmptyPayload(controller)

    // When
    controller.log("dummy_agent")
    let heartbeatPayload = controller.flush()

    // Then
    try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
      heartbeatPayload.headerValue(),
      """
      {
        "version": 2,
        "heartbeats": [
          {
            "agent": "dummy_agent",
            "dates": ["2021-11-01"]
          }
        ]
      }
      """
    )

    assertHeartbeatControllerFlushesEmptyPayload(controller)
  }

  @MainActor func testLogAndFlushAsync() throws {
    // Given
    let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST)
    let controller = HeartbeatController(
      storage: HeartbeatStorageFake(),
      dateProvider: { date }
    )
    let expectation = expectation(description: #function)

    assertHeartbeatControllerFlushesEmptyPayload(controller)

    // When
    controller.log("dummy_agent")
    controller.flushAsync { heartbeatPayload in
      // Then
      do {
        try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
          heartbeatPayload.headerValue(),
          """
          {
            "version": 2,
            "heartbeats": [
              {
                "agent": "dummy_agent",
                "dates": ["2021-11-01"]
              }
            ]
          }
          """
        )
        expectation.fulfill()
      } catch {
        XCTFail("Unexpected error: \(error)")
      }
    }
    waitForExpectations(timeout: 1.0)

    assertHeartbeatControllerFlushesEmptyPayload(controller)
  }

  func testLogAtEndOfTimePeriodAndAcceptAtStartOfNextOne() throws {
    // Given
    let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST)
    let testDate = AdjustableDate(date: date)

    let controller = HeartbeatController(
      storage: HeartbeatStorageFake(),
      dateProvider: { testDate.date }
    )

    assertHeartbeatControllerFlushesEmptyPayload(controller)

    // When
    // - Clock time 2021-11-01 @ 00:00:00 (EST)
    controller.log("dummy_agent")

    // - Advance to 2021-11-01 @ 23:59:59 (EST)
    testDate.date.addTimeInterval(60 * 60 * 24 - 1)

    controller.log("dummy_agent")

    // - Advance to 2021-11-02 @ 00:00:00 (EST)
    testDate.date.addTimeInterval(1)

    controller.log("dummy_agent")

    // Then
    let heartbeatPayload = controller.flush()

    try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
      heartbeatPayload.headerValue(),
      """
      {
        "version": 2,
        "heartbeats": [
          {
            "agent": "dummy_agent",
            "dates": [
              "2021-11-01",
              "2021-11-02"
            ]
          }
        ]
      }
      """
    )

    assertHeartbeatControllerFlushesEmptyPayload(controller)
  }

  func testDoNotLogMoreThanOnceInACalendarDay() throws {
    // Given
    let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST)
    let controller = HeartbeatController(
      storage: HeartbeatStorageFake(),
      dateProvider: { date }
    )

    // When
    controller.log("dummy_agent")
    controller.log("dummy_agent")

    // Then
    let heartbeatPayload = controller.flush()

    try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
      heartbeatPayload.headerValue(),
      """
      {
        "version": 2,
        "heartbeats": [
          {
            "agent": "dummy_agent",
            "dates": ["2021-11-01"]
          }
        ]
      }
      """
    )
  }

  func testDoNotLogMoreThanOnceInACalendarDay_AfterFlushing() throws {
    // Given
    let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST)
    let controller = HeartbeatController(
      storage: HeartbeatStorageFake(),
      dateProvider: { date }
    )

    // When
    controller.log("dummy_agent")
    let heartbeatPayload = controller.flush()
    controller.log("dummy_agent")

    // Then
    try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
      heartbeatPayload.headerValue(),
      """
      {
        "version": 2,
        "heartbeats": [
          {
            "agent": "dummy_agent",
            "dates": ["2021-11-01"]
          }
        ]
      }
      """
    )

    // Below assertion asserts that duplicate was not logged.
    assertHeartbeatControllerFlushesEmptyPayload(controller)
  }

  func testHeartbeatDatesAreStandardizedForUTC() throws {
    // Given
    let newYorkDate = try XCTUnwrap(
      DateComponents(
        calendar: .current,
        timeZone: TimeZone(identifier: "America/New_York"),
        year: 2021,
        month: 11,
        day: 01,
        hour: 23
      ).date // 2021-11-01 @ 11 PM (EST)
    )
    let heartbeatController = HeartbeatController(
      storage: HeartbeatStorageFake(),
      dateProvider: { newYorkDate }
    )

    // When
    heartbeatController.log("dummy_agent")
    let payload = heartbeatController.flush()

    // Then
    // Note below how the date was interpreted as UTC - 2021-11-02.
    try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
      payload.headerValue(),
      """
      {
        "version": 2,
        "heartbeats": [
          {
            "agent": "dummy_agent",
            "dates": ["2021-11-02"]
          }
        ]
      }
      """
    )
  }

  func testDoNotLogMoreThanOnceInACalendarDay_WhenTravelingAcrossTimeZones() throws {
    // Given
    let newYorkDate = try XCTUnwrap(
      DateComponents(
        calendar: .current,
        timeZone: TimeZone(identifier: "America/New_York"),
        year: 2021,
        month: 11,
        day: 01,
        hour: 23
      ).date // 2021-11-01 @ 11 PM (New York time zone)
    )

    let tokyoDate = try XCTUnwrap(
      DateComponents(
        calendar: .current,
        timeZone: TimeZone(identifier: "Asia/Tokyo"),
        year: 2021,
        month: 11,
        day: 02,
        hour: 23
      ).date // 2021-11-02 @ 11 PM (Tokyo time zone)
    )

    let testDate = AdjustableDate(date: newYorkDate)

    let heartbeatController = HeartbeatController(
      storage: HeartbeatStorageFake(),
      dateProvider: { testDate.date }
    )

    // When
    heartbeatController.log("dummy_agent")

    // Device travels from NYC to Tokyo.
    testDate.date = tokyoDate

    heartbeatController.log("dummy_agent")

    // Then
    let payload = heartbeatController.flush()
    try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
      payload.headerValue(),
      """
      {
        "version" : 2,
        "heartbeats" : [
          {
            "agent" : "dummy_agent",
            "dates" : [
              "2021-11-02"
            ]
          }
        ]
      }
      """
    )
  }

  func testLoggingDependsOnDateNotUserAgent() throws {
    // Given
    let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST)
    let testDate = AdjustableDate(date: date)
    let heartbeatController = HeartbeatController(
      storage: HeartbeatStorageFake(),
      dateProvider: { testDate.date }
    )

    // When
    // - Day 1
    heartbeatController.log("dummy_agent")

    // - Day 2
    testDate.date.addTimeInterval(60 * 60 * 24)
    heartbeatController.log("some_other_agent")

    // - Day 3
    testDate.date.addTimeInterval(60 * 60 * 24)
    heartbeatController.log("dummy_agent")

    // Then
    let payload = heartbeatController.flush()
    try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
      payload.headerValue(),
      """
      {
        "version": 2,
        "heartbeats": [
          {
            "agent": "dummy_agent",
            "dates": [
              "2021-11-01",
              "2021-11-03"
            ]
          },
          {
            "agent": "some_other_agent",
            "dates": [
              "2021-11-02"
            ]
          }
        ]
      }
      """
    )
  }

  func testFlushHeartbeatFromToday_WhenTodayHasAHeartbeat_ReturnsPayloadWithOnlyTodaysHeartbeat() throws {
    // Given
    let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST)
    let yesterdaysDate = date.addingTimeInterval(-1 * 60 * 60 * 24)
    let todaysDate = date
    let tomorrowsDate = date.addingTimeInterval(60 * 60 * 24)

    let testDate = AdjustableDate(date: yesterdaysDate)

    let heartbeatController = HeartbeatController(
      storage: HeartbeatStorageFake(),
      dateProvider: { testDate.date }
    )

    // When
    heartbeatController.log("yesterdays_dummy_agent")
    testDate.date = todaysDate
    heartbeatController.log("todays_dummy_agent")
    testDate.date = tomorrowsDate
    heartbeatController.log("tomorrows_dummy_agent")
    testDate.date = todaysDate

    // Then
    let payload = heartbeatController.flushHeartbeatFromToday()
    try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
      payload.headerValue(),
      """
      {
        "version": 2,
        "heartbeats": [
          {
            "agent": "todays_dummy_agent",
            "dates": ["2021-11-01"]
          }
        ]
      }
      """
    )

    let remainingPayload = heartbeatController.flush()
    try HeartbeatLoggingTestUtils.assertEqualPayloadStrings(
      remainingPayload.headerValue(),
      """
      {
        "version": 2,
        "heartbeats": [
          {
            "agent": "tomorrows_dummy_agent",
            "dates": ["2021-11-02"]
          },
          {
            "agent": "yesterdays_dummy_agent",
            "dates": ["2021-10-31"]
          }
        ]
      }
      """
    )

    assertHeartbeatControllerFlushesEmptyPayload(heartbeatController)
  }

  func testFlushHeartbeatFromToday_WhenTodayDoesNotHaveAHeartbeat_ReturnsEmptyPayload() throws {
    // Given
    let date = Date(timeIntervalSince1970: 1_635_739_200) // 2021-11-01 @ 00:00:00 (EST)
    let heartbeatController = HeartbeatController(id: #function, dateProvider: { date })
    // When
    heartbeatController.flushHeartbeatFromToday()
    // Then
    assertHeartbeatControllerFlushesEmptyPayload(heartbeatController)
  }
}

// MARK: - Fakes

private final class HeartbeatStorageFake: HeartbeatStorageProtocol, @unchecked Sendable {
  // The unchecked Sendable conformance is used to prevent warnings for the below var, which
  // violates the class's Sendable conformance. Ignoring this violation should be okay for
  // testing purposes.
  private var heartbeatsBundle: HeartbeatsBundle?

  func readAndWriteSync(using transform: (HeartbeatsBundle?) -> HeartbeatsBundle?) {
    heartbeatsBundle = transform(heartbeatsBundle)
  }

  func readAndWriteAsync(using transform: @escaping (HeartbeatsBundle?) -> HeartbeatsBundle?) {
    heartbeatsBundle = transform(heartbeatsBundle)
  }

  func getAndSet(using transform: (HeartbeatsBundle?) -> HeartbeatsBundle?) throws
    -> HeartbeatsBundle? {
    let oldHeartbeatsBundle = heartbeatsBundle
    heartbeatsBundle = transform(heartbeatsBundle)
    return oldHeartbeatsBundle
  }

  func getAndSetAsync(using transform: @escaping (FirebaseCoreInternal.HeartbeatsBundle?)
    -> FirebaseCoreInternal.HeartbeatsBundle?,
    completion: @escaping (Result<
      FirebaseCoreInternal.HeartbeatsBundle?,
      any Error
    >) -> Void) {
    let oldHeartbeatsBundle = heartbeatsBundle
    heartbeatsBundle = transform(heartbeatsBundle)
    completion(.success(oldHeartbeatsBundle))
  }
}
