package api

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/photoprism/photoprism/internal/auth/acl"
	clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
	"github.com/photoprism/photoprism/internal/config"
	"github.com/photoprism/photoprism/internal/photoprism/get"
	"github.com/photoprism/photoprism/pkg/authn"
	"github.com/photoprism/photoprism/pkg/clean"
	"github.com/photoprism/photoprism/pkg/http/header"
	"github.com/photoprism/photoprism/pkg/rnd"
)

func TestAuthAnyJWT(t *testing.T) {
	t.Run("ClusterScope", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-success")
		spec := fx.defaultClaimsSpec()
		token := fx.issue(t, spec)

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.Header.Set(header.UserAgent, "PhotoPrism Portal/1.0")
		req.RemoteAddr = "192.0.2.10:12345"
		c.Request = req

		session := authAnyJWT(c, "192.0.2.10", token, acl.ResourceCluster, nil)
		require.NotNil(t, session)
		assert.Equal(t, http.StatusOK, session.HttpStatus())
		assert.Empty(t, session.ClientUID)
		assert.Equal(t, spec.Subject, session.GetClientName())
		assert.Contains(t, session.AuthScope, "cluster")
		assert.Equal(t, spec.Issuer, session.AuthIssuer)
		assert.Equal(t, authn.MethodJWT.String(), session.AuthMethod)
		assert.Equal(t, authn.ProviderAccessToken.String(), session.AuthProvider)
		assert.Equal(t, authn.GrantJwtBearer.String(), session.GrantType)
		assert.Equal(t, "192.0.2.10", session.ClientIP)
		assert.Equal(t, "PhotoPrism Portal/1.0", session.UserAgent)
		assert.Equal(t, token, session.AuthToken())
		assert.True(t, strings.HasPrefix(session.AuthID, "jwt"))
		assert.Equal(t, session.AuthID, session.RefID)
		assert.True(t, rnd.IsRefID(session.RefID))
		assert.True(t, session.SessExpires > session.CreatedAt.Unix())
		assert.GreaterOrEqual(t, session.LastActive, session.CreatedAt.Unix())
		assert.True(t, session.GetUser().IsUnknown())
		assert.Equal(t, acl.RolePortal, session.GetClientRole())
		assert.Empty(t, session.PreviewToken)
		assert.Empty(t, session.DownloadToken)
	})
	t.Run("FilesScopeTokens", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-files")
		spec := fx.defaultClaimsSpec()
		spec.Scope = []string{"cluster", "files"}
		token := fx.issue(t, spec)

		origScope := fx.nodeConf.Options().JWTScope
		fx.nodeConf.Options().JWTScope = "cluster vision metrics files"
		get.SetConfig(fx.nodeConf)
		t.Cleanup(func() {
			fx.nodeConf.Options().JWTScope = origScope
			get.SetConfig(fx.nodeConf)
		})

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.Header.Set(header.UserAgent, "PhotoPrism Portal/1.0")
		req.RemoteAddr = "192.0.2.50:4567"
		c.Request = req

		session := authAnyJWT(c, "192.0.2.50", token, acl.ResourceFiles, acl.Permissions{acl.AccessLibrary})
		require.NotNil(t, session)
		assert.Equal(t, http.StatusOK, session.HttpStatus())
		assert.Empty(t, session.PreviewToken)
		assert.Empty(t, session.DownloadToken)
		cfg := fx.nodeConf.ClientSession(session)
		assert.Equal(t, fx.preview, cfg.PreviewToken)
		assert.Equal(t, fx.download, cfg.DownloadToken)
		assert.True(t, session.SessExpires > session.CreatedAt.Unix())
		assert.True(t, session.LastActive >= session.CreatedAt.Unix())
	})
	t.Run("ConfigScopePortalRole", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-config")
		spec := fx.defaultClaimsSpec()
		spec.Scope = []string{"cluster", "config"}
		token := fx.issue(t, spec)

		origScope := fx.nodeConf.Options().JWTScope
		fx.nodeConf.Options().JWTScope = "cluster config"
		get.SetConfig(fx.nodeConf)
		t.Cleanup(func() {
			fx.nodeConf.Options().JWTScope = origScope
			get.SetConfig(fx.nodeConf)
		})

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/config", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.Header.Set(header.UserAgent, "PhotoPrism Portal/1.0")
		req.RemoteAddr = "192.0.2.51:1234"
		c.Request = req

		session := authAnyJWT(c, "192.0.2.51", token, acl.ResourceConfig, acl.Permissions{acl.ActionView})
		require.NotNil(t, session)
		assert.Equal(t, http.StatusOK, session.HttpStatus())
		assert.Equal(t, acl.RolePortal, session.GetClientRole())
		assert.True(t, session.Valid())

		cfg := fx.nodeConf.ClientSession(session)
		require.NotNil(t, cfg)
		assert.Equal(t, string(config.ClientUser), cfg.Mode)
	})
	t.Run("ClusterCIDRAllowed", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-cidr-allow")
		spec := fx.defaultClaimsSpec()
		token := fx.issue(t, spec)

		origCIDR := fx.nodeConf.Options().ClusterCIDR
		fx.nodeConf.Options().ClusterCIDR = "192.0.2.0/24"
		get.SetConfig(fx.nodeConf)
		t.Cleanup(func() {
			fx.nodeConf.Options().ClusterCIDR = origCIDR
			get.SetConfig(fx.nodeConf)
		})

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.RemoteAddr = "192.0.2.10:2222"
		c.Request = req

		session := authAnyJWT(c, "192.0.2.10", token, acl.ResourceCluster, nil)
		require.NotNil(t, session)
		assert.Empty(t, session.ClientUID)
		assert.Equal(t, spec.Subject, session.GetClientName())
	})
	t.Run("ClusterCIDRBlocked", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-cidr-block")
		spec := fx.defaultClaimsSpec()
		token := fx.issue(t, spec)

		origCIDR := fx.nodeConf.Options().ClusterCIDR
		fx.nodeConf.Options().ClusterCIDR = "192.0.2.0/24"
		get.SetConfig(fx.nodeConf)
		t.Cleanup(func() {
			fx.nodeConf.Options().ClusterCIDR = origCIDR
			get.SetConfig(fx.nodeConf)
		})

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.RemoteAddr = "203.0.113.10:2222"
		c.Request = req

		assert.Nil(t, authAnyJWT(c, "203.0.113.10", token, acl.ResourceCluster, nil))
	})
	t.Run("JWTScopeDefaultRejectsOtherResources", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-scope-default-reject")
		spec := fx.defaultClaimsSpec()
		spec.Scope = []string{"photos"}
		token := fx.issue(t, spec)

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.RemoteAddr = "192.0.2.60:1001"
		c.Request = req

		assert.Nil(t, authAnyJWT(c, "192.0.2.60", token, acl.ResourcePhotos, nil))
	})
	t.Run("JWTScopeAllowed", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-scope-allow")
		token := fx.issue(t, fx.defaultClaimsSpec())

		orig := fx.nodeConf.Options().JWTScope
		fx.nodeConf.Options().JWTScope = "cluster vision"
		get.SetConfig(fx.nodeConf)
		t.Cleanup(func() {
			fx.nodeConf.Options().JWTScope = orig
			get.SetConfig(fx.nodeConf)
		})

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.RemoteAddr = "192.0.2.30:1001"
		c.Request = req

		sess := authAnyJWT(c, "192.0.2.30", token, acl.ResourceCluster, nil)
		require.NotNil(t, sess)
	})
	t.Run("JWTScopeAllowsSuperset", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-scope-reject")
		token := fx.issue(t, fx.defaultClaimsSpec())

		orig := fx.nodeConf.Options().JWTScope
		fx.nodeConf.Options().JWTScope = "cluster"
		get.SetConfig(fx.nodeConf)
		t.Cleanup(func() {
			fx.nodeConf.Options().JWTScope = orig
			get.SetConfig(fx.nodeConf)
		})

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.RemoteAddr = "192.0.2.40:1001"
		c.Request = req

		sess := authAnyJWT(c, "192.0.2.40", token, acl.ResourceCluster, nil)
		require.NotNil(t, sess)
	})
	t.Run("JWTScopeCustomResource", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-scope-custom")
		spec := fx.defaultClaimsSpec()
		spec.Scope = []string{"photos"}
		token := fx.issue(t, spec)

		origScope := fx.nodeConf.Options().JWTScope
		fx.nodeConf.Options().JWTScope = "photos"
		get.SetConfig(fx.nodeConf)
		t.Cleanup(func() {
			fx.nodeConf.Options().JWTScope = origScope
			get.SetConfig(fx.nodeConf)
		})

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.RemoteAddr = "192.0.2.50:2001"
		c.Request = req

		_, verifyErr := get.VerifyJWT(c.Request.Context(), token, clusterjwt.ExpectedClaims{
			Issuer:   fmt.Sprintf("portal:%s", fx.clusterUUID),
			Audience: fmt.Sprintf("node:%s", fx.nodeUUID),
			Scope:    []string{"photos"},
			JWKSURL:  fx.nodeConf.JWKSUrl(),
		})
		require.NoError(t, verifyErr)

		sess := authAnyJWT(c, "192.0.2.50", token, acl.ResourcePhotos, nil)
		require.NotNil(t, sess)
	})
	t.Run("VisionScope", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-vision")
		spec := fx.defaultClaimsSpec()
		spec.Scope = []string{"vision"}
		token := fx.issue(t, spec)

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/vision/status", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.RemoteAddr = "198.18.0.5:8080"
		c.Request = req

		session := authAnyJWT(c, "198.18.0.5", token, acl.ResourceVision, nil)
		require.NotNil(t, session)
		assert.Equal(t, http.StatusOK, session.HttpStatus())
		assert.Contains(t, session.AuthScope, "vision")
		assert.Equal(t, spec.Issuer, session.AuthIssuer)
	})
	t.Run("RejectsMalformedOrUnknown", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-invalid")
		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
		req.Header.Set("Authorization", "Bearer invalid-token-without-dots")
		req.RemoteAddr = "192.0.2.10:12345"
		c.Request = req

		assert.Nil(t, authAnyJWT(c, "192.0.2.10", "invalid-token-without-dots", acl.ResourceCluster, nil))

		// Ensure we also bail out when JWKS URL is not configured.
		fx.nodeConf.SetJWKSUrl("")
		get.SetConfig(fx.nodeConf)
		assert.Nil(t, authAnyJWT(c, "192.0.2.10", "", acl.ResourceCluster, nil))
	})
	t.Run("NoIssuerMatch", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-no-issuer")
		spec := fx.defaultClaimsSpec()
		token := fx.issue(t, spec)

		// Remove all issuer candidates.
		origPortal := fx.nodeConf.Options().PortalUrl
		origSite := fx.nodeConf.Options().SiteUrl
		origClusterUUID := fx.nodeConf.Options().ClusterUUID
		fx.nodeConf.Options().PortalUrl = ""
		fx.nodeConf.Options().SiteUrl = ""
		fx.nodeConf.Options().ClusterUUID = ""
		get.SetConfig(fx.nodeConf)
		t.Cleanup(func() {
			fx.nodeConf.Options().PortalUrl = origPortal
			fx.nodeConf.Options().SiteUrl = origSite
			fx.nodeConf.Options().ClusterUUID = origClusterUUID
			get.SetConfig(fx.nodeConf)
		})

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.RemoteAddr = "203.0.113.5:2222"
		c.Request = req

		assert.Nil(t, authAnyJWT(c, "203.0.113.5", token, acl.ResourceCluster, nil))
	})
	t.Run("UnsupportedResource", func(t *testing.T) {
		fx := newPortalJWTFixture(t, "cluster-jwt-unsupported")
		token := fx.issue(t, fx.defaultClaimsSpec())

		gin.SetMode(gin.TestMode)
		w := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(w)
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
		req.Header.Set("Authorization", "Bearer "+token)
		req.RemoteAddr = "198.51.100.7:9999"
		c.Request = req

		assert.Nil(t, authAnyJWT(c, "198.51.100.7", token, acl.ResourcePhotos, nil))
	})
}

