// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0

package providers

import (
	"context"
	"encoding/json"
	"net"
	"net/http"
	"testing"
	"time"

	"github.com/lestrrat-go/jwx/v2/jws"
	"github.com/openpubkey/openpubkey/providers/mocks"
	"github.com/openpubkey/openpubkey/util"
	"github.com/stretchr/testify/require"
)

func TestStandardProviders(t *testing.T) {
	t.Parallel()
	testCases := []struct {
		name         string
		gqSign       bool
		providerName string
	}{
		{name: "happy case Google",
			providerName: "google",
			gqSign:       false,
		},
		{name: "happy case Google (GQ sign)",
			providerName: "google",
			gqSign:       true,
		},
		{name: "happy case Azure",
			providerName: "azure",
			gqSign:       false,
		},
		{name: "happy case Azure (GQ sign)",
			providerName: "azure",
			gqSign:       true,
		},
		{name: "happy case Gitlab",
			providerName: "gitlab",
			gqSign:       false,
		},
		{name: "happy case Gitlab (GQ sign)",
			providerName: "gitlab",
			gqSign:       true,
		},
		{name: "happy case Hello",
			providerName: "hello",
			gqSign:       false,
		},
		{name: "happy case Hello (GQ sign)",
			providerName: "hello",
			gqSign:       true,
		},
	}
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			var op OpenIdProvider
			var err error

			switch tc.providerName {
			case "google":
				opts := GetDefaultGoogleOpOptions()
				opts.GQSign = tc.gqSign
				op, err = CreateMockGoogleOpWithOpts(opts,
					mocks.UserBrowserInteractionMock{
						SubjectId: "alice@gmail.com",
					})
				require.NoError(t, err, tc.name)
			case "azure":
				opts := GetDefaultAzureOpOptions()
				opts.GQSign = tc.gqSign
				op, err = CreateMockAzureOpWithOpts(opts,
					mocks.UserBrowserInteractionMock{
						SubjectId: "alice@hotmail.com",
					})
				require.NoError(t, err, tc.name)
			case "hello":
				opts := GetDefaultHelloOpOptions()
				opts.GQSign = tc.gqSign
				op, err = CreateMockHelloOpWithOpts(opts,
					mocks.UserBrowserInteractionMock{
						SubjectId: "alice@gmail.com",
					})
				require.NoError(t, err, tc.name)
			case "gitlab":
				opts := GetDefaultGitlabOpOptions()
				opts.GQSign = tc.gqSign
				op, err = CreateMockGitlabOpWithOpts(opts,
					mocks.UserBrowserInteractionMock{
						SubjectId: "alice@gmail.com",
					})
				require.NoError(t, err, tc.name)
			default:
				t.Fatalf("unsupported provider name: %s", tc.providerName)
			}

			cic := GenCIC(t)

			tokens, err := op.RequestTokens(context.Background(), cic)
			require.NoError(t, err, tc.name)
			idToken := tokens.IDToken

			cicHash, err := cic.Hash()
			require.NoError(t, err, tc.name)
			require.NotNil(t, cicHash, tc.name)

			headerB64, payloadB64, _, err := jws.SplitCompact(idToken)
			require.NoError(t, err, tc.name)
			headerJson, err := util.Base64DecodeForJWT(headerB64)
			require.NoError(t, err, tc.name)

			if tc.gqSign {
				headers := jws.NewHeaders()
				err = json.Unmarshal(headerJson, &headers)
				require.NoError(t, err, tc.name)
				algFound := headers.Algorithm()
				require.Equal(t, string("GQ256"), algFound.String(), "alg in jwt header should match GQ256")
			} else {
				payload, err := util.Base64DecodeForJWT(payloadB64)
				require.NoError(t, err, tc.name)
				require.Contains(t, string(payload), string(cicHash), tc.name)
			}
			require.Contains(t, string(tokens.RefreshToken), "mock-refresh-token", tc.name)
			require.Equal(t, "mock-access-token", string(tokens.AccessToken), tc.name)

			err = op.VerifyIDToken(context.Background(), idToken, cic)
			require.NoError(t, err, tc.name)

			switch op := op.(type) {
			case RefreshableOpenIdProvider:
				reTokens, err := op.RefreshTokens(context.Background(), tokens.RefreshToken)
				require.NoError(t, err, tc.name)

				require.Contains(t, string(reTokens.RefreshToken), "mock-refresh-token", tc.name)
				require.Equal(t, "mock-access-token", string(reTokens.AccessToken), tc.name)

				err = op.VerifyRefreshedIDToken(context.Background(), tokens.IDToken, reTokens.IDToken)
				require.NoError(t, err, tc.name)

				require.NotEqual(t, tc.providerName, "hello", tc.name, "hello provider is not refreshable")
			default:
				// Make sure a bug doesn't cause us to skip refreshed ID Token tests
				require.NotEqual(t, tc.providerName, "google", tc.name, "google provider should be refreshable")
				require.NotEqual(t, tc.providerName, "azure", tc.name, "azure provider should be refreshable")
			}
		})
	}
}

func TestDefaultConstructors(t *testing.T) {
	googOp := NewGoogleOp()
	require.NotNil(t, googOp, "Google provider should be created")

	azureOp := NewAzureOp()
	require.NotNil(t, azureOp, "Azure provider should be created")

	GitlabOp := NewGitlabOp()
	require.NotNil(t, GitlabOp, "Gitlab provider should be created")

	helloOp := NewHelloOp()
	require.NotNil(t, helloOp, "Hello provider should be created")
}

func TestRemoteRedirectURI(t *testing.T) {
	// As this test opens a http listener on a specific port, it is best to not run in parallel in order to avoid port conflicts

	remoteRedirectURIExpected := "http://localhost:8182/login-callback"
	opts := GetDefaultGoogleOpOptions()
	opts.RemoteRedirectURI = remoteRedirectURIExpected
	op, err := CreateMockGoogleOpWithOpts(opts,
		mocks.UserBrowserInteractionMock{
			SubjectId: "alice@gmail.com",
		})
	require.NoError(t, err)

	// This checks that we can override the redirect URI by opening a
	// http listener to make sure we get redirected to the expected redirect URI
	// that we set above
	ctx, cancel := context.WithCancel(context.Background())
	mux := http.NewServeMux()
	mux.HandleFunc("/login-callback", func(w http.ResponseWriter, r *http.Request) {
		defer cancel()
		uriReceived := r.URL.String()
		require.Contains(t, uriReceived, "/login-callback?code=", "callback URI path should match")
	})

	ln, err := net.Listen("tcp", "localhost:8182")
	require.NoError(t, err)
	srv := &http.Server{Handler: mux}

	go func() {
		err := srv.Serve(ln)
		if err != nil && err != http.ErrServerClosed {
			t.Errorf("server error: %v", err)
		}
	}()

	t.Cleanup(func() {
		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer cancel()
		_ = srv.Shutdown(ctx)
	})

	// We only call request tokens to trigger the callback to see if the correct
	// redirect URI param is set. We do not care about the result.
	_, _ = op.RequestTokens(ctx, GenCIC(t))
}
