package api

import (
	"fmt"
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/photoprism/photoprism/internal/auth/oidc"
	"github.com/photoprism/photoprism/internal/entity"
	"github.com/photoprism/photoprism/internal/event"
	"github.com/photoprism/photoprism/internal/photoprism/get"
	"github.com/photoprism/photoprism/internal/server/limiter"
	"github.com/photoprism/photoprism/internal/thumb/avatar"
	"github.com/photoprism/photoprism/pkg/authn"
	"github.com/photoprism/photoprism/pkg/clean"
	"github.com/photoprism/photoprism/pkg/http/header"
	"github.com/photoprism/photoprism/pkg/i18n"
	"github.com/photoprism/photoprism/pkg/log/status"
	"github.com/photoprism/photoprism/pkg/rnd"
	"github.com/photoprism/photoprism/pkg/time/tz"
	"github.com/photoprism/photoprism/pkg/time/unix"
	"github.com/photoprism/photoprism/pkg/txt"
)

// OIDCRedirect completes the OIDC flow, creates a session, and renders a page that stores the token client-side.
//
//	@Summary	complete OIDC login (callback)
//	@Id			OIDCRedirect
//	@Tags		Authentication
//	@Produce	html
//	@Param		state		query		string	true	"opaque OAuth2 state value"
//	@Param		code		query		string	true	"authorization code"
//	@Success	200			{string}	string	"HTML page bootstrapping token storage"
//	@Failure	401,403,429	{string}	string	"rendered error page"
//	@Router		/api/v1/oidc/redirect [get]
func OIDCRedirect(router *gin.RouterGroup) {
	router.GET("/oidc/redirect", func(c *gin.Context) {
		// Prevent CDNs from caching this endpoint.
		if header.IsCdn(c.Request) {
			AbortNotFound(c)
			return
		}

		// Disable caching of responses.
		c.Header(header.CacheControl, header.CacheControlNoStore)

		// Get client IP address for logs and rate limiting checks.
		clientIp := ClientIP(c)
		userAgent := UserAgent(c)
		userName := "unknown user"

		// Get global config.
		conf := get.Config()

		// Abort in public mode and if OIDC is disabled.
		if get.Config().Public() {
			event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrDisabledInPublicMode.Error()})
			c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
			return
		} else if !conf.OIDCEnabled() {
			event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthenticationDisabled.Error()})
			c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
			return
		}

		// Check request rate limit.
		r := limiter.Login.Request(clientIp)

		// Abort if failure rate limit is exceeded.
		if r.Reject() || limiter.Auth.Reject(clientIp) {
			c.HTML(http.StatusTooManyRequests, "auth.gohtml", CreateSessionError(http.StatusTooManyRequests, i18n.Error(i18n.ErrTooManyRequests)))
			return
		}

		// Check if the required request parameters are present.
		if c.Query("state") == "" || c.Query("code") == "" {
			event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthCodeRequired.Error()})
			c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
			return
		}

		// Get OIDC provider.
		provider := get.OIDC()

		if provider == nil {
			event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrInvalidProviderConfiguration.Error()})
			c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
			return
		}

		// Check the auth request and, if successful, get user information and tokens.
		userInfo, tokens, claimErr := provider.CodeExchangeUserInfo(c)

		if claimErr != nil {
			event.AuditErr([]string{clientIp, "create session", "oidc", claimErr.Error()})
			return
		}

		groupClaim := conf.OIDCGroupClaim()

		var idTokenClaims map[string]any

		if tokens != nil && tokens.IDTokenClaims != nil {
			idTokenClaims = tokens.IDTokenClaims.Claims
		}

		groups, groupOverage := oidc.GroupsFromClaims(idTokenClaims, groupClaim)
		moreGroups, moreOverage := oidc.GroupsFromClaims(userInfo.Claims, groupClaim)

		// Merge groups, since some IDPs split memberships across ID tokens and user info.
		// Appending is safe because downstream helpers normalize and deduplicate.
		if len(moreGroups) > 0 {
			groups = append(groups, moreGroups...)
		}

		// "Overage" means the IdP omitted some or all groups because the user has too many; tokens carry a marker (`_claim_names.groups`).
		if moreOverage {
			groupOverage = true
		}

		requiredGroups := conf.OIDCGroup()

		if len(requiredGroups) > 0 {
			switch {
			case groupOverage && len(groups) == 0:
				message := "IdP omitted some or all groups; cannot validate required groups"
				event.AuditErr([]string{clientIp, "create session", "oidc", message})
				event.LoginError(clientIp, "oidc", userName, userAgent, message)
				c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
				return
			case !oidc.HasAnyGroup(groups, requiredGroups):
				message := "missing required group membership"
				event.AuditErr([]string{clientIp, "create session", "oidc", message})
				event.LoginError(clientIp, "oidc", userName, userAgent, message)
				c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
				return
			}
		}

		mappedRole, hasMappedRole := oidc.MapGroupsToRole(groups, conf.OIDCGroupRoles())
		defaultRole := conf.OIDCRole()

		// Step 1: Create user account if it does not exist yet.
		var user *entity.User
		var err error

		userEmail := clean.Email(userInfo.Email)

		// Optionally check if the email domain matches.
		if domain := conf.OIDCDomain(); domain == "" {
			// Do nothing.
		} else if _, emailDomain, _ := strings.Cut(userEmail, "@"); emailDomain == "" || !userInfo.EmailVerified {
			event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrVerifiedEmailRequired.Error()})
			event.LoginError(clientIp, "oidc", userEmail, userAgent, authn.ErrVerifiedEmailRequired.Error())
			c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
			return
		} else if !strings.HasSuffix("."+emailDomain, "."+domain) {
			message := fmt.Sprintf("domain must match '%s'", domain)
			event.AuditErr([]string{clientIp, "create session", "oidc", userEmail, message})
			event.LoginError(clientIp, "oidc", userEmail, userAgent, message)
			c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
			return
		}

		// Find existing user record and update it, if necessary.
		if oidcUser := entity.OidcUser(userInfo, provider.Issuer(), oidc.Username(userInfo, conf.OIDCUsername())); authn.ProviderOIDC.NotEqual(oidcUser.AuthProvider) {
			event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthProviderIsNotOIDC.Error()})
			event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrAuthProviderIsNotOIDC.Error())
			c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
			return
		} else if oidcUser.UserName == "" {
			event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrUsernameRequiredToRegister.Error()})
			event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrUsernameRequiredToRegister.Error())
			c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
			return
		} else if user = entity.FindUser(oidcUser); user != nil {
			// Ensure user has a username.
			if user.Username() == "" {
				event.AuditErr([]string{clientIp, "create session", "oidc", oidcUser.UserName, authn.ErrUsernameRequired.Error()})
				event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrUsernameRequired.Error())
				c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
				return
			}

			userName = user.Username()
			event.AuditInfo([]string{clientIp, "create session", "oidc", "found user", userName})

			// Check if the account is enabled and the OIDC Subject ID matches.
			switch {
			case !user.CanLogIn():
				event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountDisabled.Error()})
				event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountDisabled.Error())
				c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
				return
			case authn.ProviderOIDC.NotEqual(user.AuthProvider):
				event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAuthProviderIsNotOIDC.Error()})
				event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAuthProviderIsNotOIDC.Error())
				c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
				return
			case user.AuthID == "" || oidcUser.AuthID == "" || user.AuthID != oidcUser.AuthID:
				event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrInvalidAuthID.Error()})
				event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrInvalidAuthID.Error())
				c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
				return
			}

			// Update user profile information.
			details := user.Details()

			// Update user display name.
			if entity.SrcPriority[details.NameSrc] <= entity.SrcPriority[entity.SrcOIDC] {
				user.SetDisplayName(userInfo.Name, entity.SrcOIDC)
				user.SetGivenName(userInfo.GivenName)
				user.SetFamilyName(userInfo.FamilyName)
				details.UserGender = clean.Name(string(userInfo.Gender))
			}

			// Update nickname.
			if name := clean.Name(userInfo.Nickname); name != "" {
				details.NickName = clean.Name(userInfo.Nickname)
			}

			// Update profile URL.
			if u := clean.Uri(userInfo.Profile); u != "" {
				details.ProfileURL = u
			}

			// Update website URL.
			if u := clean.Uri(userInfo.Website); u != "" {
				details.SiteURL = u
			}

			// Update UI locale.
			user.Settings().UILanguage = clean.Locale(userInfo.Locale.String(), user.Settings().UILanguage)

			// Update UI timezone.
			if zone := userInfo.Zoneinfo; zone != "" && zone != tz.UTC {
				user.Settings().UITimeZone = zone
			}

			// Update user location, if available.
			if addr := userInfo.GetAddress(); addr != nil {
				user.Details().UserLocation = clean.Name(addr.Locality)
				user.Details().UserCountry = clean.TypeLowerUnderscore(addr.Country)
			}

			// Update birthday, if available.
			if birthDate := txt.ParseTime(userInfo.Birthdate, userInfo.Zoneinfo); !birthDate.IsZero() {
				user.BornAt = &birthDate
				user.Details().BirthDay = birthDate.Day()
				user.Details().BirthMonth = int(birthDate.Month())
				user.Details().BirthYear = birthDate.Year()
			}

			// Update email, if verified.
			if userInfo.EmailVerified {
				user.UserEmail = clean.Email(userInfo.Email)
				user.VerifiedAt = entity.TimeStamp()
			}

			if hasMappedRole && !user.HasRole(mappedRole) {
				user.SetRole(mappedRole.String())
			}

			// Update Subject ID and Issuer URI.
			user.SetAuthID(userInfo.Subject, provider.Issuer())

			// Update existing user account.
			if err = user.Save(); err != nil {
				event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountUpdateFailed.Error(), err.Error()})
				event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountUpdateFailed.Error()+" ("+err.Error()+")")
				c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
				return
			}

			// Set user avatar image?
			if avatarUrl := userInfo.Picture; avatarUrl != "" && !user.HasAvatar() {
				if err = avatar.SetUserImageURL(user, avatarUrl, entity.SrcOIDC, conf.ThumbCachePath()); err != nil {
					event.AuditWarn([]string{clientIp, "create session", "oidc", userName, "failed to set avatar image", err.Error()})
				}
			}
		} else if conf.UsersQuotaReached(conf.OIDCRole()) { //nolint:gocritic
			userName = oidcUser.Username()
			event.AuditWarn([]string{clientIp, "create session", "oidc", "create user", userName, authn.ErrUsersQuotaExceeded.Error()})
			event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrUsersQuotaExceeded.Error())
			c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrQuotaExceeded)))
			return
		} else if conf.OIDCRegister() {
			// Create new user record.
			user = &oidcUser

			userName = oidcUser.Username()

			// Resolve potential naming conflict by adding a random number to the username.
			if found := entity.FindUserByName(userName); found != nil {
				userName += rnd.Base10(6)
			}

			event.AuditInfo([]string{clientIp, "create session", "oidc", "create user", userName})

			user.UserName = userName

			// Set user profile information.
			user.SetDisplayName(userInfo.Name, entity.SrcOIDC)
			user.SetGivenName(userInfo.GivenName)
			user.SetFamilyName(userInfo.FamilyName)
			user.Details().UserGender = clean.Name(string(userInfo.Gender))
			user.Details().NickName = clean.Name(userInfo.Nickname)

			// Set user profile URL.
			user.Details().ProfileURL = clean.Uri(userInfo.Profile)

			// Set user site URL.
			user.Details().SiteURL = clean.Uri(userInfo.Website)

			// Set UI locale.
			user.Settings().UILanguage = clean.Locale(userInfo.Locale.String(), "")

			// Set UI timezone.
			user.Settings().UITimeZone = userInfo.Zoneinfo

			// Set user location, if available.
			if addr := userInfo.GetAddress(); addr != nil {
				user.Details().UserLocation = clean.Name(addr.Locality)
				user.Details().UserCountry = clean.TypeLowerUnderscore(addr.Country)
			}

			// Set birthday, if available.
			if birthDate := txt.ParseTime(userInfo.Birthdate, userInfo.Zoneinfo); !birthDate.IsZero() {
				user.BornAt = &birthDate
				user.Details().BirthDay = birthDate.Day()
				user.Details().BirthMonth = int(birthDate.Month())
				user.Details().BirthYear = birthDate.Year()
			}

			// Set email, if verified.
			if userInfo.EmailVerified {
				user.UserEmail = clean.Email(userInfo.Email)
				user.VerifiedAt = entity.TimeStamp()
			}

			// Set user role and permissions.
			if hasMappedRole {
				user.SetRole(mappedRole.String())
			} else {
				user.SetRole(defaultRole.String())
			}
			user.CanLogin = true
			user.WebDAV = conf.OIDCWebDAV()

			// Create new user account, and then update the Subject ID to make sure it is unique.
			if err = user.Create(); err != nil {
				event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountCreateFailed.Error(), err.Error()})
				event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountCreateFailed.Error()+" ("+err.Error()+")")
				c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
				return
			} else if err = user.UpdateAuthID(userInfo.Subject, provider.Issuer()); err != nil {
				event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountUpdateFailed.Error(), err.Error()})
				event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountUpdateFailed.Error()+" ("+err.Error()+")")
				c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
				return
			}

			// Set user avatar image.
			if avatarUrl := userInfo.Picture; avatarUrl == "" {
				event.AuditDebug([]string{clientIp, "create session", "oidc", userName, "no avatar image provided"})
			} else if err = avatar.SetUserImageURL(user, avatarUrl, entity.SrcOIDC, conf.ThumbCachePath()); err != nil {
				event.AuditWarn([]string{clientIp, "create session", "oidc", userName, "failed to set avatar image", err.Error()})
			}
		} else {
			event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrRegistrationDisabled.Error()})
			event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrRegistrationDisabled.Error())
			c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
			return
		}

		// Check if login is allowed.
		if !user.CanLogIn() {
			event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountDisabled.Error()})
			event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountDisabled.Error())
			c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
			return
		}

		// Step 2: Create user session.
		sess := get.Session().New(c)
		sess.SetProvider(authn.ProviderOIDC)
		sess.SetMethod(authn.MethodDefault)
		sess.SetAuthID(user.AuthID, provider.Issuer())
		sess.SetUser(user)
		sess.SetGrantType(authn.GrantAuthorizationCode)

		// Ensure that the ID token fits into the existing
		// database column; otherwise, truncate it.
		if n := len(tokens.IDToken); n > 2048 {
			sess.IdToken = tokens.IDToken[:2048]
		} else {
			sess.IdToken = tokens.IDToken
		}

		// Set session expiration and timeout.
		sess.SetExpiresIn(unix.Day)
		sess.SetTimeout(-1)

		// Save session after successful authentication.
		if sess, err = get.Session().Save(sess); err != nil {
			event.AuditErr([]string{clientIp, "create session", "oidc", userName, status.Error(err)})
			c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
			return
		} else if sess == nil {
			event.AuditErr([]string{clientIp, "create session", "oidc", userName, status.Failed})
			c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrUnexpected)))
			return
		}

		// Return the reserved request rate limit token after successful authentication.
		r.Success()

		// Response includes user data, session data, and client config values.
		response := CreateSessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess))

		// Log session created event.
		event.AuditInfo([]string{clientIp, "session %s", "oidc", userName, status.Created}, sess.RefID)

		// Log session expiration time.
		if expires := sess.ExpiresAt(); !expires.IsZero() {
			event.AuditDebug([]string{clientIp, "session %s", "oidc", userName, "expires at %s"}, sess.RefID, txt.DateTime(&expires))
		}

		// Log successful login.
		event.LoginInfo(clientIp, "oidc", userName, userAgent)

		// Update login timestamp.
		user.UpdateLoginTime()

		// Step 3: Render HTML template to set the access token in localStorage.
		c.HTML(http.StatusOK, "auth.gohtml", response)
	})
}