func TestJwtIssuerCandidates(t *testing.T) {
	t.Run("IncludesAllSources", func(t *testing.T) {
		conf := config.NewConfig(config.CliTestContext())
		conf.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
		conf.Options().PortalUrl = "https://portal.example.test/"
		conf.Options().SiteUrl = "https://site.example.test/base/"

		orig := get.Config()
		get.SetConfig(conf)
		t.Cleanup(func() { get.SetConfig(orig) })

		cands := jwtIssuerCandidates(conf)
		assert.Equal(t, []string{
			"portal:11111111-1111-4111-8111-111111111111",
			"https://portal.example.test",
			"https://site.example.test/base",
		}, cands)
	})
	t.Run("DefaultsToLocalhost", func(t *testing.T) {
		conf := config.NewConfig(config.CliTestContext())
		conf.Options().ClusterUUID = ""
		conf.Options().PortalUrl = ""
		conf.Options().SiteUrl = ""

		assert.Equal(t, []string{"http://localhost:2342"}, jwtIssuerCandidates(conf))
	})
}

func TestShouldAttemptJWT(t *testing.T) {
	gin.SetMode(gin.TestMode)
	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)
	req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
	c.Request = req

	assert.True(t, shouldAttemptJWT(c, "a.b.c"))
	assert.False(t, shouldAttemptJWT(nil, "a.b.c"))
	assert.False(t, shouldAttemptJWT(c, "invalidtoken"))
	assert.False(t, shouldAttemptJWT(c, ""))
}

