// Copyright 2023 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.

import Foundation
import XCTest

@testable import FirebaseAuth
import FirebaseCore

#if os(iOS)
  @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  class OAuthProviderTests: RPCBaseTests {
    static let kFakeAuthorizedDomain = "test.firebaseapp.com"
    static let kFakeAuthorizedWebDomain = "test.web.app"
    private let kFakeAccessToken = "fakeAccessToken"
    private let kFakeIDToken = "fakeIDToken"
    private let kFakeProviderID = "fakeProviderID"
    static let kFakeAPIKey = "asdfghjkl"
    static let kFakeEmulatorHost = "emulatorhost"
    static let kFakeEmulatorPort = 12345
    static let kFakeClientID = "123456.apps.googleusercontent.com"
    static let kFakeOAuthResponseURL = "fakeOAuthResponseURL"
    static let kFakeFirebaseAppID = "1:123456789:ios:123abc456def"
    static let kFakeEncodedFirebaseAppID = "app-1-123456789-ios-123abc456def"
    static let kFakeTenantID = "tenantID"
    static let kFakeReverseClientID = "com.googleusercontent.apps.123456"

    // Switches for testing different OAuth test flows
    // TODO: Consider using an enum for these instead.
    static var testTenantID = false
    static var testCancel = false
    static var testErrorString = false
    static var testInternalError = false
    static var testInvalidClientID = false
    static var testUnknownError = false
    static var testAppID = false
    static var testEmulator = false
    static var testAppCheck = false

    static var auth: Auth?

    /** @fn testObtainingOAuthCredentialNoIDToken
        @brief Tests the correct creation of an OAuthCredential without an IDToken.
     */
    func testObtainingOAuthCredentialNoIDToken() throws {
      initApp(#function)
      let credential = OAuthProvider.credential(providerID: .apple,
                                                accessToken: kFakeAccessToken)
      XCTAssertEqual(credential.accessToken, kFakeAccessToken)
      XCTAssertEqual(credential.provider, AuthProviderID.apple.rawValue)
      XCTAssertNil(credential.idToken)
    }

    /** @fn testObtainingOAuthCredentialWithFullName
        @brief Tests the correct creation of an OAuthCredential with a fullName.
     */
    func testObtainingOAuthCredentialWithFullName() throws {
      let kFakeGivenName = "Paul"
      let kFakeFamilyName = "B"
      var fullName = PersonNameComponents()
      fullName.givenName = kFakeGivenName
      fullName.familyName = kFakeFamilyName
      initApp(#function)
      let credential = OAuthProvider.appleCredential(withIDToken: kFakeIDToken, rawNonce: nil,
                                                     fullName: fullName)
      XCTAssertEqual(credential.fullName, fullName)
      XCTAssertEqual(credential.provider, "apple.com")
      XCTAssertEqual(credential.idToken, kFakeIDToken)
      XCTAssertNil(credential.accessToken)
    }

    /** @fn testObtainingOAuthCredentialWithIDToken
        @brief Tests the correct creation of an OAuthCredential with an IDToken
     */
    func testObtainingOAuthCredentialWithIDToken() throws {
      initApp(#function)
      let credential = OAuthProvider.credential(providerID: .email,
                                                idToken: kFakeIDToken,
                                                accessToken: kFakeAccessToken)
      XCTAssertEqual(credential.accessToken, kFakeAccessToken)
      XCTAssertEqual(credential.provider, AuthProviderID.email.rawValue)
      XCTAssertEqual(credential.idToken, kFakeIDToken)
    }

    /** @fn testObtainingOAuthProvider
        @brief Tests the correct creation of an FIROAuthProvider instance.
     */
    func testObtainingOAuthProvider() throws {
      initApp(#function)
      let provider = OAuthProvider(providerID: kFakeProviderID, auth: OAuthProviderTests.auth!)
      XCTAssertEqual(provider.providerID, kFakeProviderID)
    }

    /** @fn testGetCredentialWithUIDelegateWithClientID
        @brief Tests a successful invocation of @c getCredentialWithUIDelegate
     */
    func testGetCredentialWithUIDelegateWithClientID() throws {
      initApp(#function)
      try testOAuthFlow(description: #function)
    }

    /** @fn testGetCredentialWithUIDelegateWithTenantID
        @brief Tests a successful invocation of @c getCredentialWithUIDelegate:completion:
     */
    func testGetCredentialWithUIDelegateWithTenantID() throws {
      initApp(#function)

      // Update tenantID on workqueue to enable _protectedDataDidBecomeAvailableObserver to finish
      // init.
      kAuthGlobalWorkQueue.sync {
        OAuthProviderTests.auth?.tenantID = OAuthProviderTests.kFakeTenantID
      }
      OAuthProviderTests.testTenantID = true
      try testOAuthFlow(description: #function)
      OAuthProviderTests.auth?.tenantID = nil
      OAuthProviderTests.testTenantID = false
    }

    /** @fn testGetCredentialWithUIDelegateUserCancellationWithClientID
        @brief Tests an unsuccessful invocation of @c testGetCredentialWithUIDelegateUserCancellationWithClientID due to user
            cancelation.
     */
    func testGetCredentialWithUIDelegateUserCancellationWithClientID() throws {
      initApp(#function)
      OAuthProviderTests.testCancel = true
      try testOAuthFlow(description: #function)
      OAuthProviderTests.testCancel = false
    }

    /** @fn testGetCredentialWithUIDelegateNetworkRequestFailedWithClientID
        @brief Tests an unsuccessful invocation of @c getCredentialWithUIDelegate due to a
            failed network request within the web context.
     */
    func testGetCredentialWithUIDelegateNetworkRequestFailedWithClientID() throws {
      initApp(#function)
      OAuthProviderTests.testErrorString = true
      try testOAuthFlow(description: #function)
      OAuthProviderTests.testErrorString = false
    }

    /** @fn testGetCredentialWithUIDelegateInternalErrorWithClientID
        @brief Tests an unsuccessful invocation of @c getCredentialWithUIDelegate due to an
            internal error within the web context.
     */
    func testGetCredentialWithUIDelegateInternalErrorWithClientID() throws {
      initApp(#function)
      OAuthProviderTests.testInternalError = true
      try testOAuthFlow(description: #function)
      OAuthProviderTests.testInternalError = false
    }

    /** @fn testGetCredentialWithUIDelegateInvalidClientID
        @brief Tests an unsuccessful invocation of @c getCredentialWithUIDelegate due to an
            use of an invalid client ID.
     */
    func testGetCredentialWithUIDelegateInvalidClientID() throws {
      initApp(#function)
      OAuthProviderTests.testInvalidClientID = true
      try testOAuthFlow(description: #function)
      OAuthProviderTests.testInvalidClientID = false
    }

    /** @fn testGetCredentialWithUIDelegateUnknownErrorWithClientID
        @brief Tests an unsuccessful invocation of @c getCredentialWithUIDelegate due to an
            unknown error.
     */
    func testGetCredentialWithUIDelegateUnknownErrorWithClientID() throws {
      initApp(#function)
      OAuthProviderTests.testUnknownError = true
      try testOAuthFlow(description: #function)
      OAuthProviderTests.testUnknownError = false
    }

    /** @fn testGetCredentialWithUIDelegateWithFirebaseAppID
        @brief Tests a successful invocation of @c getCredentialWithUIDelegate
     */
    func testGetCredentialWithUIDelegateWithFirebaseAppID() throws {
      initApp(#function, useAppID: true, omitClientID: true,
              scheme: OAuthProviderTests.kFakeEncodedFirebaseAppID)
      OAuthProviderTests.testAppID = true
      try testOAuthFlow(description: #function)
      OAuthProviderTests.testAppID = false
    }

    /** @fn testGetCredentialWithUIDelegateWithFirebaseAppIDWhileClientIdPresent
        @brief Tests a successful invocation of @c getCredentialWithUIDelegate when the
       client ID is present in the plist file, but the encoded app ID is the registered custom URL
       scheme.
     */
    func testGetCredentialWithUIDelegateWithFirebaseAppIDWhileClientIdPresent() throws {
      initApp(#function, useAppID: true, scheme: OAuthProviderTests.kFakeEncodedFirebaseAppID)
      OAuthProviderTests.testAppID = true
      try testOAuthFlow(description: #function)
      OAuthProviderTests.testAppID = false
    }

    /** @fn testGetCredentialWithUIDelegateUseEmulator
        @brief Tests a successful invocation of @c getCredentialWithUIDelegate when using the emulator.
     */
    func testGetCredentialWithUIDelegateUseEmulator() throws {
      initApp(#function, useAppID: true)
      OAuthProviderTests.auth?.requestConfiguration.emulatorHostAndPort =
        "\(OAuthProviderTests.kFakeEmulatorHost):\(OAuthProviderTests.kFakeEmulatorPort)"
      OAuthProviderTests.testEmulator = true
      try testOAuthFlow(description: #function)
      OAuthProviderTests.testEmulator = false
    }

    /** @fn testGetCredentialWithUIDelegateWithAppCheckToken
        @brief Tests a successful invocation of @c getCredentialWithUIDelegate
     */
    func testGetCredentialWithUIDelegateWithAppCheckToken() throws {
      let fakeAppCheck = FakeAppCheck()
      initApp(#function, useAppID: true)
      OAuthProviderTests.auth?.requestConfiguration.appCheck = fakeAppCheck
      OAuthProviderTests.testAppCheck = true
      try testOAuthFlow(description: #function)
      OAuthProviderTests.testAppCheck = false
    }

    /** @fn testOAuthCredentialCoding
        @brief Tests successful archiving and unarchiving of @c GoogleAuthCredential.
     */
    func testOAuthCredentialCoding() throws {
      let kAccessToken = "accessToken"
      let kIDToken = "idToken"
      let kRawNonce = "nonce"
      let kSecret = "sEcret"
      let kFullName = PersonNameComponents()
      let kPendingToken = "pendingToken"

      let credential = OAuthCredential(withProviderID: "dummyProvider",
                                       idToken: kIDToken,
                                       rawNonce: kRawNonce,
                                       accessToken: kAccessToken,
                                       secret: kSecret,
                                       fullName: kFullName,
                                       pendingToken: kPendingToken)

      XCTAssertTrue(OAuthCredential.supportsSecureCoding)
      let data = try NSKeyedArchiver.archivedData(
        withRootObject: credential,
        requiringSecureCoding: true
      )
      let unarchivedCredential = try XCTUnwrap(NSKeyedUnarchiver.unarchivedObject(
        ofClasses: [OAuthCredential.self, NSPersonNameComponents.self], from: data
      ) as? OAuthCredential)
      XCTAssertEqual(unarchivedCredential.idToken, kIDToken)
      XCTAssertEqual(unarchivedCredential.rawNonce, kRawNonce)
      XCTAssertEqual(unarchivedCredential.accessToken, kAccessToken)
      XCTAssertEqual(unarchivedCredential.secret, kSecret)
      XCTAssertEqual(unarchivedCredential.fullName, kFullName)
      XCTAssertEqual(unarchivedCredential.pendingToken, kPendingToken)
      XCTAssertEqual(unarchivedCredential.provider, OAuthProvider.id)
    }

    private func initApp(_ functionName: String, useAppID: Bool = false, omitClientID: Bool = false,
                         scheme: String = OAuthProviderTests.kFakeReverseClientID) {
      let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000",
                                    gcmSenderID: "00000000000000000-00000000000-000000000")
      options.apiKey = OAuthProviderTests.kFakeAPIKey
      options.projectID = "myProjectID"
      if useAppID {
        options.googleAppID = OAuthProviderTests.kFakeFirebaseAppID
      }
      if !omitClientID {
        options.clientID = OAuthProviderTests.kFakeClientID
      }

      let strippedName = functionName.replacingOccurrences(of: "(", with: "")
        .replacingOccurrences(of: ")", with: "")
      FirebaseApp.configure(name: strippedName, options: options)
      OAuthProviderTests.auth = Auth.auth(app: FirebaseApp.app(name: strippedName)!)
      OAuthProviderTests.auth = Auth(
        app: FirebaseApp.app(name: strippedName)!,
        backend: authBackend
      )
      OAuthProviderTests.auth?.mainBundleUrlTypes =
        [["CFBundleURLSchemes": [scheme]]]
    }

    private func testOAuthFlow(description: String,
                               with fakeAppCheck: FakeAppCheck? = nil) throws {
      let expectation = self.expectation(description: description)
      let provider = OAuthProvider(providerID: kFakeProviderID, auth: OAuthProviderTests.auth!)

      // Use fake authURLPresenter so we can test the parameters that get sent to it.
      OAuthProviderTests.auth?.authURLPresenter = FakePresenter()

      // 1. Setup fakes and parameters for getCredential.
      if !OAuthProviderTests.testEmulator {
        let projectConfigExpectation = self.expectation(description: "projectConfiguration")
        rpcIssuer.projectConfigRequester = { request in
          // 3. Validate the created Request instance.
          XCTAssertEqual(request.apiKey, OAuthProviderTests.kFakeAPIKey)
          XCTAssertEqual(request.endpoint, "getProjectConfig")
          // 4. Fulfill the expectation.
          projectConfigExpectation.fulfill()
          do {
            // 5. Send the response from the fake backend.
            return try self.rpcIssuer.respond(withJSON: ["authorizedDomains": [
              OAuthProviderTests.kFakeAuthorizedWebDomain,
              OAuthProviderTests.kFakeAuthorizedDomain]])
          } catch {
            XCTFail("Failure sending response: \(error)")
            return (nil, nil)
          }
        }
      }

      class FakePresenter: NSObject, AuthWebViewControllerDelegate {
        func webViewController(_ webViewController: AuthWebViewController,
                               canHandle URL: URL) -> Bool {
          XCTFail("Do not call")
          return false
        }

        func webViewControllerDidCancel(_ webViewController: AuthWebViewController) {
          XCTFail("Do not call")
        }

        func webViewController(_ webViewController: AuthWebViewController,
                               didFailWithError error: Error) {
          XCTFail("Do not call")
        }

        func present(_ presentURL: URL, uiDelegate UIDelegate: AuthUIDelegate?,
                     callbackMatcher: @escaping (URL?) -> Bool,
                     completion: @escaping (URL?, Error?) -> Void) {
          // 6. Verify flow triggers present in the FakePresenter class with the right parameters.
          if OAuthProviderTests.testEmulator {
            XCTAssertEqual(presentURL.scheme, "http")
            XCTAssertEqual(presentURL.host, OAuthProviderTests.kFakeEmulatorHost)
            XCTAssertEqual(presentURL.port, OAuthProviderTests.kFakeEmulatorPort)
            XCTAssertEqual(presentURL.path, "/emulator/auth/handler")
          } else {
            XCTAssertEqual(presentURL.scheme, "https")
            XCTAssertEqual(presentURL.host, OAuthProviderTests.kFakeAuthorizedDomain)
            XCTAssertEqual(presentURL.path, "/__/auth/handler")
          }
          let params = AuthWebUtils.dictionary(withHttpArgumentsString: presentURL.query)
          XCTAssertEqual(params["ibi"], Bundle.main.bundleIdentifier)
          if OAuthProviderTests.testAppID {
            XCTAssertEqual(params["appId"], OAuthProviderTests.kFakeFirebaseAppID)
          } else {
            XCTAssertEqual(params["clientId"], OAuthProviderTests.kFakeClientID)
          }
          XCTAssertEqual(params["apiKey"], OAuthProviderTests.kFakeAPIKey)
          XCTAssertEqual(params["authType"], "signInWithRedirect")
          XCTAssertNotNil(params["v"])
          if OAuthProviderTests.testTenantID {
            XCTAssertEqual(params["tid"], OAuthProviderTests.kFakeTenantID)
          } else {
            XCTAssertNil(params["tid"])
          }
          let appCheckToken = presentURL.fragment
          let verifyAppCheckToken = OAuthProviderTests.testAppCheck ? "fac=fakeAppCheckToken" : nil
          XCTAssertEqual(appCheckToken, verifyAppCheckToken)

          // 7. Test callbackMatcher
          let kFakeRedirectStart = OAuthProviderTests
            .testAppID ? "app-1-123456789-ios-123abc456def" :
            OAuthProviderTests.kFakeReverseClientID
          let kFakeRedirectURLBase = kFakeRedirectStart + "://firebaseauth/" +
            "link?deep_link_id=https%3A%2F%2Fexample.firebaseapp.com%2F__%2Fauth%2Fcallback%3F"
          var kFakeRedirectURLRest = "authType%3DsignInWithRedirect%26link%3D"

          if OAuthProviderTests.testInternalError {
            kFakeRedirectURLRest = "firebaseError%3D%257B%2522code%2522%253" +
              "A%2522auth%252Finternal-error%2522%252C%2522message%2522%253A%2522Internal%2520" +
              "error%2520.%2522%257D%26authType%3DsignInWithRedirect"
          } else if OAuthProviderTests.testInvalidClientID {
            kFakeRedirectURLRest = "firebaseError%3D%257B%2522code%2522%253A%2522auth" +
              "%252Finvalid-oauth-client-id%2522%252C%2522message%2522%253A%2522The%2520OAuth%2520client%" +
              "2520ID%2520provided%2520is%2520either%2520invalid%2520or%2520does%2520not%2520match%2520the%" +
              "2520specified%2520API%2520key.%2522%257D%26authType%3DsignInWithRedirect"
          } else if OAuthProviderTests.testErrorString {
            kFakeRedirectURLRest = "firebaseError%3D%257B%2522code%2" +
              "522%253A%2522auth%252Fnetwork-request-failed%2522%252C%2522message%2522%253A%2522The%" +
              "2520network%2520request%2520failed%2520.%2522%257D%26authType%3DsignInWithRedirect"
          } else if OAuthProviderTests.testUnknownError {
            kFakeRedirectURLRest = "firebaseError%3D%257B%2522code%2522%253A%2522auth%2" +
              "52Funknown-error-id%2522%252C%2522message%2522%253A%2522The%2520OAuth%2520client%2520ID%" +
              "2520provided%2520is%2520either%2520invalid%2520or%2520does%2520not%2520match%2520the%2520" +
              "specified%2520API%2520key.%2522%257D%26authType%3DsignInWithRedirect"
          }
          var redirectURL = "\(kFakeRedirectURLBase)\(kFakeRedirectURLRest)"
          // Add fake OAuthResponse to callback.
          if !OAuthProviderTests.testErrorString, !OAuthProviderTests.testInternalError,
             !OAuthProviderTests.testInvalidClientID, !OAuthProviderTests.testUnknownError {
            redirectURL += OAuthProviderTests.kFakeOAuthResponseURL
          }

          // Verify that the URL is rejected by the callback matcher without the event ID.
          XCTAssertFalse(callbackMatcher(URL(string: "\(redirectURL)")))

          // Verify that the URL is accepted by the callback matcher with the matching event ID.
          let redirectWithEventID =
            "\(redirectURL)%26eventId%3D\(params["eventId"] ?? "missingEventID")"
          let originalComponents = URLComponents(string: redirectWithEventID)!
          XCTAssertTrue(callbackMatcher(originalComponents.url))

          var components = originalComponents
          components.query = "https"
          XCTAssertFalse(callbackMatcher(components.url))

          components = originalComponents
          components.host = "badhost"
          XCTAssertFalse(callbackMatcher(components.url))

          components = originalComponents
          components.path = "badpath"
          XCTAssertFalse(callbackMatcher(components.url))

          components = originalComponents
          components.query = "badquery"
          XCTAssertFalse(callbackMatcher(components.url))

          // 8. Do the callback to the original call.
          kAuthGlobalWorkQueue.async {
            if OAuthProviderTests.testCancel {
              completion(nil, AuthErrorUtils.webContextCancelledError(message: nil))
            } else {
              completion(originalComponents.url, nil)
            }
          }
        }
      }

      // 2. Request the credential.
      provider.getCredentialWith(nil) { credential, error in

        // 9. After the response triggers the callback, verify the values in the callback credential
        XCTAssertTrue(Thread.isMainThread)
        if OAuthProviderTests.testCancel {
          XCTAssertNil(credential)
          XCTAssertEqual((error as? NSError)?.code, AuthErrorCode.webContextCancelled.rawValue)
        } else if OAuthProviderTests.testErrorString {
          XCTAssertNil(credential)
          XCTAssertEqual((error as? NSError)?.code, AuthErrorCode.webNetworkRequestFailed.rawValue)
        } else if OAuthProviderTests.testInternalError {
          XCTAssertNil(credential)
          XCTAssertEqual((error as? NSError)?.code, AuthErrorCode.webInternalError.rawValue)
        } else if OAuthProviderTests.testInvalidClientID {
          XCTAssertNil(credential)
          XCTAssertEqual((error as? NSError)?.code, AuthErrorCode.invalidClientID.rawValue)
        } else if OAuthProviderTests.testUnknownError {
          XCTAssertNil(credential)
          XCTAssertEqual(
            (error as? NSError)?.code,
            AuthErrorCode.webSignInUserInteractionFailure.rawValue
          )
        } else {
          XCTAssertNil(error)
          let oAuthCredential = credential as? OAuthCredential
          XCTAssertEqual(
            oAuthCredential?.OAuthResponseURLString,
            OAuthProviderTests.kFakeOAuthResponseURL
          )
        }
        expectation.fulfill()
      }
      waitForExpectations(timeout: 5)
    }
  }
#endif
