package auth

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

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/gin-gonic/gin"
	"golang.org/x/oauth2"
)

var (
	provMu    sync.Mutex
	provCache = map[string]*oidc.Provider{}
)

// EvictProvider drops a cached discovery document. Keyed on provider, not
// instance: an instance now has several, and evicting all of them because one
// changed would re-fetch discovery for providers nobody touched.
func EvictProvider(providerID string) {
	provMu.Lock()
	delete(provCache, providerID)
	provMu.Unlock()
}

func requestScheme(c *gin.Context) string {
	if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
		return "http"
	}
	return "https"
}

// CallbackURL must return the same string in the start and callback halves of
// one flow, or the identity provider rejects the token exchange.
func CallbackURL(c *gin.Context, providerID string) string {
	return fmt.Sprintf("%s://%s/auth/oidc/%s/callback", requestScheme(c), c.Request.Host, providerID)
}

func oauthConfigFor(ctx context.Context, c *gin.Context, p *models.AuthProvider, secret string) (*oidc.Provider, *oauth2.Config, error) {
	if p.Kind == models.KindOAuth2 {
		return nil, githubOAuthConfig(p, secret, CallbackURL(c, p.ProviderID)), nil
	}
	provMu.Lock()
	prov := provCache[p.ProviderID]
	provMu.Unlock()
	if prov == nil {
		var err error
		prov, err = oidc.NewProvider(ctx, p.Issuer)
		if err != nil {
			return nil, nil, fmt.Errorf("provider discovery failed: %w", err)
		}
		provMu.Lock()
		provCache[p.ProviderID] = prov
		provMu.Unlock()
	}
	return prov, &oauth2.Config{
		ClientID:     p.ClientID,
		ClientSecret: secret,
		RedirectURL:  CallbackURL(c, p.ProviderID),
		Endpoint:     prov.Endpoint(),
		Scopes:       p.Scopes,
	}, nil
}

// loadProvider resolves a provider strictly within one instance. There is no
// unscoped lookup: a provider ID travels in a URL, and an unscoped one would
// let a request against one instance's host drive another instance's provider.
func loadProvider(instanceID, providerID string) (*models.AuthProvider, string, error) {
	p, err := services.GetAuthProvider(instanceID, providerID)
	if err != nil {
		return nil, "", fmt.Errorf("unknown provider")
	}
	if !p.Enabled {
		return nil, "", fmt.Errorf("provider is disabled")
	}
	secret, err := services.GetAuthProviderSecret(instanceID, providerID)
	if err != nil {
		return nil, "", err
	}
	return p, secret, nil
}

func HandleSSOStart(c *gin.Context) {
	inst, ok := InstanceFromHost(c)
	if !ok {
		c.Redirect(http.StatusFound, "/login?error=unknown_host")
		return
	}
	// Losing the feature stops new SSO logins. It deliberately does not touch
	// session validation, so nobody is evicted mid-session.
	if !services.GetLicenseState(inst.InstanceID).Feature("oidc") {
		c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
		return
	}
	providerID := c.Param("providerId")
	p, secret, err := loadProvider(inst.InstanceID, providerID)
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=provider_unavailable")
		return
	}
	ctx := c.Request.Context()
	_, oauthCfg, err := oauthConfigFor(ctx, c, p, secret)
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=provider_unreachable")
		return
	}
	state, err := randomHex(16)
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=state_failed")
		return
	}
	if err := saveState(ctx, state, oidcState{InstanceID: inst.InstanceID, ProviderID: p.ProviderID}); err != nil {
		c.Redirect(http.StatusFound, "/login?error=state_failed")
		return
	}
	c.Redirect(http.StatusFound, oauthCfg.AuthCodeURL(state))
}