func TestNodeAllowsJWT(t *testing.T) {
	fx := newPortalJWTFixture(t, "node-allows")
	conf := fx.nodeConf

	assert.True(t, shouldAllowJWT(conf, "192.0.2.9"))

	origCIDR := conf.Options().ClusterCIDR
	conf.Options().ClusterCIDR = "192.0.2.0/24"
	assert.True(t, shouldAllowJWT(conf, "192.0.2.25"))
	assert.False(t, shouldAllowJWT(conf, "203.0.113.1"))
	conf.Options().ClusterCIDR = origCIDR

	origJWKS := conf.JWKSUrl()
	conf.SetJWKSUrl("")
	assert.False(t, shouldAllowJWT(conf, "192.0.2.25"))
	conf.SetJWKSUrl(origJWKS)

	assert.False(t, shouldAllowJWT(nil, "192.0.2.25"))
}

func TestExpectedClaimsFor(t *testing.T) {
	fx := newPortalJWTFixture(t, "expected-claims")

	claims := expectedClaimsFor(fx.nodeConf, "cluster")
	assert.Equal(t, fmt.Sprintf("node:%s", fx.nodeUUID), claims.Audience)
	assert.Equal(t, []string{"cluster"}, claims.Scope)
	assert.Equal(t, fx.nodeConf.JWKSUrl(), claims.JWKSURL)

	noScope := expectedClaimsFor(fx.nodeConf, "")
	assert.Nil(t, noScope.Scope)
}

func TestVerifyTokenFromPortal(t *testing.T) {
	fx := newPortalJWTFixture(t, "verify-token")
	spec := fx.defaultClaimsSpec()
	token := fx.issue(t, spec)

	expected := expectedClaimsFor(fx.nodeConf, clean.Scope("cluster"))
	claims := verifyTokenFromPortal(context.Background(), token, expected, []string{"wrong", spec.Issuer})
	require.NotNil(t, claims)
	assert.Equal(t, spec.Issuer, claims.Issuer)
	assert.Equal(t, spec.Subject, claims.Subject)

	nilClaims := verifyTokenFromPortal(context.Background(), token, expected, []string{"wrong"})
	assert.Nil(t, nilClaims)
}