func HandleSSOCallback(c *gin.Context) {
	ctx := c.Request.Context()
	st, ok := consumeState(ctx, c.Query("state"))
	if !ok {
		c.Redirect(http.StatusFound, "/login?error=invalid_state")
		return
	}

	// The path segment is attacker-controlled; the state was issued by the start
	// handler. A mismatch means the two halves of this flow disagree about which
	// provider is signing somebody in, and that is not a thing to resolve by
	// picking one. The state has already been consumed, so this is not replayable.
	if c.Param("providerId") != st.ProviderID {
		c.Redirect(http.StatusFound, "/login?error=invalid_state")
		return
	}

	// The start handler checks this too, but an ungated callback is the half
	// that matters: a start that refuses is a dead end, while a callback that
	// completes signs somebody in. A licence that lapsed mid-flow stops the
	// exchange here rather than after it.
	//
	// Resolved from the consumed state rather than from the host, because on
	// this route the instance is whatever the state said and nobody is signed
	// in yet.
	if !services.GetLicenseState(st.InstanceID).Feature("oidc") {
		c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
		return
	}

	p, secret, err := loadProvider(st.InstanceID, st.ProviderID)
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=provider_unavailable")
		return
	}
	provider, oauthCfg, err := oauthConfigFor(ctx, c, p, secret)
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=provider_unreachable")
		return
	}
	token, err := oauthCfg.Exchange(ctx, c.Query("code"))
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=exchange_failed")
		return
	}

	var email, name string
	if p.Kind == models.KindOAuth2 {
		email, name, err = githubIdentity(ctx, oauthCfg, token)
		if err != nil {
			c.Redirect(http.StatusFound, "/login?error=identity_failed")
			return
		}
	} else {
		rawIDToken, ok := token.Extra("id_token").(string)
		if !ok {
			c.Redirect(http.StatusFound, "/login?error=missing_id_token")
			return
		}
		idToken, err := provider.Verifier(&oidc.Config{ClientID: oauthCfg.ClientID}).Verify(ctx, rawIDToken)
		if err != nil {
			c.Redirect(http.StatusFound, "/login?error=verification_failed")
			return
		}
		var claims struct {
			Email string `json:"email"`
			Name  string `json:"name"`
		}
		if err := idToken.Claims(&claims); err != nil || claims.Email == "" {
			c.Redirect(http.StatusFound, "/login?error=missing_email")
			return
		}
		email, name = claims.Email, claims.Name
	}

	completeSSOLogin(c, st.InstanceID, email, name)
}

// completeSSOLogin is the tail both provider kinds share: resolve the user
// within the instance, provision on first sign-in, mint the session.
func completeSSOLogin(c *gin.Context, instanceID, email, name string) {
	email = strings.ToLower(strings.TrimSpace(email))
	if email == "" {
		c.Redirect(http.StatusFound, "/login?error=missing_email")
		return
	}

	// Scoped to the instance the callback state names, so an address that also
	// exists in another instance is invisible here. That scoping replaces the
	// cross-instance guard this code used to need: there is no longer a way for
	// the lookup to return a user belonging to somebody else.
	u, err := services.GetUserInInstanceByEmail(instanceID, email)
	if err != nil {
		u, err = services.CreateUser(instanceID, email, "", models.RoleMember, models.AuthOIDC)
		if err != nil {
			c.Redirect(http.StatusFound, "/login?error=provisioning_failed")
			return
		}
	}

	sessionID, err := SaveSession(c.Request.Context(), &Session{
		UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: name,
	})
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=session_failed")
		return
	}
	_ = services.TouchLastLogin(u.UserID)
	SetSessionCookie(c, sessionID)
	c.Redirect(http.StatusFound, "/")
}

func HandleLogout(c *gin.Context) {
	if cookie, err := c.Request.Cookie(sessionCookieName); err == nil {
		_ = DeleteSession(c.Request.Context(), cookie.Value)
	}
	http.SetCookie(c.Writer, &http.Cookie{
		Name:     sessionCookieName,
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		MaxAge:   -1,
	})
	c.Redirect(http.StatusFound, "/")
}
